feat(codegen/digitalocean): vault-wireable token auth #13
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "worktree-473"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds an optional, sensitive
tokenglobal argument to the @swamp/digitalocean codegen so the API token can be sourced from a vault instead of only theDO_API_TOKENenvironment variable. Mirrors the Hetzner Cloud work in #12 (swamp-club #471).What changed
tokenglobal arg — optional, sensitive (z.meta({ sensitive: true })), takes precedence overDO_API_TOKEN, wireable with avault.get(...)expression. Injected unless a resource already has a realtokenproperty (collision guard). Threaded through the_libauth path and every helper call site (CRUD, action, sub-resource, discovery, readiness/action polling); never written into a request body.getToken(explicitToken?)prefers the arg over the env var; the singlevalidatedTokenis replaced with aSetso each distinct token (env or per-model) is validated exactly once againstGET /v2/account. The no-token error names both sources.tokeninjection intonewFieldNamesso upgrade diffing sees the emitted field — preventing a spuriousRemoved: tokenentry on the next regeneration.codegen/designs/digitalocean.mdlib behaviors updated.model/digitalocean— 45 models +_lib+ README + manifest bumped to2026.05.29.1, each model gaining an additiveAdded: tokenupgrade entry (identityupgradeAttributes— backward compatible).Verification
fmt)./v2/account, token-never-in-body, error paths, DELETE threading) and generator tests (sensitive arg in both schemas, threading into CRUD/action/sub-resource methods, collision guard).deno check/lint/fmt --check/install --frozenclean acrosscodegen/andmodel/digitalocean/.2026.05.29.1, exactly one new upgrade entry readingAdded: token, upgrades array grew by +1 (history preserved).Note:
z.meta({ sensitive: true })is honored by swamp-core in run logs, reports, and data storage (notswamp model getoutput, but a vault-sourced arg is stored there as thevault.get(...)expression, so no secret leaks on the recommended path).Closes swamp-club #473.
🤖 Generated with Claude Code
Code Review
Blocking Issues
None found.
Suggestions
remove()doesn't drain the 404 response body (libGenerator.ts~line 181)tryRead()explicitly drains the response body on 404 (await resp.text()) to avoid resource leaks with real HTTP connections.remove()doesn't — it just readsresp.statusand returns without consuming the body for either the 404 or the 204/200 success case. DELETE 204 is fine (empty body), but DELETE 404 returns an error JSON payload that goes unconsumed. This is inconsistent withtryReadandsubResourceUpdate(which both drain). Low-severity in practice, but worth aligning.tryFindByField,createAndPollAction, andpollResourceReadyhave no direct tests (libGenerator_test.ts)These three new runtime helpers are added to the generated
_lib/digitalocean.tsand exercised via snapshot-tested generated code, butlibGenerator_test.tsonly has tests forgetToken,create,read, andremove.tryFindByFieldin particular has non-trivial pagination logic (loop over pages usingmeta.total, array-finding heuristic) that would benefit from direct test cases covering: found on first page, found on second page, not found, 404 on the list endpoint itself.Minor inconsistency:
getmethod for child resources usescontext.globalArgs.tokenwheregis already in scope (extensionModelGenerator.ts~line 362)When generating the
getmethod for child resources (endpointLineis non-null), the generator emitsconst g = context.globalArgs;then the dynamic endpoint assignment, but the subsequentread()call usescontext.globalArgs.tokeninstead ofg.token. Every other method that definesgusesg.token. Functionally correct (same object), but inconsistent.checkExistslist-filter passes""if the naming field is undefined (extensionModelGenerator.ts~line 296)When
listFilterFieldis"label"andg.labelisundefined, the generated code callstryFindByField(..., "label", g.label?.toString() ?? "", ...). Passing an empty string means it searches for resources with an empty label rather than skipping the check. For"name"the same applies. In practice a user who callscheckExists: truewithout providing a name has already made an error, but the empty-string search could return a spurious match. A guard likeif (!g.${listFilterField}) throw new Error(...)before thetryFindByFieldcall would make the failure mode explicit.Adversarial Review
This PR adds vault-wireable
tokenauth to the DigitalOcean codegen pipeline. Every generated model gets an optionaltokenglobal argument (marked sensitive) that overridesDO_API_TOKEN, and every helper call in the generated code threads that token through. The shared lib (_lib/digitalocean.ts) switches from a single cached token to aSetof validated tokens. The README and design doc are updated.Critical / High
None found.
Medium
libGenerator.ts(generated_lib/digitalocean.ts),remove()function, line ~187 of the template: Theremove()helper returns{ existed: resp.status !== 404 }but does not drain the response body on 404. Every other path (non-ok handled byrequest(), 204 has no body, etc.) drains the body, but a 404 on DELETE leaks the undrained response. In practice this is likely harmless because DigitalOcean's 404 responses are small and the connection won't be reused, but it's inconsistent with howtryReadhandles 404 (which callsawait resp.text()). Theread(),update(), andsubResourceUpdate()helpers all drain their responses.Breaking scenario: If Deno's HTTP client enforces strict body consumption for connection reuse, a 404 on DELETE could log a warning or leak a connection.
Suggested fix: Add
if (resp.status === 404) { await resp.text(); }before the return, or unconditionally drain withawait resp.text();before checking status.libGenerator.ts(generated_lib/digitalocean.ts),subResourceUpdate()function, line ~199 of the template: Whenrequest()returns a 404 for the sub-resource endpoint,subResourceUpdate()silently drains the body and returnsvoidwithout throwing. The caller then callsread()to re-read the parent resource. If the sub-resource path returned 404 because the parent resource doesn't exist, this will just produce a confusing error from theread()call ("Resource not found") instead of a clear "sub-resource update failed" error.Breaking scenario: User calls
resizeon a database cluster that has been deleted. Gets "Resource not found: GET /v2/databases/..." instead of an error about the resize operation itself.Suggested fix: Check
resp.status === 404insubResourceUpdate()and throw with a descriptive error.extensionModelGenerator.ts:296: In thecheckExistslist-filter branch, the generated code doesg.${listFilterField}?.toString() ?? "". WhenlistFilterFieldis"name"andg.nameis undefined (user forgot to set it), this searches for a resource withname === "", finds nothing, and proceeds to create a resource with an empty/missing name — which will likely fail with a confusing API error. The direct-lookup branch (line 285) has the same pattern but usesg.${namingField}directly without the?.toString() ?? ""fallback, so it would passundefinedtotryRead, which would call/endpoint/undefinedand get a 404 — also wrong but at least wouldn't create a bad resource.Breaking scenario: User sets
checkExists: truebut forgets to setnamein globalArgs. The check passes (no resource named ""), then create fails with a DigitalOcean validation error about missing name — but only after wasting the checkExists API call.Suggested fix: This is an edge case since
nameis typically required in GlobalArgsSchema and Zod would reject the input beforeexecuteruns. LOW risk in practice.Low
libGenerator.ts,validatedTokensSet (line ~19 of generated lib): ThevalidatedTokensSet is module-level and grows unboundedly. In a long-running process that receives many distinct tokens (e.g., a service processing requests for multiple tenants), this set would grow without bound. In practice this is unlikely to matter since DigitalOcean models typically use 1-2 tokens, but it's technically a memory leak.libGenerator.ts,pollAction()andpollResourceReady()timing: Both polling functions use fixed delays (5s and 10s respectively) with 60 max attempts. ForpollResourceReadythis means up to 10 minutes of polling, during which the token validation cache is relied upon. If the token is rotated during a long poll, the cached validation would still allow requests but the API would reject them with 401, whichrequest()would throw as a generic API error. This is a known limitation rather than a bug.pipeline.ts:435-437: The comment explains whytokenis added tonewFieldNames— to prevent spurious "Removed: token" upgrade entries. The logic is correct but relies on the assumption that the field name collision check inextensionModelGenerator.ts(line 129) usesnameOnly === "token"to match. If a future refactor changes how the collision is detected, these two sites could drift. The code is correct as-is.Verdict
PASS — The PR is a clean, systematic threading of an optional
tokenparameter through all DO API helpers and generated model methods. The token handling follows the same pattern already established in the Hetzner provider (validated once per distinct token, sent only in the Authorization header, marked sensitive). Tests are thorough — covering token precedence, validation caching, collision guards, and per-method threading. The medium findings are pre-existing patterns carried from the original code, not regressions introduced by this PR.