@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.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,212 @@
1
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+ import type { ContractRename, LegacyConnection, LegacyProfile } from './contract3.ts';
3
+ import { keyOf } from './contract3.ts';
4
+ import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
5
+
6
+ /**
7
+ * Turning contract 2's shape into contract 3's, without touching a file.
8
+ *
9
+ * Split from the flow because these are the decisions and that is the ordering:
10
+ * which connections exist after hoisting, what each profile's grants become, and
11
+ * what a rule with an expiry means. Every defect review found in this migration
12
+ * was in one of those three, and none of them needed a filesystem to reproduce.
13
+ */
14
+
15
+ /**
16
+ * Hoist every profile's connections into one list.
17
+ *
18
+ * **Keyed on provider and account, not on the id.** The common case is two
19
+ * profiles that both connected the same mailbox: same provider, same account,
20
+ * usually the same id, and they merge into one row because they *are* one
21
+ * account. The interesting case is two profiles each holding a row spelled
22
+ * `gmail.main` naming different mailboxes, which is legal under contract 2
23
+ * because a connection lived inside one profile and nothing ever compared them.
24
+ *
25
+ * That collision is resolved by renaming, never by picking. Both accounts are
26
+ * real, both have a credential, and choosing either would take somebody's
27
+ * mailbox away silently. The second becomes `gmail.main_2`, and the rename is
28
+ * reported so the operator sees it before anything else reads the file.
29
+ */
30
+ export function hoistConnections(profiles: ReadonlyMap<string, LegacyProfile>): {
31
+ rows: LegacyConnection[];
32
+ renames: ContractRename[];
33
+ perProfile: Map<string, Map<string, string>>;
34
+ } {
35
+ const rows: LegacyConnection[] = [];
36
+ const renames: ContractRename[] = [];
37
+ const byAccount = new Map<string, LegacyConnection>();
38
+ const taken = new Set<string>();
39
+ const perProfile = new Map<string, Map<string, string>>();
40
+
41
+ for (const [profile, config] of profiles) {
42
+ const mapping = new Map<string, string>();
43
+ perProfile.set(profile, mapping);
44
+
45
+ for (const connection of config.connections ?? []) {
46
+ // Two profiles' owner layers are never the same thing, whatever their
47
+ // rows say. Every contract-2 profile carried an identical owner layer
48
+ // written from a fixed table — `{memory: 'Memory', vault: 'Vault', ...}`
49
+ // — so an identity of provider-plus-account made all of them collide and
50
+ // merge. That is the one outcome ADR-059 forbids: interleaving two sets of
51
+ // notes is not reversible and not reviewable, and for the vault the wrong
52
+ // answer is a credential. Keying on the profile forces a rename instead.
53
+ const owner = C3_OWNER_PROVIDERS.includes(connection.provider);
54
+ const identity = owner
55
+ ? `${connection.provider} @${profile}`
56
+ : `${connection.provider} ${connection.account}`;
57
+ const existing = byAccount.get(identity);
58
+
59
+ if (existing) {
60
+ // The same account, already hoisted. This profile's old key maps to
61
+ // whatever the first one settled on, which may itself be a rename.
62
+ mapping.set(keyOf(connection), keyOf(existing));
63
+ continue;
64
+ }
65
+
66
+ let id = connection.id;
67
+ if (taken.has(`${connection.provider}.${id}`)) {
68
+ // The profile's own name for an owner-layer surface, which is what
69
+ // ADR-059 specifies and reads far better than `memory.main_2` when the
70
+ // thing being separated is "work's notes". A numeric suffix is the
71
+ // fallback for a real account, and for the case where the profile name
72
+ // is itself taken.
73
+ const preferred = owner ? sanitise(profile) : `${id}_2`;
74
+ let candidate = preferred;
75
+ let suffix = 2;
76
+ while (taken.has(`${connection.provider}.${candidate}`)) {
77
+ suffix += 1;
78
+ candidate = `${preferred}_${suffix}`;
79
+ }
80
+ renames.push({
81
+ from: `${connection.provider}.${id}`,
82
+ to: `${connection.provider}.${candidate}`,
83
+ reason: owner
84
+ ? `"${profile}" has its own ${connection.provider}, which is not "${id}"'s`
85
+ : `"${profile}" named a different account (${connection.account}) with that id`,
86
+ });
87
+ id = candidate;
88
+ }
89
+
90
+ const row: LegacyConnection = { ...connection, id };
91
+ rows.push(row);
92
+ byAccount.set(identity, row);
93
+ taken.add(keyOf(row));
94
+ mapping.set(keyOf(connection), keyOf(row));
95
+ }
96
+ }
97
+
98
+ return { rows, renames, perProfile };
99
+ }
100
+
101
+ /** A profile name as a connection id: the same alphabet `connectionRef` allows. */
102
+ function sanitise(profile: string): string {
103
+ const cleaned = profile.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^_+/, '');
104
+ return cleaned.length > 0 ? cleaned : 'two';
105
+ }
106
+
107
+ /**
108
+ * A rule, in whichever of the two shapes contract 2 accepted, with its expiry.
109
+ *
110
+ * Reading the capability alone got both directions wrong, and it is the same
111
+ * mistake `config-repair.ts` documents. `isRuleActive` is what made a lapsed
112
+ * rule inert, so dropping `expires_at` turned an allow that died months ago into
113
+ * a live permanent grant — and an expired *deny* into a permanent one, which is
114
+ * not the safe direction either, because a deny outranks every allow.
115
+ *
116
+ * A rule that has already lapsed is dropped rather than carried: it granted and
117
+ * denied nothing on the day of the migration, and writing it forward would give
118
+ * it a meaning it did not have.
119
+ */
120
+ type Rule = string | { capability: string; expires_at: string };
121
+
122
+ function patternsOf(rules: unknown, now = Date.now()): Rule[] {
123
+ if (!Array.isArray(rules)) return [];
124
+
125
+ return rules.flatMap((rule): Rule[] => {
126
+ if (typeof rule === 'string') return [rule];
127
+
128
+ const object = rule as { capability?: unknown; expires_at?: unknown } | null;
129
+ const capability = object?.capability;
130
+ if (typeof capability !== 'string') return [];
131
+
132
+ const expiry = object?.expires_at;
133
+ if (typeof expiry !== 'string') return [capability];
134
+
135
+ const at = Date.parse(expiry);
136
+ if (Number.isNaN(at)) return [capability];
137
+ return at > now ? [{ capability, expires_at: expiry }] : [];
138
+ });
139
+ }
140
+
141
+ /** The capability a rule names, whichever shape it is in. */
142
+ function capabilityOf(rule: Rule): string {
143
+ return typeof rule === 'string' ? rule : rule.capability;
144
+ }
145
+
146
+ /**
147
+ * The grant rows one contract-2 profile becomes.
148
+ *
149
+ * Every connection gets the rules that named its provider, which is precisely
150
+ * what the flat block meant: rules covered every account of a provider in the
151
+ * profile. So this loses nothing, and gains the ability to diverge afterwards.
152
+ *
153
+ * A rule naming a provider the profile has no connection for is dropped rather
154
+ * than carried. Under contract 2 an `allow` like that was refused at load, and a
155
+ * `deny` was permitted as a note to self; there is nowhere to put either now,
156
+ * because a row without a connection is not expressible.
157
+ */
158
+ export function grantsFor(
159
+ config: LegacyProfile,
160
+ mapping: ReadonlyMap<string, string>,
161
+ ): { connection: string; allow: Rule[]; deny: Rule[] }[] {
162
+ const allow = patternsOf(config.policy?.allow);
163
+ const deny = patternsOf(config.policy?.deny);
164
+
165
+ const covers = (rule: Rule, provider: string): boolean => {
166
+ const pattern = capabilityOf(rule);
167
+ return pattern === '*' || pattern.startsWith(`${provider}.`);
168
+ };
169
+
170
+ // Keyed on the *settled* connection, because two rows can become one. A
171
+ // profile could hold `gmail.main` and `gmail.archive` for the same mailbox at
172
+ // different scopes — which is legal under contract 2, and the reason tokens
173
+ // are per connection at all — and the hoist keys on provider-plus-account, so
174
+ // both settle on one row. Emitting a grant per original row then wrote the
175
+ // same `connection:` twice and `assertGrantsUnique` refused the profile,
176
+ // failing the migration on a merge it had just performed itself.
177
+ //
178
+ // The rules are unioned rather than picked between. Both rows were in force
179
+ // before, the connection they describe is now one connection, and dropping
180
+ // either would narrow a policy the operator never narrowed.
181
+ const rows = new Map<string, { connection: string; allow: Rule[]; deny: Rule[] }>();
182
+
183
+ for (const connection of config.connections ?? []) {
184
+ const provider = connection.provider;
185
+ // A bare `*` becomes the provider wildcard rather than being copied
186
+ // through. It would still mean the same thing inside a row, which is
187
+ // already scoped to one connection, but writing it out is what makes the
188
+ // file say so. An expiry rides along untouched.
189
+ const widen = (rule: Rule): Rule => {
190
+ if (typeof rule === 'string') return rule === '*' ? `${provider}.*` : rule;
191
+ return rule.capability === '*' ? { ...rule, capability: `${provider}.*` } : rule;
192
+ };
193
+
194
+ const key = mapping.get(keyOf(connection)) ?? keyOf(connection);
195
+ const row = rows.get(key) ?? { connection: key, allow: [], deny: [] };
196
+
197
+ row.allow = union(row.allow, allow.filter((rule) => covers(rule, provider)).map(widen));
198
+ row.deny = union(row.deny, deny.filter((rule) => covers(rule, provider)).map(widen));
199
+ rows.set(key, row);
200
+ }
201
+
202
+ return [...rows.values()];
203
+ }
204
+
205
+ /** Rules from two merged rows, keeping the first spelling of each capability. */
206
+ function union(held: readonly Rule[], adding: readonly Rule[]): Rule[] {
207
+ const merged = [...held];
208
+ for (const rule of adding) {
209
+ if (!merged.some((one) => capabilityOf(one) === capabilityOf(rule))) merged.push(rule);
210
+ }
211
+ return merged;
212
+ }
@@ -0,0 +1,399 @@
1
+ import { newConnectionsTemplate } from './config-templates.ts';
2
+ import { planMoves } from './contract3-data.ts';
3
+ import { applyMoves } from './migrate-move.ts';
4
+ import {
5
+ mergeCredentials,
6
+ planCredentials,
7
+ connectionRefs,
8
+ type CredentialPlan,
9
+ } from './contract3-credentials.ts';
10
+ import { parseDocument } from 'yaml';
11
+ import {
12
+ CONNECTIONS_FILE,
13
+ ConfigError,
14
+ isRemoteWorkspace,
15
+ layout,
16
+ listProfiles,
17
+ readWorkspaceFile,
18
+ workspaceFiles,
19
+ writeWorkspaceFile,
20
+ } from '#profile';
21
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
22
+ import { ConfigDocument } from './config-edit.ts';
23
+ import { grantsFor, hoistConnections } from './contract3-shape.ts';
24
+ import { C3 } from './contract3-layout.ts';
25
+ /**
26
+ * Contract 2 to contract 3: connections move out of the profile.
27
+ *
28
+ * The hardest migration this project has done, and the one it could least
29
+ * afford to skip. `layout.ts` and ADR-030 both set the precedent that there is
30
+ * no migration, because machinery to move an old layout is more code than the
31
+ * thing it moves and has to keep working forever. That precedent was set for
32
+ * *empty directories*. Here the thing being moved is every credential the
33
+ * operator holds, and "re-authorise fifteen accounts in a browser" is not a
34
+ * release note anyone should write.
35
+ *
36
+ * Four moves, in an order chosen so a crash between any two leaves a workspace
37
+ * that still opens:
38
+ *
39
+ * 1. Connections are hoisted into `connections.yaml`.
40
+ * 2. Credentials are merged into one store, and read back before the old ones
41
+ * are touched.
42
+ * 3. Profiles are rewritten, `grants:` being the old connections crossed with
43
+ * the old flat policy, which is exactly what contract 2 meant.
44
+ * 4. Bytes move to their connection-keyed homes.
45
+ *
46
+ * The registry rename rides along at the end: it is one document and cannot
47
+ * half-apply.
48
+ */
49
+
50
+ /** What a divergent id was renamed to, and why. */
51
+ export interface ContractRename {
52
+ readonly from: string;
53
+ readonly to: string;
54
+ readonly reason: string;
55
+ }
56
+
57
+ export interface Contract3Migration {
58
+ readonly workspaceRoot: string;
59
+ readonly profiles: readonly string[];
60
+ readonly connections: readonly string[];
61
+ readonly renames: readonly ContractRename[];
62
+ readonly credentials: readonly string[];
63
+ readonly moved: readonly string[];
64
+ readonly changes: readonly string[];
65
+ readonly alreadyCurrent: boolean;
66
+ }
67
+
68
+ export interface LegacyConnection {
69
+ readonly id: string;
70
+ readonly provider: string;
71
+ readonly account: string;
72
+ readonly label?: string;
73
+ readonly credential_ref?: string;
74
+ readonly config?: Record<string, unknown>;
75
+ }
76
+
77
+ export interface LegacyProfile {
78
+ readonly contract?: number;
79
+ readonly connections?: LegacyConnection[];
80
+ readonly policy?: { allow?: unknown[]; deny?: unknown[] };
81
+ readonly oauth_apps?: Record<string, unknown>;
82
+ /**
83
+ * Read for `token_ref` alone, and read raw rather than through `authSchema`:
84
+ * this walks profiles that have not been validated, and a profile that fails
85
+ * validation for an unrelated reason still has an endpoint token to leave
86
+ * behind.
87
+ */
88
+ readonly auth?: { token_ref?: string };
89
+ }
90
+
91
+ /** The schema default, and what every profile written by the CLI carries. */
92
+ const DEFAULT_TOKEN_REF = 'profile/token';
93
+
94
+ /** Whether this workspace still holds anything at contract 2. */
95
+ export async function needsContract3(workspaceRoot: string): Promise<boolean> {
96
+ for (const profile of await listProfiles(workspaceRoot)) {
97
+ const raw = await readProfile(workspaceRoot, profile);
98
+ if (raw !== null && (raw.contract ?? 0) === 2) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ async function readProfile(root: string, profile: string): Promise<LegacyProfile | null> {
104
+ const text = await readWorkspaceFile(workspaceFiles(root), C3.profile(profile));
105
+ if (text === null) return null;
106
+ try {
107
+ return parseDocument(text).toJSON() as LegacyProfile;
108
+ } catch {
109
+ // A file that will not parse is not this function's problem to report;
110
+ // `check` gives it a better sentence than "needs migrating" would.
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** `gmail.main` — how a connection is addressed in every file after this. */
116
+ export function keyOf(connection: { provider: string; id: string }): string {
117
+ return `${connection.provider}.${connection.id}`;
118
+ }
119
+
120
+ export async function migrateToContract3(
121
+ workspaceRoot: string,
122
+ options: { apply: boolean; subject?: string } = { apply: true },
123
+ ): Promise<Contract3Migration> {
124
+ const legacy = new Map<string, LegacyProfile>();
125
+
126
+ for (const profile of await listProfiles(workspaceRoot)) {
127
+ const raw = await readProfile(workspaceRoot, profile);
128
+ if (raw !== null && (raw.contract ?? 0) === 2) legacy.set(profile, raw);
129
+ }
130
+
131
+ const nothing: Contract3Migration = {
132
+ workspaceRoot,
133
+ profiles: [],
134
+ connections: [],
135
+ renames: [],
136
+ credentials: [],
137
+ moved: [],
138
+ changes: [],
139
+ alreadyCurrent: true,
140
+ };
141
+ if (legacy.size === 0) return nothing;
142
+
143
+ const { rows, renames, perProfile } = hoistConnections(legacy);
144
+ const files = workspaceFiles(workspaceRoot);
145
+
146
+ // Everything that can be computed is computed before the first write, so a
147
+ // refusal leaves the workspace exactly as it was.
148
+ const plans: CredentialPlan[] = [...legacy].map(([profile, config]) => ({
149
+ profile,
150
+ renames: connectionRefs(perProfile.get(profile) ?? new Map()),
151
+ tokenRef:
152
+ typeof config.auth?.token_ref === 'string' ? config.auth.token_ref : DEFAULT_TOKEN_REF,
153
+ }));
154
+
155
+ const credentials = await planCredentials(workspaceRoot, plans);
156
+ const moves = await planMoves(files, [...legacy.keys()], perProfile);
157
+
158
+ const changes: string[] = [
159
+ `${CONNECTIONS_FILE}: ${rows.length} connection(s) hoisted`,
160
+ ...renames.map((rename) => `renamed ${rename.from} to ${rename.to} (${rename.reason})`),
161
+ ...[...legacy.keys()].map((profile) => `profiles/${profile}.yaml: contract 3, grants`),
162
+ ];
163
+ if (credentials.refs.length > 0) {
164
+ changes.push(`${C3.credentials()}: ${credentials.refs.length} credential(s) merged`);
165
+ }
166
+ if (credentials.tokens.length > 0) {
167
+ changes.push(
168
+ `${credentials.tokens.join(', ')}: left behind — one endpoint token per workspace now, ` +
169
+ 'and a fresh one is minted on the next command',
170
+ );
171
+ }
172
+ if (moves.length > 0) changes.push(`${moves.length} object(s) moved to their connection`);
173
+
174
+ const result: Contract3Migration = {
175
+ workspaceRoot,
176
+ profiles: [...legacy.keys()],
177
+ connections: rows.map(keyOf),
178
+ renames,
179
+ credentials: credentials.refs,
180
+ moved: moves.map((move) => move.to),
181
+ changes,
182
+ alreadyCurrent: false,
183
+ };
184
+
185
+ if (!options.apply) return result;
186
+
187
+ // The registry first, and this ordering is the whole of the re-entrancy.
188
+ //
189
+ // Every later step is idempotent — writing `connections.yaml` again produces
190
+ // the same file, merging a credential that is already there is a no-op,
191
+ // rewriting a contract-3 profile is skipped, and a move whose source is gone
192
+ // is skipped. `rewriteRegistry` is the one step that is not, because it reads
193
+ // `targets:` and would find none the second time.
194
+ //
195
+ // Running it last meant an interruption anywhere before it left profiles at
196
+ // contract 3 and `lanes-link.yaml` still at contract 2 — a state where
197
+ // re-entry found nothing to migrate, and `workspaceSchema` parsed a file whose
198
+ // `workspaces:` defaulted to empty, so every command refused with "declares no
199
+ // workspace" and there was no way back.
200
+ await rewriteRegistry(workspaceRoot);
201
+ await writeConnections(workspaceRoot, rows, legacy);
202
+ await mergeCredentials(workspaceRoot, plans);
203
+
204
+ // **The bytes move before the profile says they have.**
205
+ //
206
+ // `rewriteProfiles` is what stamps `contract: 3`, and that stamp is the only
207
+ // thing `needsContract3` reads — so it is not a step among steps, it is the
208
+ // record that the migration finished. Running it before `applyMoves` meant an
209
+ // interruption between the two left profiles claiming contract 3 with every
210
+ // byte still under `data/<profile>/`, and a re-run that looked at the stamp
211
+ // and found nothing to do. The workspace opened, which is what this file
212
+ // ordered its steps to guarantee, and the owner's memory, tasks, skills and
213
+ // audit log were not in it.
214
+ //
215
+ // A network round trip per object made that window real rather than
216
+ // theoretical: this migrates buckets now, and the first one it was pointed at
217
+ // held 1,906 objects.
218
+ await applyMoves(files, moves);
219
+ await rewriteProfiles(workspaceRoot, legacy, perProfile, options.subject);
220
+
221
+ return result;
222
+ }
223
+
224
+ /**
225
+ * The hoisted rows, plus every profile's `oauth_apps` merged into one block.
226
+ *
227
+ * **Anything already in the file survives**, which is the rule `writeRegistry`
228
+ * states for the same reason: a workspace part way through this has entries that
229
+ * are already right, and re-deriving them from what is left would undo a
230
+ * correction.
231
+ *
232
+ * Not a tidiness argument here. `rewriteProfiles` stamps profiles one at a time
233
+ * and `legacy` holds only those still at contract 2, so an interruption part way
234
+ * through it left a rerun hoisting a *subset* — and this overwrote the file with
235
+ * it, deleting the already-migrated profiles' rows. Their grants named those
236
+ * connections still. In the shape that actually bites, two profiles held
237
+ * `gmail.main` for different mailboxes and were hoisted to `gmail.main` and
238
+ * `gmail.main_2`; the rerun rebuilt the file from the second profile alone, so
239
+ * `gmail.main` came back naming the *other* person's mailbox and the first
240
+ * profile's surviving grant pointed at it. Nothing errored, and
241
+ * `loadProfileConfig` returned ok.
242
+ */
243
+ async function writeConnections(
244
+ root: string,
245
+ rows: readonly LegacyConnection[],
246
+ legacy: ReadonlyMap<string, LegacyProfile>,
247
+ ): Promise<void> {
248
+ const apps: Record<string, unknown> = {};
249
+ for (const config of legacy.values()) Object.assign(apps, config.oauth_apps ?? {});
250
+
251
+ const held = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
252
+ const current = held === null ? null : ConfigDocument.fromText(held, CONNECTIONS_FILE);
253
+ const previous = ((current?.toJSON() as { connections?: unknown } | null)?.connections ??
254
+ []) as LegacyConnection[];
255
+
256
+ const merged = new Map<string, LegacyConnection>();
257
+ for (const row of rows) merged.set(keyOf(row), row);
258
+ // Second, so a row this run re-derived does not displace the one already
259
+ // written for it.
260
+ for (const row of previous) {
261
+ if (typeof row?.provider === 'string' && typeof row?.id === 'string') merged.set(keyOf(row), row);
262
+ }
263
+
264
+ const document = ConfigDocument.fromText(newConnectionsTemplate(), CONNECTIONS_FILE);
265
+ document.setIn(['connections'], [...merged.values()]);
266
+ document.setIn(['oauth_apps'], {
267
+ ...((current?.toJSON() as { oauth_apps?: Record<string, unknown> } | null)?.oauth_apps ?? {}),
268
+ ...apps,
269
+ });
270
+
271
+ await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, document.toString());
272
+ }
273
+
274
+ /** Contract 3, `grants:` and `members:`, with everything else left as written. */
275
+ async function rewriteProfiles(
276
+ root: string,
277
+ legacy: ReadonlyMap<string, LegacyProfile>,
278
+ perProfile: ReadonlyMap<string, Map<string, string>>,
279
+ subject: string | undefined,
280
+ ): Promise<void> {
281
+ for (const [profile, config] of legacy) {
282
+ // `openKey` at the contract-3 path, not `open`, which resolves the live
283
+ // layout: this produces contract 3, and contract 4 moves what it produced.
284
+ const document = await ConfigDocument.openKey(root, C3.profile(profile));
285
+
286
+ document.setIn(['contract'], 3);
287
+ document.setIn(['grants'], grantsFor(config, perProfile.get(profile) ?? new Map()));
288
+ // Empty unless the migration was run by somebody signed in. Nobody is a
289
+ // legitimate state and it is default deny on the identity axis, but a
290
+ // workspace whose profiles nobody can consume is a poor thing to hand back,
291
+ // so `update` passes the signed-in subject through.
292
+ document.setIn(['members'], subject ? [{ subject, role: 'owner' }] : []);
293
+
294
+ // The authorization block arrives here rather than in the template, because
295
+ // an existing profile has one only if it was deployed. Every endpoint runs
296
+ // the flow now, loopback included (ADR-062).
297
+ if (document.getIn(['auth', 'authorization']) === undefined) {
298
+ document.setIn(['auth', 'authorization'], { mode: 'self' });
299
+ }
300
+
301
+ document.removeIn(['connections']);
302
+ document.removeIn(['policy']);
303
+ document.removeIn(['oauth_apps']);
304
+
305
+ await document.save({ contract: 3 });
306
+ }
307
+ }
308
+
309
+ /** `targets:` becomes `workspaces:`, and a pointer's `workspace:` becomes `at:`. */
310
+ async function rewriteRegistry(root: string): Promise<void> {
311
+ const document = await ConfigDocument.openKey(root, C3.workspace);
312
+ const registry = document.toJSON() as {
313
+ contract?: number;
314
+ targets?: Record<string, { workspace?: string }>;
315
+ workspaces?: Record<string, unknown>;
316
+ } | null;
317
+
318
+ const targets = registry?.targets;
319
+
320
+ // Already renamed, which is what a contract-1 workspace looks like here: the
321
+ // 1-to-2 migration wrote `workspaces:` on its way through. Returning early
322
+ // skipped the contract stamp and the default below, so that path finished a
323
+ // migration and left neither.
324
+ if (targets !== undefined) {
325
+ const workspaces: Record<string, unknown> = {};
326
+ for (const [name, entry] of Object.entries(targets)) {
327
+ const { workspace, ...rest } = entry;
328
+ workspaces[name] = workspace === undefined ? rest : { at: workspace, ...rest };
329
+ }
330
+
331
+ // **Anything already under `workspaces:` wins over what `targets:` says**,
332
+ // which is the same rule the 1-to-2 migration states for the same reason: a
333
+ // workspace part way through this has entries that are already right, and
334
+ // re-deriving them from a stale block would undo a correction.
335
+ //
336
+ // It is not hypothetical here. `editRegistry` used to write `workspaces:`
337
+ // into a contract-2 file without touching its `targets:`, so a deploy from
338
+ // an unmigrated laptop left two registries disagreeing — and this
339
+ // overwrote the newer one with the older, silently reverting a recorded
340
+ // deployment to whatever the last contract-2 command had written. That
341
+ // write is fixed at source, and this is what repairs a file already
342
+ // carrying both.
343
+ const merged: Record<string, unknown> = { ...workspaces };
344
+ for (const [name, entry] of Object.entries(registry?.workspaces ?? {})) {
345
+ const derived = merged[name];
346
+ // Per field, not per entry. The newer block is what `editRegistry` wrote
347
+ // when it could not see `targets:`, and `sync` writes only `{ at }` — so
348
+ // replacing the entry wholesale discarded `primary` (which schema.ts calls
349
+ // the one question about a deployment that must not be guessed at),
350
+ // `last_deploy`, and the whole `deploy:` block carrying project and
351
+ // region. Preserving the record was the entire point of the merge.
352
+ merged[name] =
353
+ derived !== null && typeof derived === 'object' && entry !== null && typeof entry === 'object'
354
+ ? { ...(derived as Record<string, unknown>), ...(entry as Record<string, unknown>) }
355
+ : entry;
356
+ }
357
+ document.setIn(['workspaces'], merged);
358
+ document.removeIn(['targets']);
359
+ }
360
+
361
+ document.setIn(['contract'], 3);
362
+ const workspaces = (document.toJSON() as { workspaces?: Record<string, unknown> } | null)
363
+ ?.workspaces ?? {};
364
+
365
+ // The workspace on this machine, and only the *first* one when there is no
366
+ // such thing. Written rather than left absent so the sticky default is on
367
+ // from the first command after upgrading (ADR-061).
368
+ //
369
+ // This used to take the first key outright, on the stated grounds that "for
370
+ // every workspace this migration will ever see" that is `local`. It is not:
371
+ // the registry is written sorted, so a workspace that had ever deployed came
372
+ // out of the 1-to-2 migration with `cloud` ahead of `local`. Upgrading then
373
+ // pointed every subsequent command at a bucket — which is the one kind of
374
+ // workspace that can be unreachable, and was: the next `status` answered with
375
+ // a 403 from GCS rather than with the profiles sitting on the disk.
376
+ //
377
+ // A pointer carries `at:`; a workspace declaring its own adapters does not.
378
+ if (document.getIn(['default_workspace']) === undefined) {
379
+ const names = Object.keys(workspaces);
380
+ const here = names.find((name) => {
381
+ const entry = workspaces[name] as
382
+ | { at?: unknown; storage?: { adapter?: unknown } }
383
+ | undefined;
384
+ // A pointer is here when it points at a path rather than a bucket:
385
+ // `resolveTargetWorkspace` follows a local one just as happily.
386
+ if (typeof entry?.at === 'string') return !isRemoteWorkspace(entry.at);
387
+ // Otherwise it declares its own adapters, and only a filesystem one is on
388
+ // this machine. Reading "no `at:`" as "local" missed that a cloud target
389
+ // surveyed by `bootstrap` but never rolled out is a *declaration* — so a
390
+ // deploy that failed at build or IAM left the same 403 default this was
391
+ // written to prevent.
392
+ return entry?.storage?.adapter === 'filesystem';
393
+ });
394
+ const chosen = here ?? names[0];
395
+ if (chosen !== undefined) document.setIn(['default_workspace'], chosen);
396
+ }
397
+
398
+ await document.save();
399
+ }