Repository navigation
Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6
Description
Context
There is no Harbor client today; storage is Tatum-only and plaintext (packages/walrus/src/storage.ts). Harbor's model is SPACES > BUCKETS > FILES, and bucket creation is a RESERVE → SIGN → FINALIZE handshake (an Enoki-sponsored Sui tx — no SUI balance needed). We need a pure-fetch, runtime-agnostic client that works in both the Worker and Node (the seal Worker spike decides which runtime actually signs/uploads).
Goal / user story
As ContextMEM, I want a HarborClient that can create a bucket (reserve→sign→finalize), upload a file (multipart ciphertext), poll its status to ready, and download it — so private namespaces can store encrypted blobs on Walrus via Sui.
Acceptance criteria
- New
packages/walrus/src/harbor.tsexports aHarborClient(class or factory) with NOnode:fs/node:child_process/node:osimports (runs in Worker + Node). -
createBucket(spaceId, name)performs RESERVE → sign the sponsored tx withEd25519KeypairfromdecodeSuiPrivateKey(serviceKey)→ FINALIZE as one tight back-to-back sequence; returns{ bucketId, sealPolicyId }. -
uploadFile(bucketId, { name, data, contentType })does the multipart upload and returns afileId;getFileStatus(fileId)polls to a terminal/ready state mirroringwaitForWalrusStorageCertified(storage.ts:160). - A post-finalize
403 mirror_missing_grantis retried with backoff over the ~3s mirror window before surfacing an error. -
download(fileId)returns ciphertext bytes. - Unit test with a mocked
fetchcovers reserve→sign→finalize ordering, themirror_missing_grantretry, and an upload+poll happy path.
Implementation notes
- Auth:
Authorization: Bearer hbr_.... Inject config (apiKey,serviceKey,spaceId,baseUrl, optionalfetch) the same wayTatumStorageConfigis injected instorage.ts:21. - Sign with
@mysten/sui/keypairs/ed25519+decodeSuiPrivateKeyfrom@mysten/sui/cryptography; build/execute the sponsored tx (Enoki sponsors gas). Sponsor sig expires fast — RESERVE and FINALIZE must be back-to-back with no awaiting of unrelated work between them. - Add
@mysten/seal+@mysten/suitopackages/walrus/package.json(currently onlyfast-xml-parser+p-limit) and re-export frompackages/walrus/src/index.ts. - This client takes ciphertext only — encryption lives in
seal.ts(StorageProvider + SEAL issue). Mirror the polling/receipt shape already instorage.tsso theStorageProviderseam can wrap it.
Sui Overflow angle
The reserve→sign→finalize handshake is the FIRST time ContextMEM signs a real Sui tx. The resulting Bucket + Seal-policy objects are genuine on-chain artifacts (Enoki-sponsored, zero SUI), giving a concrete "where is the Sui contract" answer to show judges — with no custom Move authored yet.
Dependencies
The seal Worker spike decides whether sign/upload runs in Worker or Node. The namespace→Space/Bucket/Seal mapping issue supplies the service key + space id config this client consumes.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add a self-contained, runtime-agnostic
packages/walrus/src/harbor.tsexporting aHarborClientbuilt on purefetch(Worker + Node) plus@mysten/suifor signing only (no@mysten/sealimport here — encryption lives in the siblingseal.tsissue). The one hard design constraint is that the Enoki sponsor signature expires fast, socreateBucketperforms RESERVE → (synchronous CPU-only) SIGN → FINALIZE as one tight back-to-back sequence with zero unrelatedawaits in between. Everything else (config injection, defensive JSON parsing, the status-poll loop) is mirrored from the proven Tatum code instorage.tsso the laterStorageProviderseam can wrap it cleanly.Prerequisites & dependencies
@mysten/sealWorker spike). It decides where sign/upload eventually runs (Worker vs queue vs Node), butHarborClientis runtime-agnostic by design and uses only@mysten/suicrypto (BLS-free Ed25519 signing), so it can be authored and unit-tested now. Do not import@mysten/sealfromharbor.ts.serviceKey+spaceIdconfig this client consumes; the StorageProvider-abstraction/receipt-widening issue (which widensWalrusStorageReceipt.providerfrom the literal"tatum"atpackages/core/src/types.ts:656) wraps this client.harbor.tsstays decoupled from both — it returns plain typed shapes.TatumStorageConfigatstorage.ts:21):apiKey(hbr_…Bearer),serviceKey(suiprivkey1…),spaceId,baseUrl, optionalfetch. A Sui testnet service key is required, but it needs no SUI balance (Enoki sponsors bucket-creation gas). Worker-secret wiring (HARBOR_API_KEY/HARBOR_SERVICE_PRIVATE_KEY/HARBOR_SPACE_IDintoWorkerEnvatapps/api/src/worker.ts:32) is the separate key-mgmt issue — out of scope here.curl(capture one real RESERVE response). Mitigation baked into the plan: parse defensively (portpickString/pickUrlsfromstorage.ts:74-101) so minor field-name drift doesn't break the client.@mysten/suiis already a workspace dep at^2.9.1(rootpackage.json:31,apps/api/package.json:20), so the version is known-good undernodejs_compat.Step-by-step
packages/walrus/package.json(currently onlyfast-xml-parser+p-limit, lines 13-17) add"@mysten/sui": "^2.9.1"and"@mysten/seal": "^0.x"(seal added per issue note for the siblingseal.ts;harbor.tsitself must not import it). Addexport * from "./harbor.js";topackages/walrus/src/index.ts(after line 6). Runbun install.packages/walrus/src/harbor.ts. DefineHarborClientConfig = { apiKey, serviceKey, spaceId, baseUrl, fetch? }and a privateresolveFetch/resolveConfig(mirrorstorage.ts:49, but read noprocess.envso it stays Worker-clean — caller injects everything). Define result types:HarborBucket = { bucketId, sealPolicyId, txDigest? },HarborFileAccepted = { fileId, status? },HarborFileState = { fileId, status, ready, failed, raw }(shape parallelsWalrusStorageJobStateatstorage.ts:39). PortreadBody/pickString/pickUrls(storage.ts:64-101) as module-private helpers. Nonode:fs/node:child_process/node:os/node:pathimports (AC Walrus storage + Memory via Tatum, plus CI deploy #1).createBucket(spaceId, name)— RESERVE → SIGN → FINALIZE, back-to-back:import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"+import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";const keypair = Ed25519Keypair.fromSecretKey(decodeSuiPrivateKey(serviceKey).secretKey);(memoize on the instance).POST {baseUrl}/…/buckets/reservewithAuthorization: Bearer hbr_…, body{ spaceId, name }. Response carries the Enoki-sponsored unsigned tx:{ txBytes (base64 BCS TransactionData), sponsorSignature, reservationId }— parse viapickStringwith candidate keys (txBytes/transactionBytes/bytes,reservationId/id).const { signature } = await keypair.signTransaction(fromBase64(txBytes));(the single unavoidable await; nothing else between reserve and finalize — this is the sponsor-sig-expiry guard, issue note line 17).POST {baseUrl}/…/buckets/finalizebody{ reservationId, signature }(Harbor already holdstxBytes+sponsorSignaturefrom reserve). Parse{ bucketId, sealPolicyId }from the response (candidate keysbucketId/bucket_id/id,sealPolicyId/seal_policy_id/policyId). ReturnHarborBucket.uploadFile(bucketId, { name, data, contentType })+download(fileId):storage.ts:109-112:new FormData()+new Blob([data], { type: contentType ?? "application/octet-stream" }),form.append("file", blob, name),POST {baseUrl}/…/buckets/{bucketId}/fileswith the Bearer header (noContent-Type— letfetchset the boundary). Wrap this POST in the mirror-grant retry (Step 5). Return{ fileId }. The client takes ciphertext only (issue note line 19) — no encryption here.download(fileId):GET {baseUrl}/…/files/{fileId}/downloadwith Bearer →new Uint8Array(await res.arrayBuffer())(ciphertext bytes) (AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5).mirror_missing_grantretry. Add a privatewithMirrorGrantRetry(fn)helper: on a403whose body containsmirror_missing_grant, retry with backoff covering the ~3s window (e.g. delays400, 800, 1200, 1600, 2000ms via injectablesleep, ~6 attempts ≈ 6s of cushion); any other non-OK status throws immediately with the parsed body (mirror the error style ofstorage.ts:115). WrapuploadFile's POST and the firstgetFileStatuscall with it (AC docs: add product roadmap (Harbor storage + Talus on-chain direction) #4).getFileStatus(fileId)+waitForFileReady.getFileStatus:GET {baseUrl}/…/files/{fileId}, normalizestatusto{ ready, failed }(uppercase compare for ready states likeREADY/CERTIFIED/STOREDand terminalFAILED/ERROR) — same normalization shape asgetWalrusStorageJob(storage.ts:140-150).waitForFileReady(fileId, opts): deadline loop withpollIntervalMs/timeoutMs/onPoll, return on ready, throw on failed/timeout — a near-copy ofwaitForWalrusStorageCertified(storage.ts:160-177) so the receipt/poll shape matches Tatum (AC fix(ci): force bun + pin wrangler in Deploy workflow #3).packages/walrus/src/harbor.test.ts(vitest —import { describe, expect, it, vi } from "vitest", matchingquilt.test.ts:1). Inject a fakefetchvia config and a use a deterministic testsuiprivkey1…testnet key. Cover: (a) reserve→sign→finalize ordering — assert RESERVE called before FINALIZE, FINALIZE body carries a real signature produced bysignTransactionover the reservetxBytes, and{ bucketId, sealPolicyId }returned; (b) mirror-grant retry — fakefetchreturns403 {"error":"mirror_missing_grant"}twice then200, assert upload eventually succeeds and call count == 3 (inject a no-opsleep); (c) upload+poll happy path —uploadFile→getFileStatus/waitForFileReadytransitionsPENDING→READY; (d) download returns the exact ciphertext bytes the fakefetchserved (round-trip assert). Add a static guard test (or rely on AC#1 review) thatharbor.tssource contains nonode:import.New / changed files
packages/walrus/src/harbor.ts(new —HarborClient+ types + helpers)packages/walrus/src/harbor.test.ts(new — vitest, mockedfetch)packages/walrus/src/index.ts(edit — addexport * from "./harbor.js";)packages/walrus/package.json(edit — add@mysten/sui+@mysten/seal)Test plan
fetch+ injectedsleepso they run inbun run check/vitest run. The mirror-retry and reserve-before-finalize assertions are the load-bearing ones.bun-run scratch script using a realHARBOR_API_KEY+ testnetsuiprivkey1against the live alpha:createBucket→ log the returnedbucketId/sealPolicyIdand the Sui tx digest, then verify the Bucket + Seal-policy objects on a testnet explorer (this is ContextMEM's first real signed Sui tx — the concrete "where's the contract" artifact, issue "Sui Overflow angle"). ThenuploadFilea small ciphertext blob →waitForFileReadyto ready →download→ assert bytes are byte-identical to what was uploaded (full round-trip). Exercise the upload immediately after finalize to confirm themirror_missing_grantpath fires on real infra.Risks & gotchas
await(DB write, logging round-trip, extra fetch) between RESERVE and FINALIZE can let the Enoki sponsor signature expire → finalize fails. KeepcreateBucketa single straight-line method; the only await between reserve and finalize issignTransaction. Surface a clear error if finalize returns an expiry/sponsor error so callers retry the whole reserve→finalize sequence (never reuse staletxBytes).pickStringparsing + centralizing all paths/keys in oneconstblock at the top ofharbor.tskeeps adaptation to a one-spot edit once a real response is captured.harbor.tsdeliberately avoids@mysten/seal(the BLS/CPU/bundle risk gated by Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5);@mysten/suiEd25519 signing +fetchare Worker-safe undernodejs_compat. MultipartFormData/Blobupload already works in the Worker (proven by Tatumstorage.ts).signTransactioninput format: it expects raw BCSTransactionDatabytes, not a serializedTransactionobject. Decode the reservetxByteswithfromBase64(orfromB64, version-dependent) before signing; pin against the installed@mysten/sui@2.9.1API.fileId/bucketIdwith mainnet Tatum blobIds. Receipt/provider reconciliation is the StorageProvider issue's job.sleepinjectable so tests stay instant.Acceptance-criteria mapping
harbor.tsexportsHarborClient, nonode:fs/child_process/osimports → Steps 1, 2 (and the static no-node:guard in Step 7).createBucket(spaceId,name)RESERVE→sign(Ed25519Keypair+decodeSuiPrivateKey)→FINALIZE back-to-back, returns{bucketId, sealPolicyId}→ Step 3.uploadFilemultipart →fileId;getFileStatuspolls mirroringwaitForWalrusStorageCertified(storage.ts:160) → Steps 4, 6.403 mirror_missing_grantretried with backoff over ~3s → Step 5.download(fileId)returns ciphertext bytes → Step 4.fetch) covers reserve→sign→finalize ordering, mirror retry, upload+poll happy path → Step 7.Effort
L — ~3-4 person-days. ~1 day client skeleton (config/types/helpers/upload/status/download), ~1 day the reserve→sign→finalize signing path + mirror-grant retry, ~0.5 day tests, ~0.5-1 day reconciling the real Harbor alpha API shape from a live capture. Add a few hours of buffer if the alpha endpoint contract differs materially from the assumed shape.
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).
fix(walrus): declare @mysten/sui dependency
Merge pull request #29 from harrymove-ctrl/feat/harbor-seal-storage