Skip to content

Commit e489eb0

Browse files
hyperpolymathclaude
andcommitted
fix(vault-worker): replace KV grant store with Durable Objects (atomic one-use)
KV has no atomic read+delete — two simultaneous redeem requests for the same grant could both succeed. Fix: one GrantObject DO instance per outstanding grant. The Workers runtime serialises concurrent fetches to the same DO instance, so the read+delete in the POST handler is atomic. - Remove GRANTS KV namespace (wrangler.toml + all kv("GRANTS") calls) - Add GrantObject Durable Object: PUT stores GrantRecord, POST atomically reads+deletes and returns hint (or 404/410 on miss/expiry) - wrangler.toml: add [durable_objects] binding + [[migrations]] v1 block - Fix workers-rs 0.8 API: fetch takes &self (not &mut self); storage.get returns Result<Option<T>> (not Result<T>); #[durable_object] on struct only - Add explicit wasm-bindgen + js-sys deps for JsValue body + Date::now() Type-checks clean against wasm32-unknown-unknown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b17d356 commit e489eb0

4 files changed

Lines changed: 225 additions & 94 deletions

File tree

vault-worker/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vault-worker/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ uuid = { version = "1.6", features = ["v4", "js"] }
2727
# Routes panic messages to the Workers console log rather than aborting silently.
2828
console_error_panic_hook = "0.1"
2929

30+
# Explicit transitive deps used directly in source.
31+
# workers-rs depends on these but naming them explicitly avoids surprise if
32+
# workers-rs ever changes its re-export surface.
33+
wasm-bindgen = "0.2" # JsValue for DO request bodies
34+
js-sys = "0.3" # Date::now() for Unix timestamps in WASM
35+
3036
[profile.release]
3137
# Workers bundles are size-limited: optimize aggressively.
3238
opt-level = "s"

vault-worker/src/lib.rs

Lines changed: 184 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,119 @@
33
//
44
// vault-worker — Cloudflare Workers implementation of the RGTV grant broker.
55
//
6-
// This is the production deployment target. It exposes an HTTP API identical
7-
// to vault-broker so that the rgtv CLI and all consumers can talk to either
8-
// without modification.
9-
//
106
// Architecture:
117
// CREDENTIALS KV namespace — hint → credential value (operator-managed)
12-
// GRANTS KV namespace — grant_id → hint (30 s TTL, set here)
8+
// GRANTS DO namespace — one GrantObject Durable Object per outstanding grant
9+
//
10+
// Why Durable Objects for grants (not KV):
11+
// KV has no atomic read+delete — two simultaneous redeem requests for the same
12+
// grant could both succeed. Durable Object instances are single-threaded: the
13+
// Workers runtime queues concurrent fetches to the same DO instance. The
14+
// read+delete inside the POST handler is therefore a true atomic operation —
15+
// the second request sees the key gone when it starts.
1316
//
14-
// HTTP API (identical to vault-broker):
15-
// GET /health — unauthenticated liveness probe
16-
// GET /v1/credentials — list registered hint names
17-
// POST /v1/grants — issue a one-use grant for a hint
18-
// POST /v1/grants/:id/redeem — redeem grant, receive credential
17+
// HTTP API (identical to vault-broker — rgtv CLI works against both):
18+
// GET /health — unauthenticated liveness probe
19+
// GET /v1/credentials — list registered hint names (authenticated)
20+
// POST /v1/grants — issue a one-use grant (authenticated)
21+
// POST /v1/grants/:id/redeem — redeem grant, get credential (authenticated)
1922
//
2023
// Authentication:
2124
// All endpoints except /health require:
22-
// Authorization: Bearer <RGTV_AGENT_TOKEN>
23-
//
24-
// ── KV double-spend caveat (alpha) ─────────────────────────────────��────────
25-
// KV does not provide atomic read+delete. Two simultaneous redeem requests
26-
// for the same grant_id may both succeed within the ~30 s TTL window.
27-
// Acceptable for alpha (single-agent use, 30 s exposure window).
28-
// Production mitigation: migrate GRANTS to a Durable Object.
29-
// ────────────────────────────────────────────────────────────────────────────
25+
// Authorization: Bearer <RGTV_AGENT_TOKEN> (Worker Secret)
3026

31-
#![allow(clippy::future_not_send)] // workers-rs handlers are single-threaded WASM
27+
#![allow(clippy::future_not_send)] // workers-rs handlers run in single-threaded WASM
3228

29+
use js_sys::Date;
3330
use serde::{Deserialize, Serialize};
3431
use uuid::Uuid;
3532
use worker::*;
3633

