Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s

CI announces a new image tag to keep (any grant is enough, read-only
included); each deploy target polls for it locally instead of CI
holding standing SSH access to production. keep stays a pure relay —
the tag lives outside the encrypted vault payload in its own table,
and keep never executes anything itself.

Adds vault_announcements, POST/GET /vaults/:key/announce, and the
`keep announce`/`keep watch` CLI commands, per
PROPOSAL-announce-and-agent.md (now marked implemented). Verified live:
a read-only identity announcing successfully and being logged, watch
picking up the tag on its first poll, and watch exiting nonzero for an
unknown/ungranted vault.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-12 21:12:55 +02:00
parent 674e408f09
commit 58d5d787dd
7 changed files with 225 additions and 11 deletions

View File

@@ -61,6 +61,17 @@ db.exec(`
action TEXT NOT NULL,
accessed_at INTEGER NOT NULL
);
-- Latest known deploy tag for a vault — "current state", not a
-- history, same one-row-per-vault shape as the vaults table's own
-- prev_ciphertext retention. An image tag isn't a secret, so this
-- lives outside the encrypted payload entirely.
CREATE TABLE IF NOT EXISTS vault_announcements (
vault_key TEXT PRIMARY KEY,
tag TEXT NOT NULL,
announced_at INTEGER NOT NULL,
announced_by TEXT NOT NULL
);
`);
export interface VaultRow {
@@ -88,6 +99,13 @@ export interface GrantRow {
granted_at: number;
}
export interface AnnouncementRow {
vault_key: string;
tag: string;
announced_at: number;
announced_by: string;
}
export function getVault(vaultKey: string): VaultRow | undefined {
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
}
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
}
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
db.prepare(
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
}
// One row per vault — "latest known tag", not a history. Upsert.
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
db.prepare(
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(vault_key) DO UPDATE SET
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
}
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
}

View File

@@ -11,6 +11,8 @@ import {
logAccess,
getAccessLog,
listRecipients,
setAnnouncement,
getAnnouncement,
} from './db.js';
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
}
res.json(getAccessLog(vaultKey));
});
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
// just a small side table. Only requires ANY grant (read-only is enough)
// since announcing isn't a mutation of vault secrets — a CI identity
// should never need write access on a vault just to say "there's a new
// image". Resist reaching for hasWriteGrant here by analogy with push.
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const recipientId = req.recipientId!;
if (!hasGrant(vaultKey, recipientId)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const body = req.body as { tag?: string };
if (!body.tag) {
res.status(400).json({ error: 'tag is required' });
return;
}
setAnnouncement(vaultKey, body.tag, recipientId);
logAccess(vaultKey, recipientId, 'announce');
res.json({ ok: true });
});
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const announcement = getAnnouncement(vaultKey);
if (!announcement) {
res.status(404).json({ error: 'nothing announced yet' });
return;
}
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
});