@lanes-sh/link 0.7.2 → 0.9.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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- 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/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- 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 +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- 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 +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import type { SecretStore } from '#secrets';
|
|
3
|
+
import { credentialRefFor } from '#registry';
|
|
4
|
+
import type { ProviderRegistry } from '#registry';
|
|
5
|
+
import type { ConnectionConfig } from '#profile';
|
|
6
|
+
import { rotatableCredentialRefs } from '#connectivity';
|
|
7
|
+
import type { Renames } from './contract4-data.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Move each stored credential to the ref its renamed connection now derives.
|
|
11
|
+
*
|
|
12
|
+
* **The step without which the rename loses every account.** A `credential_ref`
|
|
13
|
+
* is almost never written down: `credentialRefFor` derives it as
|
|
14
|
+
* `<provider>/<connection>`, so renaming `gmail.main_2` to `gmail.con17`
|
|
15
|
+
* silently repoints the lookup at a key nothing holds. Config, blobs and state
|
|
16
|
+
* all move; the secret stays where it was, and the endpoint reports "no stored
|
|
17
|
+
* credential" for an account that is still perfectly well authorised.
|
|
18
|
+
*
|
|
19
|
+
* Found by rehearsing the migration against a real workspace, where it turned
|
|
20
|
+
* six live credentials into six re-authorisations. ADR-051 records why that is
|
|
21
|
+
* not an acceptable release note.
|
|
22
|
+
*
|
|
23
|
+
* Refs come from the same authority the runtime resolves them with, rather than
|
|
24
|
+
* from a pattern over the store's keys. A ref is not always
|
|
25
|
+
* `<provider>/<id>` — an `oauth_apps` entry puts the app's name in front
|
|
26
|
+
* instead — and matching on the last segment cannot tell `google/main` for a
|
|
27
|
+
* renamed Gmail connection from `google/main` for a renamed Drive one.
|
|
28
|
+
*/
|
|
29
|
+
export interface CredentialMove {
|
|
30
|
+
readonly from: string;
|
|
31
|
+
readonly to: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function planCredentialMoves(
|
|
35
|
+
connections: readonly ConnectionConfig[],
|
|
36
|
+
registry: ProviderRegistry,
|
|
37
|
+
renames: Renames,
|
|
38
|
+
): CredentialMove[] {
|
|
39
|
+
const moves: CredentialMove[] = [];
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const connection of connections) {
|
|
43
|
+
const to = renames.get(`${connection.provider}.${connection.id}`);
|
|
44
|
+
if (to === undefined) continue;
|
|
45
|
+
|
|
46
|
+
const id = to.slice(to.indexOf('.') + 1);
|
|
47
|
+
if (id === connection.id) continue;
|
|
48
|
+
|
|
49
|
+
const manifest = registry.manifest(connection.provider);
|
|
50
|
+
const after = { ...connection, id };
|
|
51
|
+
|
|
52
|
+
// A ref the row states outright does not move: it names a key the operator
|
|
53
|
+
// chose, and the id it happens to contain is not this migration's to read.
|
|
54
|
+
const refs: [string | undefined, string | undefined][] = [
|
|
55
|
+
[credentialRefFor(connection, manifest), credentialRefFor(after, manifest)],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// An OAuth connection also holds the token blob `saveTokens` rewrites, and
|
|
59
|
+
// it is the one that matters — the hot path of an ordinary read.
|
|
60
|
+
if (manifest) {
|
|
61
|
+
const before = rotatableCredentialRefs(manifest, connection.id);
|
|
62
|
+
const now = rotatableCredentialRefs(manifest, id);
|
|
63
|
+
for (let index = 0; index < before.length; index += 1) {
|
|
64
|
+
refs.push([before[index], now[index]]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const [from, into] of refs) {
|
|
69
|
+
if (from === undefined || into === undefined || from === into) continue;
|
|
70
|
+
|
|
71
|
+
// Keyed on the *pair*, not on the source. Several connections can derive
|
|
72
|
+
// one ref — three iCloud surfaces authorised as one account share
|
|
73
|
+
// `icloud/<id>` through `auth.app` — and giving distinct ids to each
|
|
74
|
+
// turns one credential into three. Deduping on the source sent it to
|
|
75
|
+
// whichever connection came first and orphaned the rest, which is what a
|
|
76
|
+
// real workspace showed on the first rehearsal.
|
|
77
|
+
const pair = `${from}\u0000${into}`;
|
|
78
|
+
if (seen.has(pair)) continue;
|
|
79
|
+
seen.add(pair);
|
|
80
|
+
moves.push({ from, to: into });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return moves;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Apply them, reading each value back before the old key is removed.
|
|
89
|
+
*
|
|
90
|
+
* The same rule the blob mover follows: a credential that half moved and a
|
|
91
|
+
* source already gone is the one state with nothing to retry from. A ref whose
|
|
92
|
+
* destination is already occupied is left alone rather than overwritten —
|
|
93
|
+
* that is a rerun finding its own work, and clobbering it would be the one
|
|
94
|
+
* irreversible thing here.
|
|
95
|
+
*/
|
|
96
|
+
export async function applyCredentialMoves(
|
|
97
|
+
store: SecretStore,
|
|
98
|
+
moves: readonly CredentialMove[],
|
|
99
|
+
): Promise<string[]> {
|
|
100
|
+
const applied: string[] = [];
|
|
101
|
+
const skipped: string[] = [];
|
|
102
|
+
|
|
103
|
+
// Grouped by source, because one credential may feed several destinations
|
|
104
|
+
// and the source may only go once every one of them is written. Deleting
|
|
105
|
+
// after the first would take it away from the connections still to come.
|
|
106
|
+
const bySource = new Map<string, string[]>();
|
|
107
|
+
for (const move of moves) {
|
|
108
|
+
const into = bySource.get(move.from) ?? [];
|
|
109
|
+
into.push(move.to);
|
|
110
|
+
bySource.set(move.from, into);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [from, destinations] of bySource) {
|
|
114
|
+
const value = await store.get(from);
|
|
115
|
+
if (value === null) continue;
|
|
116
|
+
|
|
117
|
+
// **The source goes only once every destination holds this value.** The
|
|
118
|
+
// delete used to sit outside this loop while the loop skipped an occupied
|
|
119
|
+
// destination — so a ref already holding something (a stale credential a
|
|
120
|
+
// `disconnect --keep-credential` left behind, or an unrelated account's)
|
|
121
|
+
// meant nothing was written and the source was destroyed anyway, reported
|
|
122
|
+
// as a successful move. `migrate-move.ts` guards the same hazard with
|
|
123
|
+
// `sameBytes`, and this is that guard.
|
|
124
|
+
let carried = true;
|
|
125
|
+
|
|
126
|
+
for (const to of destinations) {
|
|
127
|
+
const held = await store.get(to);
|
|
128
|
+
|
|
129
|
+
if (held !== null) {
|
|
130
|
+
// Our own earlier work, so this destination is done. Anything else is
|
|
131
|
+
// somebody's credential and is not ours to overwrite or to delete the
|
|
132
|
+
// source out from under.
|
|
133
|
+
if (held !== value) {
|
|
134
|
+
carried = false;
|
|
135
|
+
skipped.push(
|
|
136
|
+
`${from} → ${to}: ${to} already holds a different value, so nothing was moved or ` +
|
|
137
|
+
'deleted. Remove or rename it and run this again.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await store.set(to, value);
|
|
144
|
+
if ((await store.get(to)) === null) {
|
|
145
|
+
throw new ConfigError(
|
|
146
|
+
`${to} did not read back after being written. Nothing has been deleted; ` +
|
|
147
|
+
'fix the credential store and run this again.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!carried) continue;
|
|
153
|
+
|
|
154
|
+
await store.delete(from);
|
|
155
|
+
applied.push(`${from} → ${destinations.join(', ')}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [...applied, ...skipped];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The sealed vault document, which `credentialRefFor` cannot see.
|
|
163
|
+
*
|
|
164
|
+
* A vault connection's `auth.kind` is `none`, so `credentialRefForConnection`
|
|
165
|
+
* returns `undefined` for it and the loop above skips it entirely — while
|
|
166
|
+
* `vaultRef` names the document `vault/<profile>/<connection>` and a deployed
|
|
167
|
+
* target seals it in Secret Manager. Contract 4 renames the connection and
|
|
168
|
+
* gives the ref a profile, so without this the revision opens a name nothing
|
|
169
|
+
* created: every vault item reads as absent, with no error, and the ciphertext
|
|
170
|
+
* sits orphaned under the old ref.
|
|
171
|
+
*
|
|
172
|
+
* Invisible in a local rehearsal, because the `file` and `blob` adapters take
|
|
173
|
+
* their path from `layout` and were already correct. `secret` is the adapter
|
|
174
|
+
* every deployment uses.
|
|
175
|
+
*
|
|
176
|
+
* A `ref` the target states outright is left alone: it names a document the
|
|
177
|
+
* operator chose, and a deployment already sealing under it has to keep
|
|
178
|
+
* opening it.
|
|
179
|
+
*/
|
|
180
|
+
export function planVaultMoves(
|
|
181
|
+
profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
|
|
182
|
+
renames: Renames,
|
|
183
|
+
declaredRef: string | undefined,
|
|
184
|
+
): CredentialMove[] {
|
|
185
|
+
if (declaredRef !== undefined) return [];
|
|
186
|
+
|
|
187
|
+
const moves: CredentialMove[] = [];
|
|
188
|
+
|
|
189
|
+
for (const [profile, config] of profiles) {
|
|
190
|
+
const granted = (config.grants ?? [])
|
|
191
|
+
.map((grant) => grant.connection)
|
|
192
|
+
.find((ref): ref is string => typeof ref === 'string' && ref.startsWith('vault.'));
|
|
193
|
+
|
|
194
|
+
// `main` where the profile grants none, which is what `vaultRef` falls back
|
|
195
|
+
// to — so a profile that denied the vault still finds its own document
|
|
196
|
+
// rather than another profile's.
|
|
197
|
+
const was = granted === undefined ? 'main' : granted.slice('vault.'.length);
|
|
198
|
+
const to = granted === undefined ? undefined : renames.get(granted);
|
|
199
|
+
const now = to === undefined ? was : to.slice(to.indexOf('.') + 1);
|
|
200
|
+
|
|
201
|
+
const from = `vault/${was}`;
|
|
202
|
+
const into = `vault/${profile}/${now}`;
|
|
203
|
+
if (from !== into) moves.push({ from, to: into });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return moves;
|
|
207
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { layout } from '#profile';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
3
|
+
import {
|
|
4
|
+
decodeSegment,
|
|
5
|
+
DISCOVERY_NAMESPACE,
|
|
6
|
+
encodeSegment,
|
|
7
|
+
isWorkspaceNamespace,
|
|
8
|
+
OAUTH_NAMESPACE,
|
|
9
|
+
} from '#stores/state';
|
|
10
|
+
import { C3 } from './contract3-layout.ts';
|
|
11
|
+
import { planRenames, renamedProvider } from './contract4-rename.ts';
|
|
12
|
+
import { claim, type Move } from './migrate-move.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The half of the contract-4 migration that moves bytes rather than YAML.
|
|
16
|
+
*
|
|
17
|
+
* Contract 3 hoisted everything a profile owned up to the workspace; this
|
|
18
|
+
* carries it back down (ADR-066), and flattens what stays at the workspace out
|
|
19
|
+
* of `data/` while it is here (ADR-067). Same seam as `contract3-data.ts`, and
|
|
20
|
+
* the same ordering rule: everything that can refuse happens while this is
|
|
21
|
+
* still a plan.
|
|
22
|
+
*
|
|
23
|
+
* **The interesting decision is what to do with a store two profiles grant.**
|
|
24
|
+
* Contract 3's default gives every profile a grant on the same `.main`
|
|
25
|
+
* instances, so the common case in a freshly created workspace is that both
|
|
26
|
+
* profiles point at one memory. There is no answer that is right for everyone:
|
|
27
|
+
* moving it into one profile takes it away from the other, and merging is not a
|
|
28
|
+
* thing bytes can do. So it is **copied into each, and the original is left**.
|
|
29
|
+
* The operator is told, and deletes what they do not want — which is the only
|
|
30
|
+
* step in this migration that is theirs rather than ours.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Which profiles grant a connection, by `<provider>.<id>`. */
|
|
34
|
+
export type Granting = ReadonlyMap<string, readonly string[]>;
|
|
35
|
+
|
|
36
|
+
/** Old `<provider>.<id>` to new, for every connection this workspace holds. */
|
|
37
|
+
export type Renames = ReadonlyMap<string, string>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The new id for every connection, allocated in file order.
|
|
41
|
+
*
|
|
42
|
+
* File order rather than anything derived, so a rerun produces the same map and
|
|
43
|
+
* a preview matches the apply. A row that already carries an allocated id keeps
|
|
44
|
+
* it — this migration is not a renumbering, and running it twice must not walk
|
|
45
|
+
* `con1` to `con2`.
|
|
46
|
+
*
|
|
47
|
+
* The reserved ids are the owner layer's, and they are what the `lan` prefix is
|
|
48
|
+
* for: a reader can tell a surface built into Lanes from somebody's account
|
|
49
|
+
* without resolving anything.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface DataPlan {
|
|
53
|
+
readonly moves: readonly Move[];
|
|
54
|
+
/**
|
|
55
|
+
* What was copied rather than moved, and into which profiles.
|
|
56
|
+
*
|
|
57
|
+
* Reported so the operator can delete the original. Never deleted here: a
|
|
58
|
+
* store two profiles were sharing is one whose owner has to decide, and this
|
|
59
|
+
* migration guessing would be the silent wrong answer ADR-059 warns about for
|
|
60
|
+
* the vault in particular.
|
|
61
|
+
*/
|
|
62
|
+
readonly shared: readonly { readonly key: string; readonly profiles: readonly string[] }[];
|
|
63
|
+
/** Granted by nobody. Left exactly where it is, and named. */
|
|
64
|
+
readonly orphaned: readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* Per-profile credential stores contract 3 merged but did not delete.
|
|
67
|
+
*
|
|
68
|
+
* Named separately: they are a decryptable document and its key, and "no
|
|
69
|
+
* profile grants it" is the wrong sentence about one.
|
|
70
|
+
*/
|
|
71
|
+
readonly leftover: readonly string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where every object under `data/` is going.
|
|
76
|
+
*
|
|
77
|
+
* Anything matching no rule is left where it is — this moves what it
|
|
78
|
+
* understands and never deletes what it does not.
|
|
79
|
+
*/
|
|
80
|
+
export async function planMoves(
|
|
81
|
+
files: BlobStore,
|
|
82
|
+
granting: Granting,
|
|
83
|
+
profiles: readonly string[],
|
|
84
|
+
renames: Renames = new Map(),
|
|
85
|
+
): Promise<DataPlan> {
|
|
86
|
+
/** The id a connection ends up with — itself, where nothing renamed it. */
|
|
87
|
+
const renamed = (provider: string, id: string): string => {
|
|
88
|
+
const to = renames.get(`${provider}.${id}`);
|
|
89
|
+
return to === undefined ? id : to.slice(to.indexOf('.') + 1);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const moves: Move[] = [];
|
|
93
|
+
const shared: { key: string; profiles: readonly string[] }[] = [];
|
|
94
|
+
const orphaned: string[] = [];
|
|
95
|
+
const leftover: string[] = [];
|
|
96
|
+
const claimed = new Set<string>();
|
|
97
|
+
|
|
98
|
+
for (const blob of await files.list('data/')) {
|
|
99
|
+
const parts = blob.key.split('/').slice(1);
|
|
100
|
+
const head = parts[0];
|
|
101
|
+
const tail = parts.slice(1);
|
|
102
|
+
if (head === undefined) continue;
|
|
103
|
+
|
|
104
|
+
// The four the workspace keeps, out of `data/` and up to the root.
|
|
105
|
+
if (head === 'credentials.enc' || head === 'credentials.enc.key') {
|
|
106
|
+
moves.push({ from: blob.key, to: head });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (head === 'providers.d') {
|
|
110
|
+
moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (head === 'audit.log') {
|
|
114
|
+
moves.push({ from: blob.key, to: `${layout.audit()}/${tail.join('/')}` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (head === 'state.kv') {
|
|
118
|
+
for (const move of stateMoves(blob.key, tail, granting, claimed, renames, orphaned)) {
|
|
119
|
+
moves.push(move);
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `vault.d/<id>.enc` and `skills.d/<id>/...` — the two the profile takes
|
|
125
|
+
// back, keyed by the connection that already owns them.
|
|
126
|
+
if (head === 'vault.d' || head === 'skills.d') {
|
|
127
|
+
const connection = tail[0];
|
|
128
|
+
if (connection === undefined) continue;
|
|
129
|
+
|
|
130
|
+
const provider = head === 'vault.d' ? 'vault' : 'skills';
|
|
131
|
+
// A vault document is `<id>.enc`, so the id has the suffix stripped; a
|
|
132
|
+
// skills directory is the id itself.
|
|
133
|
+
const id = provider === 'vault' ? connection.replace(/\.enc(\.key)?$/, '') : connection;
|
|
134
|
+
const owners = granting.get(`${provider}.${id}`) ?? [];
|
|
135
|
+
|
|
136
|
+
const to = renamed(provider, id);
|
|
137
|
+
const into = (profile: string): string =>
|
|
138
|
+
provider === 'vault'
|
|
139
|
+
? `${layout.vaultRoot(profile)}/${connection.replace(id, to)}`
|
|
140
|
+
: `${layout.skills(profile, to)}/${tail.slice(1).join('/')}`;
|
|
141
|
+
|
|
142
|
+
record(blob.key, owners, into, moves, shared, orphaned);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// **A contract-2 leftover is a credential, not an ungranted store.**
|
|
147
|
+
// Contract 3 merges `data/<profile>/credentials.enc` and never deletes it,
|
|
148
|
+
// so a workspace that came through it still holds one per profile — and
|
|
149
|
+
// "no profile grants it" is true and is the wrong sentence about a
|
|
150
|
+
// decryptable document: the operator reads it as tidy-up and leaves it.
|
|
151
|
+
if (tail[0] === 'credentials.enc' || tail[0] === 'credentials.enc.key') {
|
|
152
|
+
leftover.push(blob.key);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Otherwise `<provider>/<connection>/…`, the namespace every provider's
|
|
157
|
+
// blobs are scoped into — memory, tasks, assets, entities and every vendor.
|
|
158
|
+
const connection = tail[0];
|
|
159
|
+
if (connection === undefined) continue;
|
|
160
|
+
|
|
161
|
+
const ref = `${head}.${connection}`;
|
|
162
|
+
const owners = granting.get(ref) ?? [];
|
|
163
|
+
|
|
164
|
+
// Both segments from the one rename: one without the other reads nothing.
|
|
165
|
+
const settled = renames.get(ref) ?? ref;
|
|
166
|
+
const dot = settled.indexOf('.');
|
|
167
|
+
const into = `${settled.slice(0, dot)}/${settled.slice(dot + 1)}`;
|
|
168
|
+
|
|
169
|
+
record(
|
|
170
|
+
blob.key,
|
|
171
|
+
owners,
|
|
172
|
+
(profile) => `${layout.blobs(profile)}/${into}/${tail.slice(1).join('/')}`,
|
|
173
|
+
moves,
|
|
174
|
+
shared,
|
|
175
|
+
orphaned,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The declarations themselves, into the directory each now owns.
|
|
180
|
+
for (const profile of profiles) {
|
|
181
|
+
const from = C3.profile(profile);
|
|
182
|
+
if (await files.has(from)) moves.push({ from, to: layout.profileConfig(profile) });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { moves, shared, orphaned, leftover };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* One object, routed by how many profiles grant the connection it belongs to.
|
|
190
|
+
*
|
|
191
|
+
* Exactly one is a move. Several is a copy into each, with the original left
|
|
192
|
+
* behind — see the header. None is left alone and named: a store nothing grants
|
|
193
|
+
* is not this migration's to place, and deleting it would be the one
|
|
194
|
+
* irreversible thing in here.
|
|
195
|
+
*/
|
|
196
|
+
function record(
|
|
197
|
+
key: string,
|
|
198
|
+
owners: readonly string[],
|
|
199
|
+
into: (profile: string) => string,
|
|
200
|
+
moves: Move[],
|
|
201
|
+
shared: { key: string; profiles: readonly string[] }[],
|
|
202
|
+
orphaned: string[],
|
|
203
|
+
): void {
|
|
204
|
+
if (owners.length === 0) {
|
|
205
|
+
orphaned.push(key);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (owners.length === 1) {
|
|
210
|
+
moves.push({ from: key, to: into(owners[0]!) });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// `keep` rather than a move: `applyMoves` deletes the source once the copy
|
|
215
|
+
// reads back, and there is a second destination still to write.
|
|
216
|
+
for (const profile of owners) moves.push({ from: key, to: into(profile), keep: true });
|
|
217
|
+
shared.push({ key, profiles: owners });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Where one object under `data/state.kv` goes — and it may go to two places.
|
|
222
|
+
*
|
|
223
|
+
* The split is by namespace rather than by key: connection records, the
|
|
224
|
+
* discovery cache and the endpoint's own OAuth server are the workspace's, and
|
|
225
|
+
* everything else is one profile's use of an account. `isWorkspaceNamespace` in
|
|
226
|
+
* `#stores/state` is that rule and this reads it rather than restating it.
|
|
227
|
+
*
|
|
228
|
+
* A cursor belongs to whichever profiles grant its connection, so it takes the
|
|
229
|
+
* same fan-out as a blob does. The alternative — one cursor shared — is exactly
|
|
230
|
+
* the bug ADR-066 is fixing, two agents consuming each other's position.
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* The namespaces contract 3 spelled without a dot, and contract 4 does.
|
|
234
|
+
*
|
|
235
|
+
* `discovery` and `oauth/…` were reachable by a provider — only `custom` is
|
|
236
|
+
* refused by grammar, so a manifest with id `oauth` landed inside them — and a
|
|
237
|
+
* dotted name is one no provider can reach. Renaming them is only half the job:
|
|
238
|
+
* the objects already written under the old spelling have to come across, or
|
|
239
|
+
* every client that had signed in is signed out and every discovered spec is
|
|
240
|
+
* fetched again. The rehearsal that found this left 75 OAuth records behind.
|
|
241
|
+
*/
|
|
242
|
+
const RENAMED_NAMESPACES: readonly [string, string][] = [
|
|
243
|
+
['discovery', DISCOVERY_NAMESPACE],
|
|
244
|
+
['oauth', OAUTH_NAMESPACE],
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/** `<provider>/<connection>` as a state namespace is `<provider>.<connection>` as a ref. */
|
|
248
|
+
function refOf(namespace: string): string {
|
|
249
|
+
const slash = namespace.indexOf('/');
|
|
250
|
+
return slash === -1 ? namespace : `${namespace.slice(0, slash)}.${namespace.slice(slash + 1)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function stateMoves(
|
|
254
|
+
from: string,
|
|
255
|
+
tail: readonly string[],
|
|
256
|
+
granting: Granting,
|
|
257
|
+
claimed: Set<string>,
|
|
258
|
+
renames: Renames = new Map(),
|
|
259
|
+
orphaned: string[] = [],
|
|
260
|
+
): Move[] {
|
|
261
|
+
const leaf = tail[tail.length - 1];
|
|
262
|
+
|
|
263
|
+
// **Guarded, the way `contract3-data.ts` guards it and for its reason.**
|
|
264
|
+
// `decodeSegment` throws `URIError` on a stray `%`, and contract 3's catch
|
|
265
|
+
// moves such a key *verbatim* — so the class of key that survives contract 3
|
|
266
|
+
// by design is exactly the class this read unguarded, and one of them threw
|
|
267
|
+
// `URI malformed` naming no file out of every command including the preview.
|
|
268
|
+
// A key this cannot parse is carried across unchanged rather than dropped.
|
|
269
|
+
if (leaf === undefined || tail.length < 2 || !leaf.endsWith('.json')) {
|
|
270
|
+
const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
|
|
271
|
+
return move === null ? [] : [move];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let namespace: string;
|
|
275
|
+
let key: string;
|
|
276
|
+
try {
|
|
277
|
+
namespace = tail.slice(0, -1).map(decodeSegment).join('/');
|
|
278
|
+
key = decodeSegment(leaf.replace(/\.json$/, ''));
|
|
279
|
+
} catch {
|
|
280
|
+
const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
|
|
281
|
+
return move === null ? [] : [move];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The undotted spellings, carried across before anything else looks at them.
|
|
285
|
+
for (const [was, now] of RENAMED_NAMESPACES) {
|
|
286
|
+
if (namespace !== was && !namespace.startsWith(`${was}/`)) continue;
|
|
287
|
+
|
|
288
|
+
// Only the first segment. `oauth/tokens` is two segments and each is
|
|
289
|
+
// encoded on its own, so encoding `oauth.v1/tokens` whole would escape the
|
|
290
|
+
// separator too and write one segment named `oauth%2Ev1%2Ftokens`.
|
|
291
|
+
const move = claim(claimed, {
|
|
292
|
+
from,
|
|
293
|
+
to: `${layout.state()}/${[encodeSegment(now), ...tail.slice(1)].join('/')}`,
|
|
294
|
+
});
|
|
295
|
+
return move === null ? [] : [move];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (isWorkspaceNamespace(namespace)) {
|
|
299
|
+
// A connection record is keyed on the ref *and* carries it in the body:
|
|
300
|
+
// `ConnectionRepository.list` reads `provider` and `id` out of the record,
|
|
301
|
+
// not out of the key. A renamed connection whose bytes moved verbatim would
|
|
302
|
+
// sit at `connections.v1/gmail.con1` still calling itself `gmail.main`, and
|
|
303
|
+
// the next reconcile would write a second record beside it.
|
|
304
|
+
const settled = namespace === 'connections.v1' ? renames.get(key) : undefined;
|
|
305
|
+
const to = `${layout.state()}/${
|
|
306
|
+
settled === undefined ? tail.join('/') : `${tail[0]!}/${encodeSegment(settled)}.json`
|
|
307
|
+
}`;
|
|
308
|
+
|
|
309
|
+
// Two profiles both hold a record for a connection they share, and the
|
|
310
|
+
// second differs only in when it was written.
|
|
311
|
+
const move = claim(claimed, {
|
|
312
|
+
from,
|
|
313
|
+
to,
|
|
314
|
+
...(settled === undefined
|
|
315
|
+
? {}
|
|
316
|
+
: { rewrite: (data: Uint8Array) => retarget(data, settled) }),
|
|
317
|
+
});
|
|
318
|
+
return move === null ? [] : [move];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A cursor's namespace is `cursors.v1` and its *key* is the connection; a
|
|
322
|
+
// provider's own store is namespaced `<provider>/<connection>` instead — and
|
|
323
|
+
// that slash is not the dot a grant is keyed on. Looking the one up with the
|
|
324
|
+
// other matched nothing and dropped every provider's state in silence, which
|
|
325
|
+
// is the same shape of bug `contract3-data.ts` records having shipped once.
|
|
326
|
+
const cursor = namespace === 'cursors.v1';
|
|
327
|
+
const ref = cursor ? key : refOf(namespace);
|
|
328
|
+
const owners = granting.get(ref) ?? [];
|
|
329
|
+
|
|
330
|
+
if (owners.length === 0) {
|
|
331
|
+
orphaned.push(from);
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const settled = renames.get(ref) ?? ref;
|
|
336
|
+
const dot = settled.indexOf('.');
|
|
337
|
+
|
|
338
|
+
// The rename reaches whichever half carries the ref, and only that half.
|
|
339
|
+
const moved = cursor
|
|
340
|
+
? `${tail[0]!}/${encodeSegment(settled)}.json`
|
|
341
|
+
: [
|
|
342
|
+
encodeSegment(settled.slice(0, dot)),
|
|
343
|
+
encodeSegment(settled.slice(dot + 1)),
|
|
344
|
+
...tail.slice(2),
|
|
345
|
+
].join('/');
|
|
346
|
+
|
|
347
|
+
return owners.map((profile) => ({
|
|
348
|
+
from,
|
|
349
|
+
to: `${layout.profileState(profile)}/${moved}`,
|
|
350
|
+
keep: owners.length > 1,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A connection record, told what it is now called.
|
|
356
|
+
*
|
|
357
|
+
* Spread rather than assigned field by field, so key order and anything a later
|
|
358
|
+
* version added survive. Bytes that are not a JSON object come back untouched:
|
|
359
|
+
* refusing to move what will not parse strands it where nothing reads it.
|
|
360
|
+
*/
|
|
361
|
+
function retarget(data: Uint8Array, ref: string): Uint8Array {
|
|
362
|
+
try {
|
|
363
|
+
const held = JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>;
|
|
364
|
+
if (held === null || typeof held !== 'object' || Array.isArray(held)) return data;
|
|
365
|
+
|
|
366
|
+
const dot = ref.indexOf('.');
|
|
367
|
+
return new TextEncoder().encode(
|
|
368
|
+
JSON.stringify({ ...held, provider: ref.slice(0, dot), id: ref.slice(dot + 1) }),
|
|
369
|
+
);
|
|
370
|
+
} catch {
|
|
371
|
+
return data;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Which profiles grant each connection, read off the profiles themselves.
|
|
377
|
+
*
|
|
378
|
+
* Derived rather than assumed, because the fan-out above is the whole decision:
|
|
379
|
+
* a connection nobody grants must not be placed anywhere, and one two profiles
|
|
380
|
+
* grant must not be placed in only the first. `connections.yaml` cannot answer
|
|
381
|
+
* this — it says what exists, not who selected it (ADR-057).
|
|
382
|
+
*/
|
|
383
|
+
export function grantingProfiles(
|
|
384
|
+
configs: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
|
|
385
|
+
): Granting {
|
|
386
|
+
const granting = new Map<string, string[]>();
|
|
387
|
+
|
|
388
|
+
for (const [profile, config] of configs) {
|
|
389
|
+
for (const grant of config.grants ?? []) {
|
|
390
|
+
const ref = grant.connection;
|
|
391
|
+
if (typeof ref !== 'string') continue;
|
|
392
|
+
const owners = granting.get(ref) ?? [];
|
|
393
|
+
if (!owners.includes(profile)) owners.push(profile);
|
|
394
|
+
granting.set(ref, owners);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return granting;
|
|
399
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { nextConnectionId } from './identity.ts';
|
|
2
|
+
import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
|
|
3
|
+
import type { Renames } from './contract4-data.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What every connection is called after contract 4.
|
|
7
|
+
*
|
|
8
|
+
* Its own module because it is one decision read by four rewrites — the rows,
|
|
9
|
+
* the grants, the blob paths and the state keys — and because a map keyed one
|
|
10
|
+
* way and queried another is the bug `contract3-data.ts` records shipping once.
|
|
11
|
+
* One place to build it, one place for everything else to read it from.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The id and provider every connection ends up with.
|
|
16
|
+
*
|
|
17
|
+
* Two things happen here, and the merge is the interesting one.
|
|
18
|
+
*
|
|
19
|
+
* **The owner layer merges to one row per surface.** Contract 3 made the
|
|
20
|
+
* *connection* the isolation boundary, so three profiles each needed their own
|
|
21
|
+
* `memory` — a workspace with three profiles came out of that migration holding
|
|
22
|
+
* `memory.main`, `memory.work` and `memory.demo`, and the file read as
|
|
23
|
+
* duplicated because it was. ADR-066 makes the *profile* the boundary, so one
|
|
24
|
+
* row serves every profile and each still keeps its own bytes at
|
|
25
|
+
* `profiles/<name>/lanes_memory/lan1/`. Merging the rows merges no notes, which
|
|
26
|
+
* is exactly why contract 3 could not do it and this can.
|
|
27
|
+
*
|
|
28
|
+
* **Both sequences are numbered cleanly**, `con1..conN` over the accounts in
|
|
29
|
+
* file order and `lan1..lan8` over the merged owner rows. A rerun produces the
|
|
30
|
+
* same map because the order is the file's, and a row already carrying an
|
|
31
|
+
* allocated id keeps it — this is not a renumbering, and running it twice must
|
|
32
|
+
* not walk `con1` to `con2`.
|
|
33
|
+
*/
|
|
34
|
+
export function planRenames(
|
|
35
|
+
connections: readonly { id: string; provider: string }[],
|
|
36
|
+
): Renames {
|
|
37
|
+
const renames = new Map<string, string>();
|
|
38
|
+
const taken = connections
|
|
39
|
+
.map((row) => row.id)
|
|
40
|
+
.filter((id) => /^(lan|con)[0-9]+$/.test(id));
|
|
41
|
+
|
|
42
|
+
// One destination per owner-layer *provider*, so every profile's instance of
|
|
43
|
+
// it lands on the same row.
|
|
44
|
+
const merged = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
for (const row of connections) {
|
|
47
|
+
const owner = C3_OWNER_PROVIDERS.includes(row.provider);
|
|
48
|
+
const provider = renamedProvider(row.provider);
|
|
49
|
+
const from = `${row.provider}.${row.id}`;
|
|
50
|
+
|
|
51
|
+
if (owner) {
|
|
52
|
+
const already = merged.get(provider);
|
|
53
|
+
if (already !== undefined) {
|
|
54
|
+
renames.set(from, already);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const id = /^(lan|con)[0-9]+$/.test(row.id) ? row.id : nextConnectionId(taken, owner);
|
|
60
|
+
if (!taken.includes(id)) taken.push(id);
|
|
61
|
+
|
|
62
|
+
const to = `${provider}.${id}`;
|
|
63
|
+
renames.set(from, to);
|
|
64
|
+
if (owner) merged.set(provider, to);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return renames;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `memory` becomes `lanes_memory`; everything else keeps its id. */
|
|
71
|
+
export function renamedProvider(provider: string): string {
|
|
72
|
+
return C3_OWNER_PROVIDERS.includes(provider) ? `lanes_${provider}` : provider;
|
|
73
|
+
}
|