Relationships
#2436 serve: boot token-secret migration deletes the source vault item
Opened by swamp_lord · 9/23/2026
Summary
On every boot, migrateTokenSecrets copies a server token's secret from a
user vault into _token-secrets, then deletes it from the user vault.
Anything else reading that vault item (CI, another service) breaks at the next
restart, with nothing in the logs about the deletion.
Environment
- swamp
main@1da85ec8, unmodified.migrateTokenSecretsis exercised directly with in-memory vault and data fakes. - Seen in production on swamp
20260910(core375d7eca) with a@swamp/1password@2026.09.10.0user vault.
Steps to reproduce
- A server token
ci-deploywhose record hasvaultName: team-vault,secretKey: server-token-ci-deploy(a legacy / user-vault-backed token). - An external system (e.g. CI) reads
team-vault/server-token-ci-deploy. - Start or restart serve → log
Migrated token secrets for "ci-deploy". - The item is gone from
team-vault, and CI fails because the item doesn't exist.
A self-contained test calling migrateTokenSecrets from a swamp checkout (migration_deletes_vault_item_test.ts):
// serve's boot-time token secret migration deletes the source vault item.
//
// Runs swamp main's own migrateTokenSecrets() (src/serve/token_secret_migration.ts)
// with in-memory fakes: a "legacy" server token whose secret lives in a user vault
// ("team-vault", e.g. a 1Password vault that CI also reads). After migration the
// secret is copied to _token-secrets AND deleted from the user vault.
//
// SWAMP_SRC=<swamp checkout> deno test -A migration_deletes_vault_item_test.ts
import { assertEquals } from "jsr:@std/assert@1";
const SRC = Deno.env.get("SWAMP_SRC")!;
const { migrateTokenSecrets } = await import(`${SRC}/src/serve/token_secret_migration.ts`);
const { TOKEN_SECRETS_VAULT_NAME } = await import(`${SRC}/src/domain/vaults/control_plane_vault_provider.ts`);
class FakeVaults {
store = new Map<string, Map<string, string>>();
log: string[] = [];
constructor(init: Record<string, Record<string, string>>) {
for (const [v, kv] of Object.entries(init)) this.store.set(v, new Map(Object.entries(kv)));
}
get(vault: string, key: string) {
const v = this.store.get(vault)?.get(key);
if (v === undefined) return Promise.reject(new Error(`missing ${vault}/${key}`));
return Promise.resolve(v);
}
put(vault: string, key: string, value: string) {
if (!this.store.has(vault)) this.store.set(vault, new Map());
this.store.get(vault)!.set(key, value);
this.log.push(`put ${vault}/${key}`);
return Promise.resolve();
}
supportsDelete(_vault: string) { return true; }
delete(vault: string, key: string) {
this.store.get(vault)?.delete(key);
this.log.push(`delete ${vault}/${key}`);
return Promise.resolve();
}
}
Deno.test("migrateTokenSecrets deletes the user-vault item after copying it", async () => {
const vaults = new FakeVaults({ "team-vault": { "server-token-ci-deploy": "s3cr3t" } });
const now = new Date().toISOString();
const later = new Date(Date.now() + 86_400_000).toISOString();
const record = {
attributes: {
name: "ci-deploy", state: "active", principalId: "user:ci", principalEmail: "ci@example.com",
collectives: [], groups: [], createdAt: now, expiresAt: later,
vaultName: "team-vault", secretKey: "server-token-ci-deploy",
},
};
const result = await migrateTokenSecrets({
tokenSecretsVaultName: TOKEN_SECRETS_VAULT_NAME,
// deno-lint-ignore no-explicit-any
vaultService: vaults as any,
// deno-lint-ignore no-explicit-any
dataQueryService: { query: () => Promise.resolve([record]) } as any,
updateTokenVaultName: () => Promise.resolve(),
});
console.log("result:", JSON.stringify(result));
console.log("vault operations:", vaults.log.join(" | "));
console.log("team-vault still has server-token-ci-deploy:", vaults.store.get("team-vault")!.has("server-token-ci-deploy"));
assertEquals(result.migrated, 1);
assertEquals(vaults.store.get(TOKEN_SECRETS_VAULT_NAME)!.get("server-token-ci-deploy"), "s3cr3t");
// The behaviour this issue is about: the external consumer's item is gone.
assertEquals(vaults.store.get("team-vault")!.has("server-token-ci-deploy"), false);
});Run it with:
SWAMP_SRC=<swamp checkout> deno test -A --config $SWAMP_SRC/deno.json migration_deletes_vault_item_test.tsExpected
The source item is left in place by default. Or deletion is opt-in, and it's logged when an external item is removed.
Evidence
result: {"migrated":1,"skipped":0,"failed":0}
vault operations: put _token-secrets/server-token-ci-deploy | delete team-vault/server-token-ci-deploy | delete team-vault/oauth-access-token-ci-deploy
team-vault still has server-token-ci-deploy: falseRoot cause (on main)
src/serve/token_secret_migration.ts (≈L112–121): after the copy, if
vaultService.supportsDelete(token.vaultName), it calls
delete(token.vaultName, token.secretKey) and delete(token.vaultName, oauthKey) unconditionally.
Also
When a token name has duplicate records and one still references a vault item
that was already deleted, L81 logs Token secret missing from vault for <name>, skipping, even though another record with that name is valid.
Related
#2113 (shipped, consistency-sweep false positives), #2235 (shipped, token data durability).
Reproduction configuration
Test environment
| swamp | built from main @ 1da85ec847ef6a8a0ce0297563b2a9fab1f4b2b8, unmodified (20260923.175528.0-sha.1da85ec8) |
| Datastore | @swamp/s3-datastore@2026.09.23.1 on local MinIO (http://127.0.0.1:19000, bucket swamp-repro) |
| Vault | none configured; control-plane _token-secrets only |
| Instances | each serve instance starts with an empty HOME and a repo containing only .swamp.yaml |
Exercised directly: migrateTokenSecrets from src/serve/token_secret_migration.ts with in-memory vault and data fakes (user vault team-vault holding server-token-ci-deploy).
Closed
No activity in this phase yet.
stack72 commented 9/23/2026, 7:48:27 PM
This is expected behavior, not a defect.
serve owns server access-token credentials. The startup migration only applies to legacy tokens whose credentials still live outside _token-secrets; it moves the credentials to the encrypted control-plane vault, updates the token record, and removes the legacy copies.
For each migrated token, the only source-vault keys removed are the token’s server-token- secret and its paired oauth-access-token- credential, if present. It does not enumerate or delete unrelated vault items.
A user vault was a previous storage location for these serve-owned credentials, not a supported shared-secret interface. External systems should not read those server-token-* or oauth-access-token-* entries directly. CI requiring its own credential should use a separately managed secret.
Sign in to post a ripple.