Repository navigation
StorageProvider seam + private SEAL-encrypted remember/recall (public→Tatum, private→Harbor) #19
Description
Context
Today "private" is only a D1 read-token check (CloudflareNamespaceStore.authorizeNamespace, apps/api/src/worker.ts:502) — bytes are uploaded plaintext and readable by anyone with the blobId/aggregator URL — and WalrusStorageReceipt.provider is hard-wired to the literal 'tatum' (packages/core/src/types.ts:655) with packages/walrus/src/storage.ts as the only provider. The roadmap mandates coexistence + real encryption: a StorageProvider seam routes public→Tatum (mainnet, plaintext) and private→Harbor+SEAL (testnet, encrypted), where a private namespace gets an owned Seal policy and bytes are SEAL-encrypted before upload and only recallable by decrypt via SessionKey + seal_approve.
Goal / user story
As a user with a private namespace, when I remember an artifact it goes through a StorageProvider that SEAL-encrypts it and stores ciphertext on Harbor, and recall fetches + decrypts — while public namespaces keep going to Tatum plaintext, with the chosen provider recorded honestly in the receipt.
Acceptance criteria
- Define a
StorageProviderinterface{ upload, status, fetch }inpackages/walrus; refactor the existing Tatum functions behind aTatumStorageProviderimplementing it, with no behavior change for current CLI/MCP callers. - Widen
WalrusStorageReceipt.provider(core/types.ts:655) from'tatum'to'tatum' | 'harbor'and add optional Harbor fields (spaceId,bucketId,fileId,sealPolicyId,sealIdentity). - New
packages/walrus/src/seal.tswraps@mysten/seal:encrypt(bytes, { sealPolicyId, identity })→ ciphertext;decrypt(ciphertext, { sessionKey, sealPolicyId })builds theseal_approvePTB. - A
selectStorageProvider({ visibility, network })picks Harbor+SEAL for private namespaces and Tatum for public; private writes encrypt the proof bundle with a per-file identity against the namespace'ssealPolicyId, then upload ciphertext viaHarborClient; the chosen provider is written into the receipt, andrememberStorageIndex/renderStorageIndex(packages/memwal/src/index.ts:164/281) carrybucketId/fileId/sealPolicyId/sealIdentity(keep back-compat parsing of the[ctxm-storage]format). - New
GET /api/memwal/artifact(worker.ts) resolves provider + refs from D1, fetches ciphertext, decrypts viaSessionKey+seal_approve, and streams plaintext — token-gated throughauthorizeNamespace(worker.ts:502); public namespaces fall back to the plaintext aggregator path. - Fail-closed: non-private writes fall back to Tatum on Harbor failure, but private writes NEVER silently fall back to plaintext; creating/revoking a namespace read token maps to granting/revoking SEAL decrypt for that policy (wired near
authorizeNamespaceand token create/revokeworker.ts:992/:1013). - Tests cover provider selection by visibility, public-only Tatum fallback, and a private round-trip (remember → recall artifact → bytes match; a request without a valid token cannot decrypt).
Implementation notes
- Land the interface + receipt widening early (no deps);
uploadProofBundle(storage.ts:183) currently always returnsprovider: 'tatum'— route it throughselectStorageProvider. SessionKeyneeds an Ed25519 signer (service key, or per-owner per the mapping issue's decision); execute theseal_approvePTB viaSuiGrpcClientdevInspect (same client asproof.ts). Per-fileidentitydeterministic from namespace + run/chunk id so recall can reconstruct it.- Public share pages CANNOT do
SessionKey+seal_approve— confirm public namespaces stay plaintext (Tatum) and only private go through SEAL. Network reality: Tatum=mainnet, Harbor=testnet; document that public-mainnet and private-testnet blobs live on different networks. - Encryption on write likely runs in the queue consumer / Node CLI+MCP path (
packages/cli/src/index.ts:347,packages/mcp/src/index.ts:200) sincepackProofBundleis Node-only — gated by the seal Worker spike. KeeppackProofBundle(tar) Node-only; the Worker private path needs an in-memory zip from R2 (separate roadmap item, out of scope here).
Sui Overflow angle
Turns "private" from a DB flag into real on-chain access control: decryption is gated by an on-chain seal_approve Move call. The headline Sui-native privacy demo — "revoke the token → recall can no longer decrypt" — lands here, shown side-by-side with a public Tatum/Walrus blob whose receipt names the provider.
Dependencies
The seal Worker spike, the HarborClient issue, and the namespace→Space/Bucket/Seal mapping + service-key issue. The interface + receipt widening have no deps and can land first.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Introduce a thin
StorageProviderseam inpackages/walrus({ upload, status, fetch }) so the existing Tatum code becomes one implementation (TatumStorageProvider) with zero behavior change, and aHarborStorageProvider(built on #6'sHarborClient) becomes the second. SEAL crypto lives in a standaloneseal.ts(decoupled from any provider) soencryptruns on the write path anddecryptruns on the read path independently.selectStorageProvider({ visibility, network })routes public→Tatum (mainnet, plaintext) and private→Harbor (testnet, SEAL ciphertext). Key design decision: private encryption happens on the Node CLI/MCP write path (wherepackProofBundlealready runs,storage.ts:231), and the Worker only does fetch+decrypt at recall time viaGET /api/memwal/artifact— keeping the heavy@mysten/sealdecrypt the one thing we put in the Worker, which is exactly what spike #5 must clear first. The hosted (R2→Walrus) private write path stays out of scope (deferred to the in-memory-zip roadmap item).Prerequisites & dependencies
seal.decrypt(SessionKey +seal_approvedevInspect) fits the Worker bundle/CPU budget. If it busts limits, the recall route must move decrypt to a Node relayer off a Queue; do not start AC#5/AC#7 until Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5 lands green.HarborClient). Providespackages/walrus/src/harbor.ts(createBucketreserve→sign→finalize,uploadFile+ status poll,downloadciphertext, the ~3smirror_missing_grant403 retry).HarborStorageProviderwraps it. AC#1 + AC#2 (interface + receipt widening) have no deps and land first.spaceId/bucketId/sealPolicyIdper namespace in D1 and decides service-key custody (shared vs per-owner). This issue reads those columns and theHARBOR_SERVICE_PRIVATE_KEYSessionKey signer; if Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7 hasn't landed, stub the mapping behind a single env defaultHARBOR_SPACE_ID+ a deterministic bucket-per-namespace lookup.WorkerEnvworker.ts:32-55+wrangler.jsonc+.env.example):HARBOR_API_KEY(hbr_),HARBOR_SERVICE_PRIVATE_KEY(suiprivkey1…),HARBOR_SPACE_ID, optionalHARBOR_SEAL_PACKAGE_ID+HARBOR_TESTNET_RPC_URL. Mirror the header-or-secret resolution pattern ofresolveMemwalCreds(worker.ts:3341).@mysten/sealinpackages/walrus/package.jsonpinned compatibly with the existing@mysten/sui ^2.9.1(rootpackage.json:31); reuse@mysten/sui/grpcSuiGrpcClientalready used inproof.ts:1.Step-by-step
Define the seam (no deps). Create
packages/walrus/src/provider.ts:StorageVisibility = "public"|"private",StorageNetwork = "mainnet"|"testnet",StorageRef = { jobId?; blobId?; fileId?; bucketId?; downloadUrls?: string[] }.interface StorageProvider { readonly provider: "tatum"|"harbor"; upload(input): Promise<WalrusStorageReceipt>; status(ref): Promise<WalrusStorageJobState>; fetch(ref): Promise<Uint8Array>; }.upload/statusreuse the existingWalrusStorageUploadInput/WalrusStorageJobState/WaitOptionsshapes fromstorage.ts:26-47,153.Widen the receipt (no deps). Edit
packages/core/src/types.ts:655—provider: "tatum"→provider: "tatum" | "harbor"; add optionalspaceId?,bucketId?,fileId?,sealPolicyId?,sealIdentity?: string. Keep all existing fields. (This is the early, dep-free landing per the issue's implementation note.)Refactor Tatum behind
TatumStorageProvider. Instorage.ts, keepuploadToWalrusStorage/getWalrusStorageJob/waitForWalrusStorageCertified/uploadProofBundle/packProofBundleexported as-is (CLI/MCP import them directly —cli/src/index.ts:347,mcp/src/index.ts:200). Addclass TatumStorageProvider implements StorageProvider:uploaddelegates touploadProofBundle(already returnsprovider:"tatum",storage.ts:191);status→getWalrusStorageJob;fetch→ GETref.downloadUrls[0](the aggregator URL) →Uint8Array. No behavior change for current callers (AC#1).SEAL wrapper (gated by Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5). Create
packages/walrus/src/seal.tswrapping@mysten/seal:encrypt(bytes, { sealPolicyId, identity })→ ciphertextUint8Array(Node write path).decrypt(ciphertext, { sessionKey, sealPolicyId, identity, suiClient })→ builds theseal_approvePTB (moveCall${HARBOR_SEAL_PACKAGE_ID}::<module>::seal_approve(identity, policyObject)) and executes it viaSuiGrpcClientdevInspect (same client pattern asproof.ts:12), then SEALSessionKey-based decrypt → plaintext.deriveSealIdentity(namespace, runId|chunkId)= deterministicsha256(${namespace}:${id})hex so recall reconstructs the exact identity (per implementation note). Export all frompackages/walrus/src/index.ts.HarborStorageProvider(deps Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6/Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7). Createpackages/walrus/src/harbor-provider.tsimplementingStorageProvideroverHarborClient(Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6):upload(ciphertextInput)→ ensure bucket (from Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7 mapping) →uploadFile→ poll status → return receipt{ provider:"harbor", spaceId, bucketId, fileId, sealPolicyId, sealIdentity, blobId?, status, certified, … };status(ref.fileId)→ Harbor poll;fetch(ref.fileId)→HarborClient.download→ ciphertextUint8Array(decrypt is a separateseal.decryptcall, kept decoupled).selectStorageProvider+ fail-closed write path. Inprovider.ts:selectStorageProvider({ visibility, network }, deps)→ returnsHarborStorageProviderwhenvisibility==="private", elseTatumStorageProvider. Add an orchestratorstorePrivateOrFallback(...):seal.encrypt(bundle.data, { sealPolicyId, identity })→HarborStorageProvider.upload(ciphertext); on Harbor/SEAL failure rethrow (NEVER plaintext fallback — AC#6).TatumStorageProvider.upload; on Harbor failure for a non-private namespace that opted into Harbor, fall back to Tatum (AC#6). RouteuploadProofBundle's callers through this selector (per implementation note "route it throughselectStorageProvider").Wire the write callers. Thread a
visibility/network(+sealPolicyId/bucketIdfrom Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7) into the CLIstorage push(cli/src/index.ts:341-378) and MCPupload_proof_to_walrus(mcp/src/index.ts:200) so private runs encrypt+Harbor and public stay Tatum. Default visibility=public preserves today's behavior.Carry Harbor refs through the memory index (back-compat). Extend
StorageIndexInput(memwal/src/index.ts:37) andrenderStorageIndex(:281) to emitbucketId=,fileId=,sealPolicyId=,sealIdentity=lines inside the existing[ctxm-storage]block; mirror insdk.ts:123. Add aparseStorageIndex(text)that reads the new keys and still parses legacy blocks without them (AC#4). These fields also get persisted to D1 (step 9) as the authoritative recall lookup; the MemWal text is the portable mirror.D1 schema + namespace summary. Add migration
apps/api/migrations/0007_harbor_seal_refs.sql:ALTER TABLE contextmem_namespaces ADD COLUMN space_id TEXT,bucket_id TEXT,seal_policy_id TEXT(and a per-version/artifactharbor_file_id+seal_identityif recall is per-artifact — store oncontextmem_namespace_versionsor a smallcontextmem_namespace_storagetable keyed by namespace+version). Add the new columns to thegetNamespaceSELECT (worker.ts:415) andnamespaceFromRow(worker.ts:4531) →HostedNamespaceSummary(sealPolicyId,bucketId,spaceId). (Mapping population is Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7's job; this issue just reads them.)GET /api/memwal/artifact(recall fetch+decrypt; gated by Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5). Register in the route block (worker.ts:~630, beside/api/memwal/recall):GET /api/memwal/artifact?namespace=…&path=….await store.authorizeNamespace(namespace, readAccessToken(request))(worker.ts:502/616) — return its{status,message}on failure (token-gated, AC#5/AC#6).auth.summary.visibility === "public"→ resolvedownloadUrls/aggregator from the stored receipt/index and stream plaintext (Tatum fallback path, AC#5).bucketId/fileId/sealPolicyId/sealIdentityfrom D1 (step 9) →HarborStorageProvider.fetch(fileId)→ ciphertext →seal.decrypt(ciphertext, { sessionKey: serviceKeySessionKey, sealPolicyId, identity, suiClient })→ stream plaintext. Build theSessionKeyfromHARBOR_SERVICE_PRIVATE_KEY(resolved likeresolveMemwalCreds,worker.ts:3341).Token→SEAL decrypt mapping (AC#6). Wire near
authorizeNamespace(:502) and token create/revoke (createNamespaceToken:992,revokeNamespaceToken:1013):authorizeNamespacepasses, so revoking the token → 403 → recall can no longer decrypt (the headline Sui-native demo) with the shared service-key SessionKey.grantSealDecrypt(namespace, token)/revokeSealDecrypt(namespace, tokenId)hook at those two call sites. Full on-chain per-grantee policy mutation depends on Namespace→Space/Bucket/Seal mapping + Harbor service-key management in Worker secrets #7's service-key/policy capability decision; land the token-gates-Worker-decrypt enforcement now and leave the on-chain grant as a hook (documented follow-up). Never silently downgrade a private namespace to plaintext.Docs. Note the network split (public-mainnet Tatum vs private-testnet Harbor blobs live on different networks) in
.env.example+ a short comment nearselectStorageProvider, per the issue's "document" requirement.New / changed files
packages/walrus/src/provider.ts(new — interface,TatumStorageProviderre-export glue,selectStorageProvider,storePrivateOrFallback)packages/walrus/src/seal.ts(new —@mysten/sealencrypt/decrypt +deriveSealIdentity)packages/walrus/src/harbor-provider.ts(new —HarborStorageProviderover Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6'sHarborClient)packages/walrus/src/storage.ts(edit — addTatumStorageProvider; keep existing fns)packages/walrus/src/index.ts(edit — export provider/seal/harbor-provider)packages/walrus/package.json(edit — add@mysten/seal)packages/core/src/types.ts(edit:655— widenprovider, add Harbor fields)packages/memwal/src/index.ts(edit:37/:281— Harbor fields +parseStorageIndexback-compat) andpackages/memwal/src/sdk.ts(:123mirror)packages/cli/src/index.ts(edit:341-378— visibility/seal refs) andpackages/mcp/src/index.ts(edit:200)apps/api/src/worker.ts(edit —WorkerEnvHARBOR_*,getNamespace/namespaceFromRowcolumns,GET /api/memwal/artifact, token→SEAL hooks)apps/api/migrations/0007_harbor_seal_refs.sql(new)apps/api/cloudflare/wrangler.jsonc+wrangler.example.jsonc+.env.example(edit — Harbor vars/secrets)Test plan
packages/walrus):selectStorageProvider({visibility:"public"})returnsTatumStorageProviderand{visibility:"private"}returnsHarborStorageProvider(AC#7).deriveSealIdentityis deterministic for the same(namespace, runId).parseStorageIndexreads new[ctxm-storage]blocks and a legacy block with no Harbor fields (AC#4 back-compat).provider:"tatum"; a private upload with Harbor failure throws and never produces a plaintext receipt (AC#6).seal.encrypt→HarborClient.uploadFile→ poll certified →GET /api/memwal/artifactwith a valid token →seal.decrypt→ assertBuffer.equals(plaintextOut, bundle.data)(remember→recall→bytes match, AC#7). Capture the real Sui testnet tx digest from theseal_approvedevInspect / bucket finalize as evidence.GET /api/memwal/artifactfor the same private namespace without a valid read token →authorizeNamespace401/403, no ciphertext fetched, no decrypt (AC#5/AC#6/AC#7). AfterrevokeNamespaceToken, the previously-working token returns 403 and recall can no longer decrypt — the demo assertion.contextmem storage push <run> --visibility private --rememberproduces aprovider:"harbor"receipt withbucketId/fileId/sealPolicyId/sealIdentity; a public push is byte-identical to today (provider:"tatum"). Confirm CIbun-check.ymltypecheck+test+build stays green.Risks & gotchas
@mysten/sealin the Worker (Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5). WebCrypto/BLS CPU + compressed-bundle cap; if decrypt busts limits, step 10's decrypt must move to a Node relayer offCONTEXTMEM_EXTRACT_QUEUE— design the recall handler so the decrypt step is swappable.mirror_missing_grant. Bucket finalize sponsor sig expires fast and Harbor returns a ~3s post-finalize 403 — that retry belongs in Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6'sHarborClient; this issue must surface (not swallow) a genuine private-write failure so it fails closed.packProofBundleis Node-only (tar/child_process,storage.ts:231) — keep private encryption on the CLI/MCP/queue Node path; the Worker hosted→Walrus private write (in-memory R2 zip) is explicitly out of scope here.0007.ALTER TABLE ADD COLUMNon existingcontextmem_namespacesis non-destructive; ensure new SELECTs tolerate NULLs for pre-migration rows (legacy public namespaces have no Harbor refs).Acceptance-criteria mapping
TatumStorageProvider, no behavior change → Steps 1, 3.providerunion + Harbor fields (core/types.ts:655) → Step 2.packages/walrus/src/seal.tsencrypt/decrypt +seal_approvePTB → Step 4.selectStorageProvider, private encrypt+Harbor upload, receipt records provider,rememberStorageIndex/renderStorageIndexcarry refs + back-compat → Steps 5, 6, 7, 8.GET /api/memwal/artifactfetch+decrypt, token-gated viaauthorizeNamespace, public→aggregator → Steps 9, 10.Effort
L — ~5-8 person-days of focused work after #5/#6/#7 land. Dep-free slice (AC#1 + AC#2 +
TatumStorageProvider: Steps 1-3) is ~1 day and can ship immediately; the SEAL/Harbor/recall slice (Steps 4-11) is the remaining ~4-7 days and is gated by the spike and the HarborClient/mapping issues.Implementation plan generated for execution (planning phase). Part of the roadmap (#4).