<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Himanshu Kumar</title>
    <description>The latest articles on DEV Community by Himanshu Kumar (@okay_anshu).</description>
    <link>https://dev.to/okay_anshu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3770550%2F47c8c1b2-5d0f-4ede-845a-f12504c700a3.png</url>
      <title>DEV Community: Himanshu Kumar</title>
      <link>https://dev.to/okay_anshu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/okay_anshu"/>
    <language>en</language>
    <item>
      <title>I built a TypeScript client for Ceph Object Storage because the only npm package was 7 years old. Here's What I Learned</title>
      <dc:creator>Himanshu Kumar</dc:creator>
      <pubDate>Sun, 15 Mar 2026 12:39:02 +0000</pubDate>
      <link>https://dev.to/okay_anshu/i-built-a-typescript-client-for-ceph-object-storage-because-the-only-npm-package-was-7-years-old-1nh0</link>
      <guid>https://dev.to/okay_anshu/i-built-a-typescript-client-for-ceph-object-storage-because-the-only-npm-package-was-7-years-old-1nh0</guid>
      <description>&lt;p&gt;Last month I went looking for a Node.js client to manage users and buckets on our Ceph RADOS Gateway. Found one package on npm — &lt;code&gt;rgw-admin-client&lt;/code&gt;. Last published in 2019. No TypeScript. No ESM. No maintenance.&lt;/p&gt;

&lt;p&gt;So I built my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What even is Ceph RGW?
&lt;/h2&gt;

&lt;p&gt;If you've worked with Kubernetes storage, you've probably bumped into Ceph. It's the storage backend behind Rook-Ceph and Red Hat OpenShift Data Foundation. The RADOS Gateway (RGW) is its S3-compatible object storage layer.&lt;/p&gt;

&lt;p&gt;RGW has an Admin API to manage users, buckets, quotas, rate limits, and usage stats. But to use it from Node.js, you either:&lt;/p&gt;

&lt;p&gt;Shell out to the &lt;code&gt;radosgw-admin&lt;/code&gt; CLI inside a Ceph toolbox pod&lt;br&gt;
Write raw HTTP requests with AWS SigV4 signing by hand&lt;br&gt;
Use that 7-year-old unmaintained package and hope for the best&lt;/p&gt;

&lt;p&gt;None of those felt great.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;radosgw-admin

import &lt;span class="o"&gt;{&lt;/span&gt; RadosGWAdminClient &lt;span class="o"&gt;}&lt;/span&gt; from &lt;span class="s1"&gt;'radosgw-admin'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

const rgw &lt;span class="o"&gt;=&lt;/span&gt; new RadosGWAdminClient&lt;span class="o"&gt;({&lt;/span&gt;
  host: &lt;span class="s1"&gt;'http://ceph-rgw.example.com'&lt;/span&gt;,
  port: 8080,
  accessKey: &lt;span class="s1"&gt;'ADMIN_ACCESS_KEY'&lt;/span&gt;,
  secretKey: &lt;span class="s1"&gt;'ADMIN_SECRET_KEY'&lt;/span&gt;,
&lt;span class="o"&gt;})&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

// create a user
const user &lt;span class="o"&gt;=&lt;/span&gt; await rgw.users.create&lt;span class="o"&gt;({&lt;/span&gt;
  uid: &lt;span class="s1"&gt;'alice'&lt;/span&gt;,
  displayName: &lt;span class="s1"&gt;'Alice'&lt;/span&gt;,
  email: &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;,
&lt;span class="o"&gt;})&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

// &lt;span class="nb"&gt;set &lt;/span&gt;a 10GB quota &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;, you can just write &lt;span class="s2"&gt;"10G"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
await rgw.quota.setUserQuota&lt;span class="o"&gt;({&lt;/span&gt;
  uid: &lt;span class="s1"&gt;'alice'&lt;/span&gt;,
  maxSize: &lt;span class="s1"&gt;'10G'&lt;/span&gt;,
  maxObjects: 50000,
&lt;span class="o"&gt;})&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

// list all buckets
const buckets &lt;span class="o"&gt;=&lt;/span&gt; await rgw.buckets.list&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;8 modules. 39 methods. Zero runtime dependencies.&lt;/p&gt;

&lt;p&gt;Why zero dependencies?&lt;br&gt;
The RGW Admin API uses AWS Signature V4 for auth. Most people would reach for aws-sdk or @aws-sdk/signature-v4 for that. I didn't want to pull in a massive dependency tree for one signing function.&lt;/p&gt;

&lt;p&gt;So I wrote the SigV4 implementation using just node:crypto. It's about 80 lines. The whole package has zero production dependencies — your node_modules stays clean.&lt;/p&gt;

&lt;p&gt;Things I actually cared about while building this&lt;br&gt;
TypeScript that helps, not annoys&lt;br&gt;
The whole codebase runs with strict: true, noImplicitAny, exactOptionalPropertyTypes, and noUncheckedIndexedAccess. No any anywhere.&lt;/p&gt;

&lt;p&gt;What this means for you — autocomplete works properly, types match what the API actually returns, and you catch mistakes before running anything.&lt;/p&gt;

