@openparachute/vault 0.6.3 → 0.6.4-rc.2
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/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/mcp.ts +44 -1
- package/core/src/notes.ts +115 -3
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +4 -1
- package/core/src/types.ts +33 -3
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/mcp-tools.ts +17 -2
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-manager.ts +38 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +13 -0
- package/src/routes.ts +30 -1
- package/src/routing.ts +9 -1
package/src/auth.ts
CHANGED
|
@@ -100,6 +100,11 @@ function tryServerWideAuth(
|
|
|
100
100
|
// would see no other operator mints; that's fine — env-var-bearer is
|
|
101
101
|
// explicitly the operator-channel, not a user surface).
|
|
102
102
|
caller_jti: null,
|
|
103
|
+
// Write-attribution (vault#298): the env-var bearer IS the operator
|
|
104
|
+
// channel. `actor: "operator"` + `via: "operator"` — a stable, honest
|
|
105
|
+
// label for cross-container hub→vault and CLI operator writes.
|
|
106
|
+
actor: "operator",
|
|
107
|
+
via: "operator",
|
|
103
108
|
};
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -137,14 +142,52 @@ export interface AuthResult {
|
|
|
137
142
|
* mints. See vault#376.
|
|
138
143
|
*/
|
|
139
144
|
caller_jti: string | null;
|
|
145
|
+
/**
|
|
146
|
+
* Write-attribution axis 1 — WHO (vault#298). The principal a write is
|
|
147
|
+
* attributed to:
|
|
148
|
+
* - Hub JWT → the validated `sub` claim (the human or the identity an
|
|
149
|
+
* agent runs as).
|
|
150
|
+
* - VAULT_AUTH_TOKEN operator bearer → `"operator"`.
|
|
151
|
+
* - Legacy YAML api_keys → `"token:<keyhash-prefix>"` so legacy writes
|
|
152
|
+
* still attribute to *something* stable, never crash.
|
|
153
|
+
* NULL only when no principal could be resolved (shouldn't happen on a
|
|
154
|
+
* successful auth, but kept nullable so a future credential class that
|
|
155
|
+
* lacks a subject degrades gracefully rather than throwing).
|
|
156
|
+
*/
|
|
157
|
+
actor: string | null;
|
|
158
|
+
/**
|
|
159
|
+
* Write-attribution axis 2 — VIA WHAT (vault#298). The interface/channel a
|
|
160
|
+
* write arrived through, derived PRAGMATICALLY from the credential class
|
|
161
|
+
* here, then REFINED by the request path at the call site (the MCP handler
|
|
162
|
+
* stamps `mcp`; the REST router keeps the credential-class default). Values:
|
|
163
|
+
* `mcp` · `surface:<name>` · `agent:<id>` · `operator` · `api`. This is the
|
|
164
|
+
* BASE value from auth — the credential class:
|
|
165
|
+
* - Hub JWT → `"api"` (the generic class; refined to `mcp` etc. downstream
|
|
166
|
+
* once the channel is known).
|
|
167
|
+
* - operator bearer → `"operator"`.
|
|
168
|
+
* - legacy YAML key → `"api"` (a REST credential; the `token:<id>` actor
|
|
169
|
+
* already carries the legacy-key identity).
|
|
170
|
+
* Never blocks the `actor` work — if the channel can't be cleanly
|
|
171
|
+
* determined the class (or `api`) stands.
|
|
172
|
+
*/
|
|
173
|
+
via: string | null;
|
|
140
174
|
}
|
|
141
175
|
|
|
142
176
|
/**
|
|
143
177
|
* Convert a legacy "read" | "full" permission into scopes + the legacyDerived
|
|
144
178
|
* flag. Used for legacy YAML key authentication paths and for tokens whose
|
|
145
179
|
* `scopes` column is still NULL.
|
|
180
|
+
*
|
|
181
|
+
* `actorLabel` (vault#298) is the write-attribution principal for this legacy
|
|
182
|
+
* credential — `token:<keyhash-prefix>` so a legacy-YAML-keyed write still
|
|
183
|
+
* attributes to a stable identity. Defaults to `"operator"` for the rare
|
|
184
|
+
* call site without a key in hand. `via` is the generic `api` class (legacy
|
|
185
|
+
* keys are a REST credential); the MCP handler refines it to `mcp` downstream.
|
|
146
186
|
*/
|
|
147
|
-
function legacyAuthResult(
|
|
187
|
+
function legacyAuthResult(
|
|
188
|
+
permission: TokenPermission,
|
|
189
|
+
actorLabel: string = "operator",
|
|
190
|
+
): AuthResult {
|
|
148
191
|
return {
|
|
149
192
|
permission,
|
|
150
193
|
scopes: legacyPermissionToScopes(permission),
|
|
@@ -152,9 +195,23 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
152
195
|
scoped_tags: null,
|
|
153
196
|
vault_name: null,
|
|
154
197
|
caller_jti: null,
|
|
198
|
+
actor: actorLabel,
|
|
199
|
+
via: "api",
|
|
155
200
|
};
|
|
156
201
|
}
|
|
157
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Stable short label for a legacy YAML api_key, derived from its stored hash.
|
|
205
|
+
* `token:<first-12-of-hash>` — enough to distinguish keys for attribution
|
|
206
|
+
* without putting a full secret-derived hash into note rows. The hash is
|
|
207
|
+
* already a one-way digest of the key (see config.ts `verifyKey`), so a prefix
|
|
208
|
+
* leaks nothing usable.
|
|
209
|
+
*/
|
|
210
|
+
function legacyKeyActorLabel(keyHash: string): string {
|
|
211
|
+
const clean = keyHash.replace(/^[^:]*:/, ""); // drop any `algo:` prefix
|
|
212
|
+
return `token:${clean.slice(0, 12)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
158
215
|
// One-shot deprecation warning tracker, keyed by token hash / legacy label so
|
|
159
216
|
// we don't spam the log on every request.
|
|
160
217
|
const warnedLegacyTokens = new Set<string>();
|
|
@@ -303,7 +360,10 @@ export async function authenticateVaultRequest(
|
|
|
303
360
|
if (vaultKey) {
|
|
304
361
|
try { writeVaultConfig(vaultConfig); } catch {}
|
|
305
362
|
warnLegacyOnce(`yaml-vault:${vaultKey.key_hash}`, "vault.yaml api_keys");
|
|
306
|
-
return legacyAuthResult(
|
|
363
|
+
return legacyAuthResult(
|
|
364
|
+
vaultKey.scope === "read" ? "read" : "full",
|
|
365
|
+
legacyKeyActorLabel(vaultKey.key_hash),
|
|
366
|
+
);
|
|
307
367
|
}
|
|
308
368
|
|
|
309
369
|
// Legacy: check global keys from config.yaml
|
|
@@ -313,7 +373,10 @@ export async function authenticateVaultRequest(
|
|
|
313
373
|
if (globalKey) {
|
|
314
374
|
try { writeGlobalConfig(globalConfig); } catch {}
|
|
315
375
|
warnLegacyOnce(`yaml-global:${globalKey.key_hash}`, "config.yaml api_keys");
|
|
316
|
-
return legacyAuthResult(
|
|
376
|
+
return legacyAuthResult(
|
|
377
|
+
globalKey.scope === "read" ? "read" : "full",
|
|
378
|
+
legacyKeyActorLabel(globalKey.key_hash),
|
|
379
|
+
);
|
|
317
380
|
}
|
|
318
381
|
}
|
|
319
382
|
|
|
@@ -515,6 +578,18 @@ async function authenticateHubJwt(
|
|
|
515
578
|
// through verbatim — manage-token's session-pin will be null in that
|
|
516
579
|
// case, and list/revoke from that session sees no mints.
|
|
517
580
|
caller_jti: claims.jti ?? null,
|
|
581
|
+
// Write-attribution (vault#298). WHO = the validated JWT subject (the
|
|
582
|
+
// human, or the identity an agent runs as — note agent-grant tokens are
|
|
583
|
+
// minted with `sub = <user-on-whose-behalf>`, so today an agent's writes
|
|
584
|
+
// attribute to that human; a delegation-chain `via` is the deferred v2 in
|
|
585
|
+
// the issue). VIA = `api` here, the generic credential class; the request
|
|
586
|
+
// path refines it (the MCP handler stamps `mcp`). The JWT carries no
|
|
587
|
+
// clean surface-name / agent-definition-id claim — `clientId` is an
|
|
588
|
+
// opaque DCR id and `aud` is just `vault.<name>` — so per the issue's
|
|
589
|
+
// pragmatic constraint we DON'T manufacture a more specific class; the
|
|
590
|
+
// channel comes from the path instead.
|
|
591
|
+
actor: claims.sub && claims.sub.length > 0 ? claims.sub : null,
|
|
592
|
+
via: "api",
|
|
518
593
|
};
|
|
519
594
|
} catch (err) {
|
|
520
595
|
if (err instanceof MalformedScopedTagsError) {
|
|
@@ -619,7 +694,10 @@ export async function authenticateGlobalRequest(
|
|
|
619
694
|
if (matched) {
|
|
620
695
|
try { writeGlobalConfig(globalConfig); } catch {}
|
|
621
696
|
warnLegacyOnce(`yaml-global:${matched.key_hash}`, "config.yaml api_keys");
|
|
622
|
-
return legacyAuthResult(
|
|
697
|
+
return legacyAuthResult(
|
|
698
|
+
matched.scope === "read" ? "read" : "full",
|
|
699
|
+
legacyKeyActorLabel(matched.key_hash),
|
|
700
|
+
);
|
|
623
701
|
}
|
|
624
702
|
}
|
|
625
703
|
|
package/src/mcp-tools.ts
CHANGED
|
@@ -166,10 +166,25 @@ export function generateScopedMcpTools(
|
|
|
166
166
|
)
|
|
167
167
|
: undefined;
|
|
168
168
|
|
|
169
|
+
// Write-attribution (vault#298). Every write through an MCP session arrives
|
|
170
|
+
// on the `mcp` channel — so we REFINE the auth's base `via` (the generic
|
|
171
|
+
// credential class) to `mcp` here, where the path/channel is known. The
|
|
172
|
+
// operator bearer keeps `operator` (its credential class IS its channel and
|
|
173
|
+
// is more informative than `mcp` for cross-container hub→vault writes); any
|
|
174
|
+
// other credential's via becomes `mcp`. `actor` (the principal) passes
|
|
175
|
+
// through unchanged.
|
|
176
|
+
const writeContext = auth
|
|
177
|
+
? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
169
180
|
const tools = generateMcpTools(
|
|
170
181
|
store,
|
|
171
|
-
expandVisibility || nearTraversable
|
|
172
|
-
? {
|
|
182
|
+
expandVisibility || nearTraversable || writeContext
|
|
183
|
+
? {
|
|
184
|
+
...(expandVisibility ? { expandVisibility } : {}),
|
|
185
|
+
...(nearTraversable ? { nearTraversable } : {}),
|
|
186
|
+
...(writeContext ? { writeContext } : {}),
|
|
187
|
+
}
|
|
173
188
|
: undefined,
|
|
174
189
|
);
|
|
175
190
|
|
|
@@ -104,6 +104,53 @@ describe("serialize + parse round-trip", () => {
|
|
|
104
104
|
expect(out).toEqual(creds);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("github_oauth with persisted owner/name round-trips through the bytes (vault#401)", () => {
|
|
108
|
+
// owner/name are persisted at repo-select time so the selection survives a
|
|
109
|
+
// cleared git origin + restart. They must serialize and parse back
|
|
110
|
+
// byte-faithfully.
|
|
111
|
+
const creds: MirrorCredentials = {
|
|
112
|
+
active_method: "github_oauth",
|
|
113
|
+
github_oauth: {
|
|
114
|
+
access_token: "gho_abc123def456ghi789",
|
|
115
|
+
scope: "repo",
|
|
116
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
117
|
+
user_login: "aaron",
|
|
118
|
+
user_id: 12345,
|
|
119
|
+
owner: "aaron",
|
|
120
|
+
name: "backup-repo",
|
|
121
|
+
},
|
|
122
|
+
pat: null,
|
|
123
|
+
};
|
|
124
|
+
const out = parseCredentials(serializeCredentials(creds));
|
|
125
|
+
expect(out).toEqual(creds);
|
|
126
|
+
expect(out.github_oauth?.owner).toBe("aaron");
|
|
127
|
+
expect(out.github_oauth?.name).toBe("backup-repo");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("github_oauth WITHOUT owner/name stays byte-stable (back-compat)", () => {
|
|
131
|
+
// Credentials written before a repo is picked (or before vault#401) carry
|
|
132
|
+
// no owner/name. Serialize must NOT emit empty owner/name lines, and the
|
|
133
|
+
// round-trip must leave the fields absent (not "").
|
|
134
|
+
const creds: MirrorCredentials = {
|
|
135
|
+
active_method: "github_oauth",
|
|
136
|
+
github_oauth: {
|
|
137
|
+
access_token: "gho_abc123def456ghi789",
|
|
138
|
+
scope: "repo",
|
|
139
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
140
|
+
user_login: "aaron",
|
|
141
|
+
user_id: 12345,
|
|
142
|
+
},
|
|
143
|
+
pat: null,
|
|
144
|
+
};
|
|
145
|
+
const serialized = serializeCredentials(creds);
|
|
146
|
+
expect(serialized).not.toContain("owner:");
|
|
147
|
+
expect(serialized).not.toContain("name:");
|
|
148
|
+
const out = parseCredentials(serialized);
|
|
149
|
+
expect(out).toEqual(creds);
|
|
150
|
+
expect(out.github_oauth?.owner).toBeUndefined();
|
|
151
|
+
expect(out.github_oauth?.name).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
107
154
|
test("github_oauth with empty scope round-trips (GitHub App tokens)", () => {
|
|
108
155
|
// Tokens from the shared Parachute GitHub App carry scope: "" (GitHub
|
|
109
156
|
// Apps use fine-grained permissions, not scopes). The empty string must
|
|
@@ -86,6 +86,18 @@ export interface GitHubOAuthCredential {
|
|
|
86
86
|
user_login: string;
|
|
87
87
|
/** GitHub numeric user id — stable across login renames. */
|
|
88
88
|
user_id: number;
|
|
89
|
+
/**
|
|
90
|
+
* The selected repo's owner — persisted at repo-select time (vault#401).
|
|
91
|
+
* Optional: absent before the operator picks a repo, and absent in
|
|
92
|
+
* credentials files written before this field existed (back-compat). When
|
|
93
|
+
* present, `applyCredentialsToRemote` reconstructs the authed remote URL
|
|
94
|
+
* from `owner`/`name` directly rather than regex-parsing the current git
|
|
95
|
+
* origin — so the selection survives the origin being cleared (e.g.
|
|
96
|
+
* `DELETE /auth` then re-OAuth) and a server restart.
|
|
97
|
+
*/
|
|
98
|
+
owner?: string;
|
|
99
|
+
/** The selected repo's name — persisted alongside `owner` (vault#401). */
|
|
100
|
+
name?: string;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
/**
|
|
@@ -138,6 +150,10 @@ export interface MirrorCredentialsPublic {
|
|
|
138
150
|
scope: string;
|
|
139
151
|
authorized_at: string;
|
|
140
152
|
token_preview: string;
|
|
153
|
+
/** Selected repo owner, when a repo has been picked (vault#401). */
|
|
154
|
+
owner?: string;
|
|
155
|
+
/** Selected repo name, when a repo has been picked (vault#401). */
|
|
156
|
+
name?: string;
|
|
141
157
|
} | null;
|
|
142
158
|
pat: {
|
|
143
159
|
label: string;
|
|
@@ -233,6 +249,15 @@ export function serializeCredentials(creds: MirrorCredentials): string {
|
|
|
233
249
|
lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
|
|
234
250
|
lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
|
|
235
251
|
lines.push(` user_id: ${creds.github_oauth.user_id}`);
|
|
252
|
+
// owner/name persisted at repo-select time (vault#401). Emitted only when
|
|
253
|
+
// present so credentials written before a repo is picked (or before this
|
|
254
|
+
// field existed) stay byte-stable.
|
|
255
|
+
if (creds.github_oauth.owner !== undefined) {
|
|
256
|
+
lines.push(` owner: ${quoteIfNeeded(creds.github_oauth.owner)}`);
|
|
257
|
+
}
|
|
258
|
+
if (creds.github_oauth.name !== undefined) {
|
|
259
|
+
lines.push(` name: ${quoteIfNeeded(creds.github_oauth.name)}`);
|
|
260
|
+
}
|
|
236
261
|
} else {
|
|
237
262
|
lines.push("github_oauth: null");
|
|
238
263
|
}
|
|
@@ -356,6 +381,8 @@ export function parseCredentials(yaml: string): MirrorCredentials {
|
|
|
356
381
|
else if (key === "scope") oauth.scope = parseScalar(rawVal!);
|
|
357
382
|
else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
|
|
358
383
|
else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
|
|
384
|
+
else if (key === "owner") oauth.owner = parseScalar(rawVal!);
|
|
385
|
+
else if (key === "name") oauth.name = parseScalar(rawVal!);
|
|
359
386
|
else if (key === "user_id") {
|
|
360
387
|
const n = Number(parseScalar(rawVal!));
|
|
361
388
|
if (Number.isFinite(n)) oauth.user_id = n;
|
|
@@ -571,6 +598,12 @@ export function sanitizeCredentials(
|
|
|
571
598
|
scope: creds.github_oauth.scope,
|
|
572
599
|
authorized_at: creds.github_oauth.authorized_at,
|
|
573
600
|
token_preview: previewToken(creds.github_oauth.access_token),
|
|
601
|
+
...(creds.github_oauth.owner !== undefined
|
|
602
|
+
? { owner: creds.github_oauth.owner }
|
|
603
|
+
: {}),
|
|
604
|
+
...(creds.github_oauth.name !== undefined
|
|
605
|
+
? { name: creds.github_oauth.name }
|
|
606
|
+
: {}),
|
|
574
607
|
}
|
|
575
608
|
: null,
|
|
576
609
|
pat: creds.pat
|
package/src/mirror-manager.ts
CHANGED
|
@@ -1073,33 +1073,49 @@ export class MirrorManager {
|
|
|
1073
1073
|
return;
|
|
1074
1074
|
}
|
|
1075
1075
|
// github_oauth: the active token is set, but a repo may not yet be
|
|
1076
|
-
// picked. The select-repo route handler writes the URL
|
|
1077
|
-
//
|
|
1078
|
-
// owner/repo. Best-effort: if `origin` already points at a github.com
|
|
1079
|
-
// URL, rewrite it with the stored token in case the token rotated.
|
|
1076
|
+
// picked. The select-repo route handler writes the URL onto origin AND
|
|
1077
|
+
// (vault#401) persists owner/name into the credentials struct.
|
|
1080
1078
|
if (creds.active_method === "github_oauth" && creds.github_oauth) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1079
|
+
// vault#401: prefer the persisted owner/name. Reconstructing the
|
|
1080
|
+
// remote from credentials alone means the selection survives the
|
|
1081
|
+
// origin being cleared (DELETE /auth then re-OAuth) across a restart —
|
|
1082
|
+
// the bug class where regex-parsing the origin silently lost the repo.
|
|
1083
|
+
const persistedOwner = creds.github_oauth.owner;
|
|
1084
|
+
const persistedName = creds.github_oauth.name;
|
|
1085
|
+
let owner: string | undefined;
|
|
1086
|
+
let repo: string | undefined;
|
|
1087
|
+
if (persistedOwner && persistedName) {
|
|
1088
|
+
owner = persistedOwner;
|
|
1089
|
+
repo = persistedName;
|
|
1090
|
+
} else {
|
|
1091
|
+
// Back-compat fallback: credentials written before #401 don't carry
|
|
1092
|
+
// owner/name. Parse them out of the current github.com origin (the
|
|
1093
|
+
// pre-#401 behavior) when origin is still present.
|
|
1094
|
+
const current = await readCurrentOrigin(repoDir);
|
|
1095
|
+
if (current && current.includes("github.com")) {
|
|
1096
|
+
const match = current.match(
|
|
1097
|
+
/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/,
|
|
1094
1098
|
);
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1099
|
-
);
|
|
1099
|
+
if (match) {
|
|
1100
|
+
owner = match[1];
|
|
1101
|
+
repo = match[2];
|
|
1100
1102
|
}
|
|
1101
1103
|
}
|
|
1102
1104
|
}
|
|
1105
|
+
if (owner && repo) {
|
|
1106
|
+
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
1107
|
+
const authed = githubAuthedRemoteUrl(
|
|
1108
|
+
creds.github_oauth.access_token,
|
|
1109
|
+
owner,
|
|
1110
|
+
repo,
|
|
1111
|
+
);
|
|
1112
|
+
const result = await applyToGitRemote(repoDir, authed);
|
|
1113
|
+
if (!result.ok) {
|
|
1114
|
+
console.warn(
|
|
1115
|
+
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1103
1119
|
}
|
|
1104
1120
|
} catch (err) {
|
|
1105
1121
|
console.warn(
|
|
@@ -912,6 +912,84 @@ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)",
|
|
|
912
912
|
await manager.stop();
|
|
913
913
|
}, 30_000);
|
|
914
914
|
|
|
915
|
+
test("vault#401: owner/name survive a cleared origin + restart (persisted in creds, not just the git remote)", async () => {
|
|
916
|
+
// The bug: pre-#401, select-repo wrote owner/name ONLY into the git
|
|
917
|
+
// origin URL. On restart, applyCredentialsToRemote regex-parsed them back
|
|
918
|
+
// out of the origin — so clearing the origin (DELETE /auth → re-OAuth
|
|
919
|
+
// without re-selecting) silently lost the repo even with a valid token.
|
|
920
|
+
// Now select-repo persists owner/name into the credentials struct, and
|
|
921
|
+
// applyCredentialsToRemote prefers the persisted values. With the origin
|
|
922
|
+
// cleared, a restart still reconstructs the github.com remote.
|
|
923
|
+
home = tmp("mirror-selectrepo-persist-");
|
|
924
|
+
const { manager, deps } = makeManager(home);
|
|
925
|
+
const cfg = {
|
|
926
|
+
...defaultMirrorConfig(),
|
|
927
|
+
enabled: true,
|
|
928
|
+
location: "internal" as const,
|
|
929
|
+
sync_mode: "manual" as const,
|
|
930
|
+
auto_commit: false,
|
|
931
|
+
auto_push: false,
|
|
932
|
+
};
|
|
933
|
+
// The manager reads config via deps.readMirrorConfig (in-memory); the
|
|
934
|
+
// route's history-on-link keys off the real per-vault file's existence.
|
|
935
|
+
// Seed both, same as the autopush test above.
|
|
936
|
+
deps.writeMirrorConfig(cfg);
|
|
937
|
+
writeMirrorConfigForVault("default", cfg);
|
|
938
|
+
await manager.start();
|
|
939
|
+
const mirrorPath = manager.getStatus().mirror_path;
|
|
940
|
+
expect(mirrorPath).toBeTruthy();
|
|
941
|
+
|
|
942
|
+
writeCredentials("default", {
|
|
943
|
+
active_method: "github_oauth",
|
|
944
|
+
github_oauth: {
|
|
945
|
+
access_token: "gho_persisttoken12345",
|
|
946
|
+
scope: "repo",
|
|
947
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
948
|
+
user_login: "aaron",
|
|
949
|
+
user_id: 1,
|
|
950
|
+
},
|
|
951
|
+
pat: null,
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
const res = await handleAuthGithubSelectRepo(
|
|
955
|
+
new Request("http://x/select", {
|
|
956
|
+
method: "POST",
|
|
957
|
+
body: JSON.stringify({ owner: "aaron", name: "backup-repo" }),
|
|
958
|
+
}),
|
|
959
|
+
manager,
|
|
960
|
+
);
|
|
961
|
+
expect(res.status).toBe(200);
|
|
962
|
+
|
|
963
|
+
// The fix: owner/name persisted into the credentials struct (not just origin).
|
|
964
|
+
const persisted = readCredentials("default");
|
|
965
|
+
expect(persisted?.github_oauth?.owner).toBe("aaron");
|
|
966
|
+
expect(persisted?.github_oauth?.name).toBe("backup-repo");
|
|
967
|
+
|
|
968
|
+
// Simulate the DELETE /auth + re-OAuth-without-reselect path: the OAuth
|
|
969
|
+
// token survives, but the git origin gets cleared.
|
|
970
|
+
Bun.spawnSync(["git", "remote", "remove", "origin"], { cwd: mirrorPath! });
|
|
971
|
+
const afterClear = Bun.spawnSync(["git", "remote", "get-url", "origin"], {
|
|
972
|
+
cwd: mirrorPath!,
|
|
973
|
+
});
|
|
974
|
+
expect(afterClear.exitCode).not.toBe(0); // origin is gone
|
|
975
|
+
|
|
976
|
+
// Restart — applyCredentialsToRemote runs again. Pre-#401 it would find no
|
|
977
|
+
// origin to parse and leave the remote unset; post-#401 it rebuilds from
|
|
978
|
+
// the persisted owner/name.
|
|
979
|
+
await manager.stop();
|
|
980
|
+
await manager.start();
|
|
981
|
+
|
|
982
|
+
const reapplied = Bun.spawnSync(["git", "remote", "get-url", "origin"], {
|
|
983
|
+
cwd: mirrorPath!,
|
|
984
|
+
});
|
|
985
|
+
expect(reapplied.exitCode).toBe(0);
|
|
986
|
+
const reappliedUrl = reapplied.stdout.toString().trim();
|
|
987
|
+
// The remote was reconstructed from persisted owner/name (token embedded,
|
|
988
|
+
// not asserted here). Without persistence this would be empty.
|
|
989
|
+
expect(reappliedUrl).toContain("github.com/aaron/backup-repo");
|
|
990
|
+
await manager.stop();
|
|
991
|
+
}, 30_000);
|
|
992
|
+
|
|
915
993
|
test("select-repo is a no-op for auto_push when mirror is disabled", async () => {
|
|
916
994
|
// Operator wiring credentials before flipping the mirror on — don't
|
|
917
995
|
// mutate auto_push behind their back.
|
package/src/mirror-routes.ts
CHANGED
|
@@ -1200,6 +1200,19 @@ export async function handleAuthGithubSelectRepo(
|
|
|
1200
1200
|
}
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
|
+
// vault#401: persist the selected owner/name INTO the credentials struct,
|
|
1204
|
+
// not only into the git origin URL. Pre-#401 the selection lived solely in
|
|
1205
|
+
// the origin URL; `applyCredentialsToRemote` regex-parsed it back out on
|
|
1206
|
+
// restart, so clearing the origin (e.g. `DELETE /auth` then re-OAuth without
|
|
1207
|
+
// re-selecting) silently lost the repo even with a still-valid token. Saving
|
|
1208
|
+
// it here lets the restart path reconstruct the remote from credentials
|
|
1209
|
+
// alone. Write before the remote-apply so the persisted selection survives
|
|
1210
|
+
// even if the apply later fails.
|
|
1211
|
+
writeCredentials(manager.getVaultName(), {
|
|
1212
|
+
...creds,
|
|
1213
|
+
github_oauth: { ...creds.github_oauth, owner, name },
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1203
1216
|
// Reach into mirror-credentials.ts for the authed URL builder.
|
|
1204
1217
|
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
1205
1218
|
const authedUrl = githubAuthedRemoteUrl(
|
package/src/routes.ts
CHANGED
|
@@ -44,6 +44,16 @@ import { findTokensReferencingTag } from "./token-store.ts";
|
|
|
44
44
|
export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
|
|
45
45
|
|
|
46
46
|
const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write-attribution context (vault#298) for REST writes — the principal
|
|
50
|
+
* (`actor`) and interface (`via`) threaded from the authenticated request into
|
|
51
|
+
* `store.createNote` / `store.updateNote`. Both null for paths without an auth
|
|
52
|
+
* context (the no-op default). See `WriteContext` in core/src/notes.ts.
|
|
53
|
+
*/
|
|
54
|
+
export type WriteCtx = { actor: string | null; via: string | null };
|
|
55
|
+
|
|
56
|
+
const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
|
|
47
57
|
import {
|
|
48
58
|
expandContent,
|
|
49
59
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -567,6 +577,13 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
567
577
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
568
578
|
extension: parseExtensionFilter(url),
|
|
569
579
|
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
580
|
+
// Write-attribution filters (vault#298) — symmetric with the MCP
|
|
581
|
+
// query-notes tool so a REST caller can ask "what did Mathilda write" /
|
|
582
|
+
// "what came in via the meeting-ingest surface" the same way.
|
|
583
|
+
createdBy: parseQuery(url, "created_by") ?? undefined,
|
|
584
|
+
lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
|
|
585
|
+
createdVia: parseQuery(url, "created_via") ?? undefined,
|
|
586
|
+
lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
|
|
570
587
|
...(bracket.dateFilter
|
|
571
588
|
? { dateFilter: bracket.dateFilter }
|
|
572
589
|
: parseQuery(url, "date_field")
|
|
@@ -704,9 +721,10 @@ export async function handleNotes(
|
|
|
704
721
|
subpath: string,
|
|
705
722
|
vault?: string,
|
|
706
723
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
724
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
707
725
|
): Promise<Response> {
|
|
708
726
|
try {
|
|
709
|
-
return await handleNotesInner(req, store, subpath, vault, tagScope);
|
|
727
|
+
return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
|
|
710
728
|
} catch (e: any) {
|
|
711
729
|
const ambig = ambiguousPathResponse(e);
|
|
712
730
|
if (ambig) return ambig;
|
|
@@ -720,6 +738,7 @@ async function handleNotesInner(
|
|
|
720
738
|
subpath: string,
|
|
721
739
|
vault?: string,
|
|
722
740
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
741
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
723
742
|
): Promise<Response> {
|
|
724
743
|
const url = new URL(req.url);
|
|
725
744
|
const method = req.method;
|
|
@@ -1109,6 +1128,9 @@ async function handleNotesInner(
|
|
|
1109
1128
|
metadata: item.metadata,
|
|
1110
1129
|
created_at: item.createdAt ?? item.created_at,
|
|
1111
1130
|
...(extension !== undefined ? { extension } : {}),
|
|
1131
|
+
// Write-attribution (vault#298) — REST batch create.
|
|
1132
|
+
actor: writeCtx.actor,
|
|
1133
|
+
via: writeCtx.via,
|
|
1112
1134
|
});
|
|
1113
1135
|
|
|
1114
1136
|
// Create explicit links
|
|
@@ -1383,6 +1405,9 @@ async function handleNotesInner(
|
|
|
1383
1405
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
1384
1406
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
1385
1407
|
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
1408
|
+
// Write-attribution (vault#298) — REST upsert-create branch.
|
|
1409
|
+
actor: writeCtx.actor,
|
|
1410
|
+
via: writeCtx.via,
|
|
1386
1411
|
};
|
|
1387
1412
|
const content = (body.content as string | undefined) ?? "";
|
|
1388
1413
|
const created = await store.createNote(content, createOpts);
|
|
@@ -1568,6 +1593,10 @@ async function handleNotesInner(
|
|
|
1568
1593
|
}
|
|
1569
1594
|
|
|
1570
1595
|
if (Object.keys(updates).length > 0) {
|
|
1596
|
+
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
1597
|
+
// write columns on the same UPDATE that bumps updated_at.
|
|
1598
|
+
updates.actor = writeCtx.actor;
|
|
1599
|
+
updates.via = writeCtx.via;
|
|
1571
1600
|
await store.updateNote(note.id, updates);
|
|
1572
1601
|
}
|
|
1573
1602
|
|
package/src/routing.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
handleStorage,
|
|
72
72
|
handleViewNote,
|
|
73
73
|
type TagScopeCtx,
|
|
74
|
+
type WriteCtx,
|
|
74
75
|
} from "./routes.ts";
|
|
75
76
|
import { handleSubscribe } from "./subscribe.ts";
|
|
76
77
|
import { handleTriggers } from "./triggers-api.ts";
|
|
@@ -812,7 +813,14 @@ export async function route(
|
|
|
812
813
|
raw: auth.scoped_tags,
|
|
813
814
|
};
|
|
814
815
|
|
|
815
|
-
|
|
816
|
+
// Write-attribution context (vault#298). `auth.actor` is the principal;
|
|
817
|
+
// `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
|
|
818
|
+
// `operator` for the env-var bearer). The REST surface IS the `api` channel,
|
|
819
|
+
// so no refinement is needed here — the base via stands (the MCP handler is
|
|
820
|
+
// the one that refines to `mcp`). Threaded only into the write handler.
|
|
821
|
+
const writeCtx: WriteCtx = { actor: auth.actor, via: auth.via };
|
|
822
|
+
|
|
823
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
816
824
|
// Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
|
|
817
825
|
// upsert/remove events over text/event-stream. Auth + tag-scope already
|
|
818
826
|
// resolved above and threaded through, mirroring the /notes branch.
|