Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
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:
@@ -183,6 +183,8 @@ keep push <vault> [--file .env] [--purge] -- encrypt + upload; requires a WRITE
|
|||||||
keep pull <vault> [--format env|json] [--out <path>] [--previous] -- fetch + decrypt; requires a grant
|
keep pull <vault> [--format env|json] [--out <path>] [--previous] -- fetch + decrypt; requires a grant
|
||||||
keep grant <vault> <recipient-id> [--read-only] -- add a recipient to a vault you have WRITE access to
|
keep grant <vault> <recipient-id> [--read-only] -- add a recipient to a vault you have WRITE access to
|
||||||
keep log <vault> -- tail the access log for one vault
|
keep log <vault> -- tail the access log for one vault
|
||||||
|
keep announce <vault> --tag <tag> -- record a deploy tag; requires ANY grant on the vault (read-only is enough)
|
||||||
|
keep watch <vault> [--interval 60] -- poll for a new announced tag; exits nonzero if the vault doesn't exist / no grant
|
||||||
|
|
||||||
# admin operations — gated by ADMIN_PASSWORD, see below
|
# admin operations — gated by ADMIN_PASSWORD, see below
|
||||||
keep recipient add --label "..." --pubkey <hex>
|
keep recipient add --label "..." --pubkey <hex>
|
||||||
@@ -215,6 +217,75 @@ Went with (2) — a global admin password is an acceptable trust model for
|
|||||||
"one person or small team running their own homelab," which is the
|
"one person or small team running their own homelab," which is the
|
||||||
actual scale this is built for.
|
actual scale this is built for.
|
||||||
|
|
||||||
|
## Announce/watch — a deploy signal, not a secret
|
||||||
|
|
||||||
|
CI producing a fresh image on every push still left deployment as a
|
||||||
|
manual step, and the obvious fix — CI SSHes into the VPS and redeploys —
|
||||||
|
was rejected: it means a standing SSH credential with production-deploy
|
||||||
|
rights sits in CI secrets, reachable by whatever's executing on a shared
|
||||||
|
runner across every project it builds. A compromised build step in one
|
||||||
|
project could then reach every other project's deploy target, which is
|
||||||
|
a real regression from `keep`'s own "read no longer implies write"
|
||||||
|
posture (see above).
|
||||||
|
|
||||||
|
The fix that doesn't reintroduce that problem: reverse the direction.
|
||||||
|
CI never reaches into a VPS — it tells `keep` "there's a new image for
|
||||||
|
this project" (`keep announce <vault> --tag <tag>`), and each VPS, which
|
||||||
|
already has to run *something* locally to redeploy itself, polls `keep`
|
||||||
|
for that signal (`keep watch <vault>`) and acts on it locally, using an
|
||||||
|
identity and grant it already legitimately holds. `keep`'s server never
|
||||||
|
executes anything remotely — it stays a pure relay, same posture as
|
||||||
|
everything else it does.
|
||||||
|
|
||||||
|
This was deliberately not built as "`keep` executes the redeploy
|
||||||
|
itself," even though `keep` typically runs on the same box as its
|
||||||
|
deploy targets and that would skip the polling indirection entirely.
|
||||||
|
Doing so would trade away the property that's been the throughline of
|
||||||
|
`keep`'s whole design: today, a fully compromised `keep` server — not
|
||||||
|
just a compromised client identity — can't do anything worse than leak
|
||||||
|
ciphertext and metadata, because the server never holds a decryption
|
||||||
|
key. The moment `keep` can also execute a command on `announce`, an
|
||||||
|
ordinary web app bug (not even a cryptographic break) becomes "attacker
|
||||||
|
triggers arbitrary redeploys" — a new class of bad outcome, not a bigger
|
||||||
|
version of an existing one.
|
||||||
|
|
||||||
|
An announced tag is stored outside the encrypted vault payload
|
||||||
|
(`vault_announcements`, one row per vault — latest known state, not a
|
||||||
|
history, same shape as `vaults.prev_ciphertext`) since an image tag
|
||||||
|
isn't a secret and doesn't belong inside a secrets blob alongside real
|
||||||
|
credentials. `POST /vaults/:key/announce` only requires *a* grant, not a
|
||||||
|
write grant — `hasGrant`, not `hasWriteGrant` — since announcing isn't a
|
||||||
|
mutation of a vault's secret content, just a signal; a CI identity for a
|
||||||
|
project can stay read-only even with this feature in play. `keep watch`
|
||||||
|
is deliberately dumb polling, not a websocket or long-poll — matches the
|
||||||
|
pattern already used elsewhere in this project family, and a 60-second
|
||||||
|
detection lag for a deploy is an acceptable tradeoff against the
|
||||||
|
complexity of push-based notification.
|
||||||
|
|
||||||
|
Two things worth stating plainly about what this does and doesn't
|
||||||
|
change:
|
||||||
|
|
||||||
|
- The tag itself is now server-visible, where previously not even the
|
||||||
|
fact that a new image existed was something `keep` needed to know.
|
||||||
|
Accepted tradeoff — a git short-SHA isn't sensitive, and the feature
|
||||||
|
only works if `keep` can see it.
|
||||||
|
- A compromised read-only CI identity could announce a *false* tag,
|
||||||
|
pointing a deploy target at an attacker-chosen image. `keep` never
|
||||||
|
pulls or runs that image itself, only relays the string — the actual
|
||||||
|
`docker compose pull` on the deploy target still pulls from whatever
|
||||||
|
registry it's configured for, using the tag it was told. This is
|
||||||
|
equivalent in severity to that CI identity's build step being
|
||||||
|
compromised in the first place (it could already push a bad image
|
||||||
|
under a legitimate tag), not a new capability.
|
||||||
|
|
||||||
|
A bulk multi-project deploy script (looping over a fixed project list,
|
||||||
|
`docker compose up -d` per project) adopts this by checking for a
|
||||||
|
per-project `.keep-vault` marker file before calling `keep pull`; a
|
||||||
|
missing marker falls through to exactly what the script did before
|
||||||
|
`keep` existed — never a hard failure, since not every project will be
|
||||||
|
bootstrapped onto `keep` at the same time. `scripts/deploy.sh` in this
|
||||||
|
repo is the single-project version of the same pattern.
|
||||||
|
|
||||||
## Deploy integration
|
## Deploy integration
|
||||||
|
|
||||||
The actual motivating use case — a deploy script pulling secrets instead
|
The actual motivating use case — a deploy script pulling secrets instead
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
# Proposal: announce + a self-deploying agent, with a no-`keep` fallback
|
# Proposal: announce + a self-deploying agent, with a no-`keep` fallback
|
||||||
|
|
||||||
**Status:** proposed, not built. Self-contained — written so a fresh agent
|
**Status:** implemented (§1–§3, §6 shape). `announce`/`watch`, the
|
||||||
with no prior conversation context can pick this up and implement it
|
`vault_announcements` table, and the API routes described below are
|
||||||
without anything explained first.
|
built — see [IMPLEMENTATION.md](./IMPLEMENTATION.md)'s "Announce/watch"
|
||||||
|
section for the as-built version of the design rationale, and the CLI
|
||||||
|
surface list there for the exact commands. §4 (patching the real bulk
|
||||||
|
deploy script) and §6 (the CI workflow step) are per-project changes
|
||||||
|
made outside this repo, on the host and in each project's own
|
||||||
|
`.gitea/workflows/docker.yml` — not something this repo's code can
|
||||||
|
contain. This file is kept as the original design record, superseded by
|
||||||
|
IMPLEMENTATION.md as the source of truth for what's actually built.
|
||||||
|
|
||||||
## Context (read this first)
|
## Context (read this first)
|
||||||
|
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -141,6 +141,29 @@ Pushing to an existing vault requires a *write* grant — a read-only
|
|||||||
recipient can pull but can't push or grant others access. A brand-new
|
recipient can pull but can't push or grant others access. A brand-new
|
||||||
vault's first push is always read-write for the pusher.
|
vault's first push is always read-write for the pusher.
|
||||||
|
|
||||||
|
## Announcing deploys
|
||||||
|
|
||||||
|
`keep` can also relay a small "there's a new image" signal alongside a
|
||||||
|
vault, so CI never needs standing SSH access to a deploy target — CI
|
||||||
|
announces a tag, and whatever already redeploys that box locally polls
|
||||||
|
for it instead of being reached into from outside:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CI, after a successful build/push — any grant is enough, read-only included:
|
||||||
|
keep announce myapp/production --tag <short-sha>
|
||||||
|
|
||||||
|
# The deploy target polls for a change and prints it:
|
||||||
|
keep watch myapp/production --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
An announced tag isn't a secret — it lives outside the encrypted vault
|
||||||
|
payload, in its own small table, and is visible to the server (same as
|
||||||
|
vault names and access timestamps already are). `keep` never pulls or
|
||||||
|
runs the announced image itself; it only relays the string. See
|
||||||
|
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for how this fits into a bulk
|
||||||
|
deploy script with a no-`keep` fallback for projects not yet bootstrapped
|
||||||
|
onto it.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -177,12 +200,16 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`),
|
|||||||
|
|
||||||
Implemented: identity, push/pull/grant/revoke/log, read/write grant
|
Implemented: identity, push/pull/grant/revoke/log, read/write grant
|
||||||
scoping, one-step rollback with an explicit purge mode for
|
scoping, one-step rollback with an explicit purge mode for
|
||||||
compromise-driven rotations, admin recipient management, Docker deploy.
|
compromise-driven rotations, admin recipient management, an admin
|
||||||
Verified end-to-end — two and three independent identities, read-only
|
cross-vault `overview`, deploy-signal `announce`/`watch`, a
|
||||||
grants correctly blocked from push/grant, grants preserved across
|
`scripts/deploy.sh` wrapper, and Docker deploy. Verified end-to-end —
|
||||||
rotation, previous-version pull working and correctly wiped by
|
two and three independent identities, read-only grants correctly
|
||||||
`--purge` for every recipient, malformed-auth rejection — against both
|
blocked from push/grant, grants preserved across rotation,
|
||||||
a local server and the built Docker image. See
|
previous-version pull working and correctly wiped by `--purge` for
|
||||||
|
every recipient, a read-only identity successfully announcing while
|
||||||
|
still being logged, `watch` picking up an announced tag and exiting
|
||||||
|
nonzero for an unknown/ungranted vault, malformed-auth rejection —
|
||||||
|
against both a local server and the built Docker image. See
|
||||||
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its
|
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its
|
||||||
resolved decisions (per-secret-key granularity deliberately not built —
|
resolved decisions (per-secret-key granularity deliberately not built —
|
||||||
use multiple vaults instead).
|
use multiple vaults instead).
|
||||||
|
|||||||
@@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise<void> {
|
|||||||
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
|
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function vaultAnnounce(vaultKey: string, tag: string): Promise<void> {
|
||||||
|
await ready();
|
||||||
|
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||||
|
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`, { tag }));
|
||||||
|
console.log(`announced '${vaultKey}' @ ${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAnnouncement(vaultKey: string): Promise<{ tag: string; announcedAt: number; announcedBy: string } | null> {
|
||||||
|
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||||
|
const res = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
return await expectOk(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately dumb — plain polling, not a websocket or long-poll. A
|
||||||
|
// 60s detection lag for a deploy signal is fine; don't build push-based
|
||||||
|
// notification for this.
|
||||||
|
export async function vaultWatch(vaultKey: string, intervalSeconds: number): Promise<void> {
|
||||||
|
await ready();
|
||||||
|
console.log(`watching '${vaultKey}' every ${intervalSeconds}s (ctrl-c to stop)...`);
|
||||||
|
|
||||||
|
let lastTag: string | null = null;
|
||||||
|
for (;;) {
|
||||||
|
const announcement = await fetchAnnouncement(vaultKey);
|
||||||
|
if (announcement && announcement.tag !== lastTag) {
|
||||||
|
lastTag = announcement.tag;
|
||||||
|
console.log(`${new Date(announcement.announcedAt * 1000).toISOString()} ${vaultKey} ${announcement.tag} (by ${announcement.announcedBy})`);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
||||||
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
|
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
|
||||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
|
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog, vaultAnnounce, vaultWatch } from './commands/vault.js';
|
||||||
|
|
||||||
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
||||||
const i = args.indexOf(`--${name}`);
|
const i = args.indexOf(`--${name}`);
|
||||||
@@ -35,6 +35,8 @@ async function main(): Promise<void> {
|
|||||||
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||||
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
||||||
if (cmd === 'log') return await vaultLog(sub);
|
if (cmd === 'log') return await vaultLog(sub);
|
||||||
|
if (cmd === 'announce') return await vaultAnnounce(sub, flag(rest, 'tag')!);
|
||||||
|
if (cmd === 'watch') return await vaultWatch(sub, Number(flag(rest, 'interval', '60')));
|
||||||
|
|
||||||
printUsage();
|
printUsage();
|
||||||
process.exitCode = cmd ? 1 : 0;
|
process.exitCode = cmd ? 1 : 0;
|
||||||
@@ -56,6 +58,8 @@ function printUsage(): void {
|
|||||||
keep grant <vault> <recipient-id> [--read-only]
|
keep grant <vault> <recipient-id> [--read-only]
|
||||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||||
keep log <vault>
|
keep log <vault>
|
||||||
|
keep announce <vault> --tag <tag> (requires any grant, read-only is enough)
|
||||||
|
keep watch <vault> [--interval 60] (polls for a new announced tag; ctrl-c to stop)
|
||||||
|
|
||||||
keep recipient add --label <label> --pubkey <hex> (admin)
|
keep recipient add --label <label> --pubkey <hex> (admin)
|
||||||
keep recipient list (admin)
|
keep recipient list (admin)
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ db.exec(`
|
|||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
accessed_at INTEGER 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 {
|
export interface VaultRow {
|
||||||
@@ -88,6 +99,13 @@ export interface GrantRow {
|
|||||||
granted_at: number;
|
granted_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementRow {
|
||||||
|
vault_key: string;
|
||||||
|
tag: string;
|
||||||
|
announced_at: number;
|
||||||
|
announced_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function getVault(vaultKey: string): VaultRow | undefined {
|
export function getVault(vaultKey: string): VaultRow | undefined {
|
||||||
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as 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);
|
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(
|
db.prepare(
|
||||||
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
||||||
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
|
).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 ?`,
|
`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 }[];
|
).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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
logAccess,
|
logAccess,
|
||||||
getAccessLog,
|
getAccessLog,
|
||||||
listRecipients,
|
listRecipients,
|
||||||
|
setAnnouncement,
|
||||||
|
getAnnouncement,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
|
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
|
||||||
|
|
||||||
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
|
|||||||
}
|
}
|
||||||
res.json(getAccessLog(vaultKey));
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user