34+
// ---------------------------------------------------------------------------
35+
// Timestamp helper
36+
// ---------------------------------------------------------------------------
37+
38+
/// Current time as Unix seconds, sourced from the JS runtime clock.
39+
fn now_secs() -> u64 {
40+
(Date::now() / 1000.0) as u64
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Grant record — stored in GrantObject DO storage
45+
// ---------------------------------------------------------------------------
46+
47+
/// A pending, unredeemed grant. Stored as a single DO storage entry under
48+
/// the key "record". The DO instance is identified by the grant_id UUID, so
49+
/// there is a 1:1 correspondence between DO instances and outstanding grants.
50+
#[derive(Serialize, Deserialize)]
51+
struct GrantRecord {
52+
hint: String,
53+
/// Unix seconds at which the grant expires.
54+
expires_at: u64,
55+
}
56+
57+
// ---------------------------------------------------------------------------
58+
// GrantObject — Durable Object (one instance per outstanding grant)
59+
// ---------------------------------------------------------------------------
60+
61+
/// A single DO instance owns exactly one grant record. The Workers runtime
62+
/// serialises all fetches to a given instance, so the check-and-delete in the
63+
/// POST (redeem) handler is atomic — no locks, no races.
64+
#[durable_object]
65+
pub struct GrantObject {
66+
state: State,
67+
env: Env,
68+
}
69+
70+
impl DurableObject for GrantObject {
71+
fn new(state: State, env: Env) -> Self {
72+
GrantObject { state, env }
73+
}
74+
75+
async fn fetch(&self, mut req: Request) -> Result<Response> {
76+
match req.method() {
77+
// PUT — store a new grant record.
78+
// Called once by handle_create_grant immediately after the grant_id
79+
// is assigned. Body: JSON-encoded GrantRecord.
80+
Method::Put => {
81+
let record: GrantRecord = req.json().await?;
82+
self.state.storage().put("record", record).await?;
83+
Response::ok("")
84+
}
85+
86+
// POST — redeem: atomic read + delete.
87+
// Because DO instances are single-threaded, no two POST requests
88+
// to this instance can interleave. The second one will find the
89+
// key absent and receive 404.
90+
Method::Post => {
91+
let storage = self.state.storage();
92+
// storage.get returns Result<Option<T>> in workers-rs 0.8.
93+
match storage.get::<GrantRecord>("record").await {
94+
Ok(Some(record)) => {
95+
// Delete first — one-use is enforced before expiry is
96+
// checked so an expired grant is also consumed and gone.
97+
storage.delete("record").await?;
98+
99+
if record.expires_at <= now_secs() {
100+
Response::error("grant expired", 410)
101+
} else {
102+
// Return only the hint; the worker resolves the
103+
// actual credential value from CREDENTIALS KV.
104+
Response::from_json(&serde_json::json!({ "hint": record.hint }))
105+
}
106+
}
107+
// Key absent (None) or storage error → treat as not found.
108+
Ok(None) | Err(_) => {
109+
Response::error("grant not found or already redeemed", 404)
110+
}
111+
}
112+
}
113+
114+
_ => Response::error("method not allowed", 405),
115+
}
116+
}
117+
}
118+
37119
// ---------------------------------------------------------------------------
38120
// JSON wire types — identical to vault-broker for drop-in compatibility
39121
// ---------------------------------------------------------------------------
@@ -78,15 +160,18 @@ struct ErrorBody {
78160
// Auth helper
79161
// ---------------------------------------------------------------------------
80162

81-
/// Returns Ok(()) if the request carries the correct bearer token, otherwise
82-
/// a 401 Response.
163+
/// Returns `Ok(())` if the request carries the correct bearer token, or an
164+
/// error-carrying `Response` (caller should return it immediately).
83165
fn check_auth(req: &Request, env: &Env) -> std::result::Result<(), Response> {
84166
let token = match env.var("RGTV_AGENT_TOKEN") {
85167
Ok(v) => v.to_string(),
86168
Err(_) => {
87-
// Fail closed: if the secret is not set, reject everything.
88-
return Err(Response::error("server misconfigured: RGTV_AGENT_TOKEN not set", 500)
89-
.unwrap_or_else(|_| Response::empty().unwrap()));
169+
// Fail closed — if the secret is missing, reject every request.
170+
return Err(Response::error(
171+
"server misconfigured: RGTV_AGENT_TOKEN not set",
172+
500,
173+
)
174+
.unwrap_or_else(|_| Response::empty().unwrap()));
90175
}
91176
};
92177
let expected = format!("Bearer {token}");
@@ -103,7 +188,6 @@ fn check_auth(req: &Request, env: &Env) -> std::result::Result<(), Response> {
103188
}
104189
}
105190

106-
/// Convenience macro: return a 401 early if auth fails.
107191
macro_rules! require_auth {
108192
($req:expr, $env:expr) => {
109193
if let Err(r) = check_auth($req, $env) {
@@ -113,7 +197,7 @@ macro_rules! require_auth {
113197
}
114198

115199
// ---------------------------------------------------------------------------
116-
// Grant TTL helper
200+
// Grant TTL
117201
// ---------------------------------------------------------------------------
118202

119203
fn grant_ttl(env: &Env) -> u64 {
@@ -123,18 +207,37 @@ fn grant_ttl(env: &Env) -> u64 {
123207
.unwrap_or(30)
124208
}
125209

210+
// ---------------------------------------------------------------------------
211+
// DO call helper — build a Request to send to a GrantObject stub
212+
// ---------------------------------------------------------------------------
213+
214+
/// Build a PUT request carrying `body_json` for the DO store operation.
215+
fn do_put_request(body_json: &str) -> Result<Request> {
216+
let mut headers = Headers::new();
217+
headers.set("Content-Type", "application/json")?;
218+
let body = wasm_bindgen::JsValue::from_str(body_json);
219+
let mut init = RequestInit::new();
220+
init.with_method(Method::Put)
221+
.with_headers(headers)
222+
.with_body(Some(body));
223+
Request::new_with_init("https://do/grant", &init)
224+
}
225+
226+
/// Build a POST request (no body) for the DO redeem operation.
227+
fn do_post_request() -> Result<Request> {
228+
let mut init = RequestInit::new();
229+
init.with_method(Method::Post);
230+
Request::new_with_init("https://do/grant", &init)
231+
}
232+
126233
// ---------------------------------------------------------------------------
127234
// GET /health
128235
// ---------------------------------------------------------------------------
129236

130-
/// Unauthenticated liveness probe. Returns broker version and credential count.
237+
/// Unauthenticated — returns broker status and number of registered credentials.
131238
async fn handle_health(_req: Request, ctx: RouteContext<()>) -> Result<Response> {
132239
let kv = ctx.kv("CREDENTIALS")?;
133-
// List all keys to get a count; KV list is eventually consistent but
134-
// adequate for an informational health check.
135-
let list = kv.list().execute().await?;
136-
let count = list.keys.len();
137-
240+
let count = kv.list().execute().await?.keys.len();
138241
Response::from_json(&HealthResponse {
139242
status: "ok",
140243
version: "0.1.0",
@@ -146,17 +249,21 @@ async fn handle_health(_req: Request, ctx: RouteContext<()>) -> Result<Response>
146249
// GET /v1/credentials
147250
// ---------------------------------------------------------------------------
148251

149-
/// List registered hint names. No credential values are returned.
150-
/// Requires bearer token.
252+
/// List registered hint names — no credential values returned.
151253
async fn handle_credentials(req: Request, ctx: RouteContext<()>) -> Result<Response> {
152254
require_auth!(&req, &ctx.env);
153255

154256
let kv = ctx.kv("CREDENTIALS")?;
155-
let list = kv.list().execute().await?;
156-
let mut hints: Vec<String> = list.keys.into_iter().map(|k| k.name).collect();
257+
let mut hints: Vec<String> = kv
258+
.list()
259+
.execute()
260+
.await?
261+
.keys
262+
.into_iter()
263+
.map(|k| k.name)
264+
.collect();
157265
hints.sort();
158266
let count = hints.len();
159-
160267
Response::from_json(&CredentialsResponse { hints, count })
161268
}
162269

@@ -165,8 +272,11 @@ async fn handle_credentials(req: Request, ctx: RouteContext<()>) -> Result<Respo
165272
// ---------------------------------------------------------------------------
166273

167274
/// Issue a one-use grant for a named credential hint.
168-
/// Body: { "hint": "<HINT_NAME>" }
169-
/// Returns: { "grant_id": "…", "hint": "…", "expires_in_secs": 30 }
275+
///
276+
/// Creates a GrantObject DO instance keyed by the new grant_id UUID and PUTs
277+
/// the GrantRecord into it. The DO instance will hold the record until it is
278+
/// redeemed (POST /v1/grants/:id/redeem) or the TTL elapses and the grant
279+
/// expires.
170280
async fn handle_create_grant(mut req: Request, ctx: RouteContext<()>) -> Result<Response> {
171281
require_auth!(&req, &ctx.env);
172282

@@ -179,9 +289,8 @@ async fn handle_create_grant(mut req: Request, ctx: RouteContext<()>) -> Result<
179289
return Response::error("hint must not be empty", 400);
180290
}
181291

182-
// Validate the hint exists in the CREDENTIALS namespace.
183-
let cred_kv = ctx.kv("CREDENTIALS")?;
184-
if cred_kv.get(&hint).text().await?.is_none() {
292+
// Validate the hint exists in CREDENTIALS before issuing a grant.
293+
if ctx.kv("CREDENTIALS")?.get(&hint).text().await?.is_none() {
185294
return Response::error(
186295
&serde_json::to_string(&ErrorBody {
187296
error: format!("unknown hint: {hint}"),
@@ -193,15 +302,17 @@ async fn handle_create_grant(mut req: Request, ctx: RouteContext<()>) -> Result<
193302

194303
let ttl = grant_ttl(&ctx.env);
195304
let grant_id = Uuid::new_v4().to_string();
305+
let record = GrantRecord {
306+
hint: hint.clone(),
307+
expires_at: now_secs() + ttl,
308+
};
196309

197-
// Write grant to GRANTS KV with expiration TTL. The value is the hint
198-
// name — the broker never stores the credential value in the grant store.
199-
let grants_kv = ctx.kv("GRANTS")?;
200-
grants_kv
201-
.put(&grant_id, hint.as_str())?
202-
.expiration_ttl(ttl)
203-
.execute()
204-
.await?;
310+
// Store the grant record in its DO instance.
311+
let body_str = serde_json::to_string(&record)
312+
.map_err(|e| Error::RustError(e.to_string()))?;
313+
let ns = ctx.durable_object("GRANTS")?;
314+
let stub = ns.id_from_name(&grant_id)?.get_stub()?;
315+
stub.fetch_with_request(do_put_request(&body_str)?).await?;
205316

206317
Response::from_json(&GrantResponse {
207318
grant_id,
@@ -216,11 +327,10 @@ async fn handle_create_grant(mut req: Request, ctx: RouteContext<()>) -> Result<
216327
// ---------------------------------------------------------------------------
217328

218329
/// Redeem a grant and receive the credential value.
219-
/// One-use: the grant is deleted from GRANTS KV immediately after retrieval.
220330
///
221-
/// ALPHA CAVEAT: KV does not provide atomic read+delete. A racing duplicate
222-
/// redeem request within the KV eventual-consistency window (~few seconds)
223-
/// may also succeed. For production, migrate to Durable Objects.
331+
/// POSTs to the GrantObject DO instance for this grant_id. The DO's
332+
/// single-threaded handler performs an atomic read+delete — if two redeem
333+
/// requests race, only the first succeeds; the second receives 404.
224334
async fn handle_redeem_grant(req: Request, ctx: RouteContext<()>) -> Result<Response> {
225335
require_auth!(&req, &ctx.env);
226336

@@ -229,20 +339,28 @@ async fn handle_redeem_grant(req: Request, ctx: RouteContext<()>) -> Result<Resp
229339
None => return Response::error("missing grant id", 400),
230340
};
231341

232-
let grants_kv = ctx.kv("GRANTS")?;
233-
234-
// Retrieve the hint stored under this grant ID.
235-
let hint = match grants_kv.get(&grant_id).text().await? {
236-
None => return Response::error("grant not found or already redeemed", 404),
237-
Some(h) => h,
238-
};
342+
// Dispatch to the DO instance for this grant.
343+
let ns = ctx.durable_object("GRANTS")?;
344+
let stub = ns.id_from_name(&grant_id)?.get_stub()?;
345+
let mut do_resp = stub.fetch_with_request(do_post_request()?).await?;
346+
347+
let status = do_resp.status_code();
348+
if status != 200 {
349+
// 404 = not found or already redeemed; 410 = expired.
350+
// Pass the DO's error text directly to the caller.
351+
let msg = do_resp.text().await.unwrap_or_default();
352+
return Response::error(&msg, status);
353+
}
239354

240-
// Delete immediately — one-use enforcement (non-atomic, see caveat above).
241-
grants_kv.delete(&grant_id).await?;
355+
// Extract the hint from the DO's JSON response.
356+
let payload: serde_json::Value = do_resp.json().await?;
357+
let hint = payload["hint"]
358+
.as_str()
359+
.ok_or_else(|| Error::RustError("malformed DO response: missing hint".into()))?
360+
.to_string();
242361

243-
// Resolve the credential value from the CREDENTIALS namespace.
244-
let cred_kv = ctx.kv("CREDENTIALS")?;
245-
let value = match cred_kv.get(&hint).text().await? {
362+
// Resolve the raw credential value from CREDENTIALS KV.
363+
let value = match ctx.kv("CREDENTIALS")?.get(&hint).text().await? {
246364
None => return Response::error("credential no longer available", 500),
247365
Some(v) => v,
248366
};
@@ -256,8 +374,6 @@ async fn handle_redeem_grant(req: Request, ctx: RouteContext<()>) -> Result<Resp
256374

257375
#[event(fetch)]
258376
pub async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
259-
// Panic messages go to the Workers console log (not to the response body).
260-
// In WASM there is no stack unwinding so this is belt-and-braces only.
261377
console_error_panic_hook::set_once();
262378

263379
Router::new()

0 commit comments

Comments
 (0)