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 ;
3330use serde:: { Deserialize , Serialize } ;
3431use uuid:: Uuid ;
3532use 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) .
83165fn 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.
107191macro_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
119203fn 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 .
131238async 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.
151253async 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.
170280async 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 .
224334async 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) ]
258376pub 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