&lt;p&gt;snake_case in, camelCase out&lt;br&gt;
The RGW API returns JSON with snake_case keys. Every JavaScript developer expects camelCase. The client transforms both directions automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// you write camelCase&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rgw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxBuckets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// the API receives: display-name=Alice&amp;amp;max-buckets=10&lt;/span&gt;

&lt;span class="c1"&gt;// the API returns: { "user_id": "alice", "display_name": "Alice" }&lt;/span&gt;

&lt;span class="c1"&gt;// you get back camelCase&lt;/span&gt;
&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// "alice"&lt;/span&gt;
&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "Alice"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You never think about it.&lt;/p&gt;

&lt;p&gt;Errors you can actually catch&lt;br&gt;
Instead of generic "Request failed with status 404", you get typed errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RGWNotFoundError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RGWAuthError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;radosgw-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rgw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nonexistent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;RGWNotFoundError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// user doesn't exist — handle it&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;RGWAuthError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// bad credentials — log and alert&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's RGWNotFoundError, RGWAuthError, RGWConflictError, RGWValidationError, and a base RGWError. Validation errors are thrown before any HTTP call — so you don't waste a round trip on bad input.&lt;/p&gt;

&lt;p&gt;Size strings that make sense&lt;br&gt;
Quota methods accept human-readable sizes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rgw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quota&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUserQuota&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;10G&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// internally converts to 10737418240 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can write '500M', '1T', '1.5G' — whatever makes sense. Or pass raw bytes if you prefer.&lt;/p&gt;

&lt;p&gt;The hard parts&lt;br&gt;
SigV4 signing — AWS Signature V4 has a very specific signing process. Canonical request, string to sign, signing key derived from date + region + service. Getting the exact byte-level match right took a few days of reading the AWS docs and comparing against known-good signatures.&lt;/p&gt;

&lt;p&gt;Ceph's response formats — Some endpoints return a JSON array on success but an XML error on failure. Some return empty body for success. The GET /bucket endpoint changes its response shape depending on whether you pass max-entries or not. Each of these needed special handling.&lt;/p&gt;

&lt;p&gt;Dual ESM + CJS — Shipping a package that works with both import and require() in 2026 is still annoying. I used tsup to build both formats with correct exports mapping in package.json. Validated with publint and @arethetypeswrong/cli in CI.&lt;/p&gt;

&lt;p&gt;What's in the box&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Users&lt;/td&gt;
&lt;td&gt;Create, get, modify, delete, list, suspend, enable, stats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;Generate and revoke S3/Swift access keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subusers&lt;/td&gt;
&lt;td&gt;Create, modify, remove Swift subusers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buckets&lt;/td&gt;
&lt;td&gt;List, info, delete, transfer ownership, verify index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quota&lt;/td&gt;
&lt;td&gt;Get/set user and bucket quotas with size strings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate Limits&lt;/td&gt;
&lt;td&gt;Per-user, per-bucket, and global rate limiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage&lt;/td&gt;
&lt;td&gt;Query bandwidth/ops reports, trim old logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Info&lt;/td&gt;
&lt;td&gt;Cluster FSID and storage backend info&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every method has JSDoc with @example blocks, so your editor shows you exactly how to use it.&lt;/p&gt;

&lt;p&gt;If you're running Rook-Ceph&lt;br&gt;
Port-forward the RGW service and point the client at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward svc/rook-ceph-rgw-my-store 8080:80 &lt;span class="nt"&gt;-n&lt;/span&gt; rook-ceph

const rgw &lt;span class="o"&gt;=&lt;/span&gt; new RadosGWAdminClient&lt;span class="o"&gt;({&lt;/span&gt;
  host: &lt;span class="s1"&gt;'http://localhost'&lt;/span&gt;,
  port: 8080,
  accessKey: &lt;span class="s1"&gt;'...'&lt;/span&gt;, // from rook secret
  secretKey: &lt;span class="s1"&gt;'...'&lt;/span&gt;,
&lt;span class="o"&gt;})&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the admin credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secret rook-ceph-dashboard-admin-gateway &lt;span class="nt"&gt;-n&lt;/span&gt; rook-ceph &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.accessKey}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Numbers&lt;br&gt;
280 tests passing&lt;br&gt;
CI runs on Node 18, 20, and 22&lt;br&gt;
90%+ code coverage&lt;br&gt;
npm provenance with trusted publisher&lt;br&gt;
Dual ESM + CJS with full type declarations&lt;/p&gt;

&lt;p&gt;What's next&lt;br&gt;
I'm planning to add multi-site/zone management and IAM role support in future versions. Open to suggestions — if you use the RGW Admin API and there's something missing, let me know.&lt;/p&gt;

&lt;p&gt;Links:&lt;/p&gt;

&lt;p&gt;npm: &lt;a href="https://www.npmjs.com/package/radosgw-admin" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/radosgw-admin&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/nycanshu/radosgw-admin" rel="noopener noreferrer"&gt;https://github.com/nycanshu/radosgw-admin&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://nycanshu.github.io/radosgw-admin" rel="noopener noreferrer"&gt;https://nycanshu.github.io/radosgw-admin&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you find it useful, a star on GitHub helps others find it too.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>opensource</category>
      <category>rookceph</category>
    </item>
  </channel>
</rss>
