@meddleware/seal-ui 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,15 +21,20 @@ package; the client seam is [`@meddleware/seal-client`](https://github.com/meddl
21
21
 
22
22
  ## Configure (build-time `VITE_*`)
23
23
 
24
+ The `seal_policies` and `access_gate` ids come from the published `deployments` (seal-client and
25
+ access-gate-client) for the active network; they are not configuration.
26
+
24
27
  | Var | Meaning |
25
28
  | --- | --- |
26
- | `VITE_NETWORK` | `testnet` (default) or `mainnet` (pending) |
27
- | `VITE_SEAL_PACKAGE_ID_{NET}` | Published `seal_policies` package id |
29
+ | `VITE_NETWORK` | Network the standalone build selects: `testnet` (default) or `mainnet`. Embedded, the host's selector rules. |
28
30
  | `VITE_SEAL_SERVER_OBJECT_IDS_{NET}` | CSV of key-server object ids (the committee) |
29
31
  | `VITE_SEAL_AGGREGATOR_URLS_{NET}` | CSV of aggregator URLs (index-aligned) |
30
- | `VITE_SEAL_THRESHOLD` | `t` in t-of-n (default 2) |
31
- | `VITE_WALRUS_PUBLISHER_{NET}` / `VITE_WALRUS_AGGREGATOR_{NET}` | Walrus HTTP endpoints (defaults provided) |
32
- | `VITE_RPC_{NET}` | Sui RPC URL override |
32
+ | `VITE_SEAL_AGGREGATOR_API_KEY_{NET}` | Enoki API key sent as `X-API-Key` to aggregator-backed servers (required by the mainnet aggregator; publishable, baked into the bundle) |
33
+ | `VITE_SEAL_THRESHOLD_{NET}` | `t` over the configured servers; a committee behind an aggregator counts as one (default testnet 2, mainnet 1). An out-of-range value disables sealing on that network with an explanation |
34
+ | `VITE_WALRUS_PUBLISHER_{NET}` | Walrus HTTP publisher. Testnet has a default; **mainnet has none** (Walrus runs no public mainnet publisher), so set an operator-run publisher |
35
+ | `VITE_WALRUS_AGGREGATOR_{NET}` | Walrus HTTP aggregator (Mysten reference endpoints by default) |
36
+ | `VITE_WALRUS_MAX_UPLOAD_BYTES` | Largest ciphertext sent to the publisher (default 10 MiB, the public-publisher limit) |
37
+ | `VITE_INDEXER_URL` | Optional read-indexer for sealed-content discovery (display data; falls back to the full node) |
33
38
 
34
39
  ## Develop
35
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/seal-ui",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "homepage": "https://docs.meddleware.co.uk/blockchain/sui/sealed-storage/",
5
5
  "description": "Standalone Vue 3 SPA for Sealed Storage — client-side Seal encryption + access-gated, decentralised Walrus storage on Sui.",
6
6
  "author": "MeddleWare <dev@meddleware.co.uk>",
@@ -44,11 +44,13 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
+ "@meddleware/access-gate-client": "^0.0.1",
47
48
  "@meddleware/design-tokens": "^0.1.7",
48
- "@meddleware/seal-client": "^0.0.7",
49
+ "@meddleware/seal-client": "^0.0.9",
49
50
  "@meddleware/ui": "^0.1.28",
50
- "@meddleware/wallet-adapter": "^0.0.9",
51
- "@mysten/sui": "^2.33.0",
51
+ "@meddleware/wallet-adapter": "^0.0.11",
52
+ "@meddleware/walrus-client": "^0.0.20",
53
+ "@mysten/sui": "^2.33.1",
52
54
  "@mysten/wallet-standard": "^0.21.0",
53
55
  "vue": "^3.5.43"
54
56
  },
@@ -71,7 +73,7 @@
71
73
  "stylelint-config-standard": "^40.0.0",
72
74
  "typescript": "~6.0.0",
73
75
  "vite": "^8.3.0",
74
- "vitest": "~5.0.1",
76
+ "vitest": "~5.0.2",
75
77
  "vue-eslint-parser": "^10.4.1",
76
78
  "vue-tsc": "~3.3.11"
77
79
  },
package/src/App.vue CHANGED
@@ -2,7 +2,7 @@
2
2
  // Standalone shell for the Sealed Storage SPA: app header + footer wrapping the core tool view.
3
3
  // The core UI lives in SealView.vue (also exported for inline embedding in the dashboard).
4
4
  import { AppHeader, AppFooter, ColorModeControl, useColorMode } from '@meddleware/ui'
5
- import { NETWORK } from './config.js'
5
+ import { network } from './config.js'
6
6
  import SealView from './components/SealView.vue'
7
7
 
8
8
  const { mode, set } = useColorMode('dark')
@@ -14,17 +14,17 @@ const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://dev.meddleware.co.uk/
14
14
  <div class="app">
15
15
  <AppHeader variant="dark">
16
16
  <template #brand>
17
- <span>🔒 Sealed Storage</span>
17
+ <h1 class="brand-title">🔒 Sealed Storage</h1>
18
18
  </template>
19
19
  <template #actions>
20
- <span class="badge">{{ NETWORK }}</span>
20
+ <span class="badge">{{ network }}</span>
21
21
  <ColorModeControl :model-value="mode" @update:model-value="set" />
22
22
  </template>
23
23
  </AppHeader>
24
24
 
25
- <div class="app__content">
25
+ <main class="app__content">
26
26
  <SealView />
27
- </div>
27
+ </main>
28
28
 
29
29
  <AppFooter :docs-url="DOCS_URL" :dev-url="DEV_URL" />
30
30
  </div>
@@ -56,4 +56,10 @@ const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://dev.meddleware.co.uk/
56
56
  color: var(--muted);
57
57
  border: 1px solid var(--border);
58
58
  }
59
+
60
+ /* The app title is the page's h1; keep the header's own type styles. */
61
+ .brand-title {
62
+ font: inherit;
63
+ margin: 0;
64
+ }
59
65
  </style>
@@ -9,9 +9,8 @@
9
9
  // globals via main.ts → styles.css.
10
10
  import { computed, onMounted, ref, watch } from 'vue'
11
11
  import { AppTabNav, UiStepper, UiTabPanel, UiToolIntro, type AppTab, type StepperStep } from '@meddleware/ui'
12
- import { Transaction } from '@mysten/sui/transactions'
13
12
  import {
14
- buildPublishSealedContentTx,
13
+ buildPublishSealedContentTransaction,
15
14
  type SealedManifest,
16
15
  type SealPolicyProvider,
17
16
  type SealedContentPointer,
@@ -20,15 +19,18 @@ import {
20
19
  import { checkManifestForNetwork } from '../manifest-guard.js'
21
20
  import { WalletGuard } from '@meddleware/wallet-adapter'
22
21
  import { useWallet, getSuiClient } from '../wallet.js'
23
- import { registry, getSealController } from '../seal.js'
22
+ import { registry, getSealController, clearSealSessions } from '../seal.js'
24
23
  import { storeBlob, readBlob } from '../walrus.js'
25
24
  import { discoverSealedContent } from '../sealed-content.js'
26
- import { NETWORK, SEAL_CONFIGURED, SEAL_PACKAGE_ID } from '../config.js'
25
+ import { activeConfig, network } from '../config.js'
27
26
 
28
27
  const { account, signPersonalMessage, signAndExecute } = useWallet()
29
28
 
30
- const providers = registry.list() as SealPolicyProvider[]
31
- const disabled = computed(() => !SEAL_CONFIGURED)
29
+ const providers = computed(() => registry.value.list() as SealPolicyProvider[])
30
+
31
+ /** Shown inside each tab panel until a wallet is connected. */
32
+ const GUARD_MESSAGE = 'Connect a Sui wallet to encrypt and decrypt sealed content.'
33
+ const disabled = computed(() => activeConfig.value.problem !== null)
32
34
 
33
35
  const TABS: AppTab[] = [
34
36
  { id: 'encrypt', label: 'Encrypt' },
@@ -65,7 +67,7 @@ watch(tab, () => {
65
67
  // Force manual mode so the pre-set ID renders in the text input immediately, before suggestions load.
66
68
  onMounted(() => {
67
69
  const gate = new URLSearchParams(location.search).get('gate')
68
- if (gate && providers.some((p) => p.type === 'nft-gate')) {
70
+ if (gate && providers.value.some((p) => p.type === 'nft-gate')) {
69
71
  encType.value = 'nft-gate'
70
72
  encValues.value = { ...encValues.value, gateId: gate }
71
73
  encGateManual.value = true
@@ -83,6 +85,15 @@ function coerce(kind: string, raw: string | boolean): unknown {
83
85
  return String(raw)
84
86
  }
85
87
 
88
+ /**
89
+ * A download filename derived from an untrusted label (on-chain pointers are written by anyone):
90
+ * keep word characters, dots and dashes, cap the length, and never start with a dot.
91
+ */
92
+ function safeFileName(label: string | undefined, fallback: string): string {
93
+ const cleaned = (label ?? '').replace(/[^\w.-]/g, '_').replace(/^\.+/, '').slice(0, 100)
94
+ return cleaned || fallback
95
+ }
96
+
86
97
  function triggerDownload(blob: Blob, name: string): void {
87
98
  const url = URL.createObjectURL(blob)
88
99
  const a = document.createElement('a')
@@ -108,8 +119,8 @@ async function fetchGateSuggestions(provider: SealPolicyProvider | null): Promis
108
119
  }
109
120
 
110
121
  // ── Encrypt ──────────────────────────────────────────────────────────────────
111
- const encType = ref(providers[0]?.type ?? '')
112
- const encProvider = computed(() => providers.find((p) => p.type === encType.value) ?? null)
122
+ const encType = ref(providers.value[0]?.type ?? '')
123
+ const encProvider = computed(() => providers.value.find((p) => p.type === encType.value) ?? null)
113
124
  const encValues = ref<Record<string, string | boolean>>({})
114
125
 
115
126
  /** One input per policy field; its type follows the field kind (v-model handles each type). */
@@ -126,6 +137,23 @@ function onEncFile(e: Event): void {
126
137
  encFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
127
138
  }
128
139
 
140
+ // SessionKeys are per address: drop them whenever the wallet disconnects or switches account.
141
+ watch(
142
+ () => account.value?.address,
143
+ (next, prev) => {
144
+ if (prev && next !== prev) clearSealSessions()
145
+ },
146
+ )
147
+
148
+ // A network switch invalidates everything shown for the previous network.
149
+ watch(network, () => {
150
+ manifest.value = null
151
+ publishDigest.value = null
152
+ discovered.value = []
153
+ status.value = null
154
+ errorMsg.value = null
155
+ })
156
+
129
157
  watch(
130
158
  [() => account.value?.address, encProvider],
131
159
  async ([, provider]) => {
@@ -143,10 +171,10 @@ watch(
143
171
  )
144
172
 
145
173
  watch(
146
- [() => account.value?.address, tab],
174
+ [() => account.value?.address, tab, network],
147
175
  async ([, t]) => {
148
176
  if (t !== 'unlock') return
149
- const nftGateProvider = providers.find((p) => p.type === 'nft-gate') ?? null
177
+ const nftGateProvider = providers.value.find((p) => p.type === 'nft-gate') ?? null
150
178
  unlockGateLoading.value = true
151
179
  unlockGateManual.value = false
152
180
  try {
@@ -181,12 +209,14 @@ async function performEncrypt(): Promise<void> {
181
209
  const data = new Uint8Array(await encFile.value.arrayBuffer())
182
210
  const { id, ciphertext } = await (await getSealController()).encrypt(provider.type, params, data)
183
211
  status.value = 'Storing ciphertext on Walrus…'
184
- const blobId = await storeBlob(ciphertext)
212
+ const owner = account.value?.address
213
+ if (!owner) throw new Error('Connect your wallet to store the ciphertext.')
214
+ const blobId = await storeBlob(ciphertext, { sendObjectTo: owner })
185
215
  manifest.value = {
186
216
  policyType: provider.type,
187
217
  id,
188
218
  blobId,
189
- network: NETWORK,
219
+ network: network.value,
190
220
  params,
191
221
  label: encLabel.value || encFile.value.name,
192
222
  }
@@ -202,7 +232,7 @@ async function performEncrypt(): Promise<void> {
202
232
  function downloadManifest(): void {
203
233
  if (!manifest.value) return
204
234
  const blob = new Blob([JSON.stringify(manifest.value, null, 2)], { type: 'application/json' })
205
- triggerDownload(blob, `${manifest.value.label ?? 'sealed'}.seal.json`)
235
+ triggerDownload(blob, `${safeFileName(manifest.value.label, 'sealed')}.seal.json`)
206
236
  }
207
237
 
208
238
  // ── Decrypt ──────────────────────────────────────────────────────────────────
@@ -212,11 +242,11 @@ const decValues = ref<Record<string, string | boolean>>({})
212
242
  // AND enforce that it targets the network this app is built for — a manifest sealed on another
213
243
  // network references a package + committee that don't exist here and can never decrypt. The
214
244
  // `decManifestError` is surfaced next to the input so a rejection explains itself.
215
- const decCheck = computed(() => checkManifestForNetwork(decManifestText.value, NETWORK))
245
+ const decCheck = computed(() => checkManifestForNetwork(decManifestText.value, network.value))
216
246
  const decManifest = computed<SealedManifest | null>(() => decCheck.value.manifest)
217
247
  const decManifestError = computed<string | null>(() => decCheck.value.error)
218
248
  const decProvider = computed(() =>
219
- decManifest.value ? (providers.find((p) => p.type === decManifest.value?.policyType) ?? null) : null,
249
+ decManifest.value ? (providers.value.find((p) => p.type === decManifest.value?.policyType) ?? null) : null,
220
250
  )
221
251
 
222
252
  async function onManifestFile(e: Event): Promise<void> {
@@ -254,7 +284,7 @@ async function performDecrypt(): Promise<void> {
254
284
  address: account.value.address,
255
285
  signPersonalMessage,
256
286
  })
257
- triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), m.label ?? 'decrypted')
287
+ triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), safeFileName(m.label, 'decrypted'))
258
288
  status.value = 'Decrypted — download started.'
259
289
  } catch (e) {
260
290
  errorMsg.value = e instanceof Error ? e.message : String(e)
@@ -280,8 +310,9 @@ async function performPublish(): Promise<void> {
280
310
  busy.value = true
281
311
  try {
282
312
  status.value = 'Publishing on-chain pointer — approve the transaction in your wallet…'
283
- const tx = new Transaction()
284
- buildPublishSealedContentTx(tx, SEAL_PACKAGE_ID, {
313
+ const seal = activeConfig.value.seal
314
+ if (!seal) throw new Error(activeConfig.value.problem ?? 'Sealed Storage is not available on this network.')
315
+ const tx = buildPublishSealedContentTransaction(seal.publishedAt, {
285
316
  gateId: String(m.params.gateId),
286
317
  blobId: m.blobId,
287
318
  sealId: m.id,
@@ -347,7 +378,7 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
347
378
  ciphertext,
348
379
  { address: account.value.address, signPersonalMessage },
349
380
  )
350
- triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), item.label || 'decrypted')
381
+ triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), safeFileName(item.label, 'decrypted'))
351
382
  status.value = 'Decrypted — download started.'
352
383
  } catch (e) {
353
384
  errorMsg.value = e instanceof Error ? e.message : String(e)
@@ -362,26 +393,18 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
362
393
 
363
394
  <UiToolIntro>Client-side encrypted, access-gated storage on Walrus + Sui.</UiToolIntro>
364
395
 
365
- <p v-if="!SEAL_CONFIGURED" class="notice notice--warn">
366
- <template v-if="NETWORK === 'mainnet'">
367
- Seal is not configured for mainnet. Set
368
- <code>VITE_SEAL_PACKAGE_ID_MAINNET</code>,
369
- <code>VITE_SEAL_SERVER_OBJECT_IDS_MAINNET</code>, and
370
- <code>VITE_SEAL_AGGREGATOR_URLS_MAINNET</code>, then redeploy.
371
- </template>
372
- <template v-else>
373
- This deployment has no Seal policy package or key-server committee configured
374
- (<code>VITE_SEAL_PACKAGE_ID_*</code> / <code>VITE_SEAL_SERVER_OBJECT_IDS_*</code>).
375
- </template>
376
- </p>
396
+ <p v-if="activeConfig.problem" class="notice notice--warn">{{ activeConfig.problem }}</p>
377
397
 
378
- <WalletGuard message="Connect a Sui wallet to encrypt and decrypt sealed content.">
379
- <AppTabNav v-model="tab" :tabs="TABS" id-prefix="seal" aria-label="Sealed storage" class="seal-tabs" />
398
+ <!-- The tab list and every panel always render (each tab controls a live panel); the wallet
399
+ prompt replaces only a panel's content until a wallet is connected. Panels stay mounted
400
+ (v-show) so each tab keeps its state. -->
401
+ <AppTabNav v-model="tab" :tabs="TABS" id-prefix="seal" aria-label="Sealed storage" all-panels class="seal-tabs" />
380
402
 
381
403
  <p v-if="errorMsg" class="notice notice--error" role="alert">{{ errorMsg }}</p>
382
404
 
383
405
  <!-- Encrypt -->
384
406
  <UiTabPanel v-show="tab === 'encrypt'" id-prefix="seal" tab="encrypt" class="card">
407
+ <WalletGuard :message="GUARD_MESSAGE">
385
408
  <UiStepper :steps="ENC_STEPS" v-model="encStep" />
386
409
 
387
410
  <!-- Step 0: Policy -->
@@ -488,10 +511,12 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
488
511
  <button type="button" class="link" @click="encStep--">Back</button>
489
512
  </div>
490
513
  </template>
514
+ </WalletGuard>
491
515
  </UiTabPanel>
492
516
 
493
517
  <!-- Unlock -->
494
518
  <UiTabPanel v-show="tab === 'unlock'" id-prefix="seal" tab="unlock" class="card">
519
+ <WalletGuard :message="GUARD_MESSAGE">
495
520
  <UiStepper :steps="UNLOCK_STEPS" v-model="unlockStep" />
496
521
 
497
522
  <!-- Step 0: Gate selector -->
@@ -548,10 +573,12 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
548
573
  <button type="button" class="link" @click="unlockStep--; discovered = []">Back</button>
549
574
  </div>
550
575
  </template>
576
+ </WalletGuard>
551
577
  </UiTabPanel>
552
578
 
553
579
  <!-- Decrypt -->
554
580
  <UiTabPanel v-show="tab === 'decrypt'" id-prefix="seal" tab="decrypt" class="card">
581
+ <WalletGuard :message="GUARD_MESSAGE">
555
582
  <UiStepper :steps="DEC_STEPS" v-model="decStep" />
556
583
 
557
584
  <!-- Step 0: Manifest input -->
@@ -600,6 +627,7 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
600
627
  </button>
601
628
  </div>
602
629
  </template>
630
+ </WalletGuard>
603
631
  </UiTabPanel>
604
632
 
605
633
  <p v-if="status" class="status muted">{{ status }}</p>
@@ -608,7 +636,6 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
608
636
  Decryption keys are released by a threshold committee of independent key servers. If enough
609
637
  servers are unreachable, decryption pauses — storage and retrieval are unaffected.
610
638
  </p>
611
- </WalletGuard>
612
639
  </template>
613
640
 
614
641
  <style scoped>
package/src/config.ts CHANGED
@@ -1,117 +1,141 @@
1
- // Build-time configuration (Vite inlines VITE_* at build). All values are baked into the static
2
- // bundle via Docker --build-arg. See Dockerfile for the full list.
1
+ // Configuration. The network is wallet-adapter's shared runtime selector (the standalone build
2
+ // selects VITE_NETWORK in main.ts; embedded, the host's selector rules). Everything network-specific
3
+ // is resolved for that network:
4
+ // - package ids (seal_policies, access_gate) come from the published `deployments` — never env;
5
+ // - the key-server committee, threshold and Walrus endpoints are operator settings baked in as
6
+ // VITE_*_{NET} build vars, with Mysten defaults where they exist.
7
+ import { computed } from 'vue'
3
8
  import type { KeyServerConfig } from '@meddleware/seal-client'
9
+ import { SEAL_POLICIES_DEPLOYMENTS, type SealPoliciesDeployment } from '@meddleware/seal-client/deployments'
10
+ import { ACCESS_GATE_DEPLOYMENTS, type AccessGateDeployment } from '@meddleware/access-gate-client/deployments'
11
+ import { useNetwork } from '@meddleware/wallet-adapter'
4
12
 
5
- export type Network = 'testnet' | 'mainnet'
13
+ type EnvSource = Record<string, string | undefined>
14
+ const env: EnvSource = (import.meta as unknown as { env?: EnvSource }).env ?? {}
6
15
 
7
- const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
8
-
9
- export const NETWORK: Network = (env.VITE_NETWORK as Network) || 'testnet'
10
-
11
- /** Read a per-network var, e.g. netEnv('VITE_SEAL_PACKAGE_ID') → VITE_SEAL_PACKAGE_ID_TESTNET. */
12
- function netEnv(base: string): string | undefined {
13
- return env[`${base}_${NETWORK.toUpperCase()}`]
14
- }
16
+ /** The active network (read-only ref). */
17
+ export const network = useNetwork().network
15
18
 
16
19
  function csv(v: string | undefined): string[] {
17
20
  return (v ?? '').split(',').map((s) => s.trim()).filter(Boolean)
18
21
  }
19
22
 
20
- export const RPC_URLS: Record<Network, string> = {
21
- testnet: env.VITE_RPC_TESTNET || 'https://fullnode.testnet.sui.io:443',
22
- mainnet: env.VITE_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
23
+ /**
24
+ * Mysten key-server defaults. Testnet: the decentralized committee (via its aggregator) plus two
25
+ * independent servers. Mainnet: the verified 5-of-8 committee behind the mainnet aggregator, which
26
+ * needs an Enoki API key (`VITE_SEAL_AGGREGATOR_API_KEY_MAINNET`, a publishable client key scoped
27
+ * to Seal — it is baked into the bundle). A committee counts as ONE server.
28
+ */
29
+ const SERVER_DEFAULTS: Record<string, { ids: string[]; aggs: string[]; threshold: number }> = {
30
+ testnet: {
31
+ ids: [
32
+ '0xb012378c9f3799fb5b1a7083da74a4069e3c3f1c93de0b27212a5799ce1e1e98',
33
+ '0x73d05d62c18d9374e3ea529e8e0ed6161da1a141a94d3f76ae3fe4e99356db75',
34
+ '0xf5d14a81a982144ae441cd7d64b09027f116a468bd36e7eca494f750591623c8',
35
+ ],
36
+ aggs: ['https://seal-aggregator-testnet.mystenlabs.com'],
37
+ threshold: 2,
38
+ },
39
+ mainnet: {
40
+ ids: ['0x686098f1439237fff9f36b99c7329683c22979d2005c2465cb891acb012a7595'],
41
+ aggs: ['https://seal-aggregator-mainnet.mystenlabs.com'],
42
+ threshold: 1,
43
+ },
23
44
  }
24
45
 
25
- /** Published `seal_policies` package id for this network (required to encrypt/decrypt). */
26
- export const SEAL_PACKAGE_ID: string =
27
- netEnv('VITE_SEAL_PACKAGE_ID') ||
28
- // Meddleware's canonical testnet deployment — override with VITE_SEAL_PACKAGE_ID_TESTNET
29
- (NETWORK === 'testnet'
30
- ? '0x9f0563bfe42fbd29932cd280cc47efe17f5339b4dc569eb110114665eecc231e'
31
- : '')
32
-
33
46
  /**
34
- * Threshold `t` in the t-of-n committee.
35
- * Testnet default: 2 (matches the 3-server testnet committee)
36
- * Mainnet default: 5 (matches the 5-of-8 Mysten Labs mainnet committee)
47
+ * Walrus HTTP endpoints. Walrus runs no public publisher on mainnet (a publisher pays SUI + WAL
48
+ * itself), so mainnet has no publisher default: set `VITE_WALRUS_PUBLISHER_MAINNET` to an
49
+ * operator-run authenticated publisher.
37
50
  */
38
- export const SEAL_THRESHOLD = Number(
39
- env.VITE_SEAL_THRESHOLD || (NETWORK === 'mainnet' ? '5' : '2'),
40
- )
51
+ const WALRUS_DEFAULTS: Record<string, { publisher: string; aggregator: string }> = {
52
+ testnet: {
53
+ publisher: 'https://publisher.walrus-testnet.walrus.space',
54
+ aggregator: 'https://aggregator.walrus-testnet.walrus.space',
55
+ },
56
+ mainnet: { publisher: '', aggregator: 'https://aggregator.walrus-mainnet.walrus.space' },
57
+ }
41
58
 
42
59
  /**
43
- * The key-server committee. Object ids and aggregator URLs are supplied as parallel CSV lists
44
- * (index-aligned); aggregatorUrl is required for decentralized (committee-type) servers only —
45
- * independent servers derive their URL from the on-chain object and do not need one.
46
- *
47
- * Testnet defaults — three verified Mysten Labs servers:
48
- * [0] Decentralized (Mysten aggregator) — requires aggregatorUrl
49
- * [1] Independent server 1
50
- * [2] Independent server 2
51
- * Override via VITE_SEAL_SERVER_OBJECT_IDS_TESTNET / VITE_SEAL_AGGREGATOR_URLS_TESTNET.
52
- *
53
- * Mainnet defaults — Mysten Labs 5-of-8 decentralized committee:
54
- * Committee object: 0x686098f1439237fff9f36b99c7329683c22979d2005c2465cb891acb012a7595
55
- * Aggregator: https://seal-aggregator-mainnet.mystenlabs.com
56
- * NOTE: The mainnet aggregator requires an Enoki API key (X-API-Key header).
57
- * Override via VITE_SEAL_SERVER_OBJECT_IDS_MAINNET / VITE_SEAL_AGGREGATOR_URLS_MAINNET.
58
- * When independent open-mode key servers become available on mainnet, they can be added here.
60
+ * The threshold error for a committee, or null when the pair is valid. A threshold no committee could
61
+ * meet (or a non-integer one) is reported instead of failing at the first decrypt.
59
62
  */
60
- export const SEAL_SERVERS: KeyServerConfig[] = (() => {
61
- const defaults: Record<Network, { ids: string[]; aggs: string[] }> = {
62
- testnet: {
63
- ids: [
64
- '0xb012378c9f3799fb5b1a7083da74a4069e3c3f1c93de0b27212a5799ce1e1e98',
65
- '0x73d05d62c18d9374e3ea529e8e0ed6161da1a141a94d3f76ae3fe4e99356db75',
66
- '0xf5d14a81a982144ae441cd7d64b09027f116a468bd36e7eca494f750591623c8',
67
- ],
68
- aggs: ['https://seal-aggregator-testnet.mystenlabs.com'],
69
- },
70
- mainnet: {
71
- ids: ['0x686098f1439237fff9f36b99c7329683c22979d2005c2465cb891acb012a7595'],
72
- aggs: ['https://seal-aggregator-mainnet.mystenlabs.com'],
73
- },
63
+ export function thresholdError(threshold: number, servers: readonly { weight?: number }[]): string | null {
64
+ const total = servers.reduce((sum, s) => sum + (s.weight ?? 1), 0)
65
+ if (servers.length === 0) return null // no committee — reported as its own problem
66
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > total) {
67
+ return `The Seal threshold must be an integer in [1, ${total}] for ${servers.length} configured server(s); got ${threshold}`
74
68
  }
69
+ return null
70
+ }
75
71
 
76
- const envIds = csv(netEnv('VITE_SEAL_SERVER_OBJECT_IDS'))
77
- const envAggs = csv(netEnv('VITE_SEAL_AGGREGATOR_URLS'))
78
- const ids = envIds.length > 0 ? envIds : defaults[NETWORK].ids
79
- const aggs = envAggs.length > 0 ? envAggs : defaults[NETWORK].aggs
80
- return ids.map((objectId, i) => ({ objectId, weight: 1, aggregatorUrl: aggs[i] }))
81
- })()
72
+ /** Everything sealing needs on one network. */
73
+ export interface SealConfig {
74
+ network: string
75
+ /** The `seal_policies` deployment, or null where none is recorded. */
76
+ seal: SealPoliciesDeployment | null
77
+ /** The `access_gate` original id ('' where none is recorded; gate suggestions are then off). */
78
+ accessGateOriginalId: string
79
+ servers: KeyServerConfig[]
80
+ threshold: number
81
+ walrusPublisher: string
82
+ walrusAggregator: string
83
+ /** Why sealing is unavailable on this network, or null when it is configured. */
84
+ problem: string | null
85
+ }
82
86
 
83
- /** Walrus HTTP publisher/aggregator for storing/reading the opaque ciphertext blob. */
84
- export const WALRUS_PUBLISHER: string =
85
- netEnv('VITE_WALRUS_PUBLISHER') ||
86
- (NETWORK === 'mainnet'
87
- ? 'https://publisher.walrus.space'
88
- : 'https://publisher.walrus-testnet.walrus.space')
87
+ /** Resolve the configuration for `net` (pure; `envSource` is injectable for tests). */
88
+ export function sealConfig(net: string, envSource: EnvSource = env): SealConfig {
89
+ const NET = net.toUpperCase()
90
+ const netEnv = (base: string) => envSource[`${base}_${NET}`]
91
+ const seal = (SEAL_POLICIES_DEPLOYMENTS as Record<string, SealPoliciesDeployment>)[net] ?? null
92
+ const accessGate = (ACCESS_GATE_DEPLOYMENTS as Record<string, AccessGateDeployment>)[net] ?? null
89
93
 
90
- export const WALRUS_AGGREGATOR: string =
91
- netEnv('VITE_WALRUS_AGGREGATOR') ||
92
- (NETWORK === 'mainnet'
93
- ? 'https://aggregator.walrus.space'
94
- : 'https://aggregator.walrus-testnet.walrus.space')
94
+ const defaults = SERVER_DEFAULTS[net]
95
+ const envIds = csv(netEnv('VITE_SEAL_SERVER_OBJECT_IDS'))
96
+ const envAggs = csv(netEnv('VITE_SEAL_AGGREGATOR_URLS'))
97
+ const ids = envIds.length ? envIds : (defaults?.ids ?? [])
98
+ const aggs = envAggs.length ? envAggs : (defaults?.aggs ?? [])
99
+ const apiKey = netEnv('VITE_SEAL_AGGREGATOR_API_KEY')
100
+ const servers: KeyServerConfig[] = ids.map((objectId, i) => {
101
+ const aggregatorUrl = aggs[i]
102
+ return aggregatorUrl && apiKey
103
+ ? { objectId, weight: 1, aggregatorUrl, apiKeyName: 'X-API-Key', apiKey }
104
+ : { objectId, weight: 1, aggregatorUrl }
105
+ })
106
+ const rawThreshold = netEnv('VITE_SEAL_THRESHOLD')
107
+ const threshold = rawThreshold ? Number(rawThreshold) : (defaults?.threshold ?? 1)
108
+
109
+ const walrus = WALRUS_DEFAULTS[net]
110
+ const problem = !seal
111
+ ? `Sealed Storage is not available on ${net}: no seal_policies deployment is recorded for it.`
112
+ : servers.length === 0
113
+ ? `Sealed Storage is not configured on ${net}: set VITE_SEAL_SERVER_OBJECT_IDS_${NET} (and VITE_SEAL_AGGREGATOR_URLS_${NET} for a committee).`
114
+ : thresholdError(threshold, servers)
115
+
116
+ return {
117
+ network: net,
118
+ seal,
119
+ accessGateOriginalId: accessGate?.originalId ?? '',
120
+ servers,
121
+ threshold,
122
+ walrusPublisher: netEnv('VITE_WALRUS_PUBLISHER') || walrus?.publisher || '',
123
+ walrusAggregator: netEnv('VITE_WALRUS_AGGREGATOR') || walrus?.aggregator || '',
124
+ problem,
125
+ }
126
+ }
95
127
 
96
- /** Default blob lifetime (Walrus storage epochs). */
97
- export const WALRUS_EPOCHS = Number(env.VITE_WALRUS_EPOCHS || '5')
128
+ /** The configuration for the active network. */
129
+ export const activeConfig = computed(() => sealConfig(network.value))
98
130
 
99
131
  /**
100
- * Meddleware's deployed `access_gate` package IDs per network.
101
- * Used by the nft-gate provider's `suggest()` to list AdminCap-owned gates for the connected wallet.
102
- * Kept in sync with access-gate-ui/src/constants.ts (same deployment).
132
+ * Largest ciphertext the app sends to the publisher. Public publishers cap requests at 10 MiB;
133
+ * an operator-run publisher may allow more via VITE_WALRUS_MAX_UPLOAD_BYTES.
103
134
  */
104
- export const ACCESS_GATE_PACKAGE_ID: Record<Network, string> = {
105
- testnet: '0x0bedd0b27d993d3292ca6a5315f7562de8bc0ff3752b445b4c53252c76f2d20d',
106
- mainnet: '',
107
- }
135
+ export const WALRUS_MAX_UPLOAD_BYTES = Number(env.VITE_WALRUS_MAX_UPLOAD_BYTES || String(10 * 1024 * 1024))
108
136
 
109
- /** True when the on-chain policy package + committee are configured for the active network. */
110
- export const SEAL_CONFIGURED = Boolean(SEAL_PACKAGE_ID) && SEAL_SERVERS.length > 0
137
+ /** Default blob lifetime (Walrus storage epochs). */
138
+ export const WALRUS_EPOCHS = Number(env.VITE_WALRUS_EPOCHS || '5')
111
139
 
112
- /**
113
- * True when the active network has no Seal committee configured (empty package ID or no servers).
114
- * On mainnet, populate VITE_SEAL_PACKAGE_ID_MAINNET + VITE_SEAL_SERVER_OBJECT_IDS_MAINNET +
115
- * VITE_SEAL_AGGREGATOR_URLS_MAINNET to enable sealing — no code change required.
116
- */
117
- export const MAINNET_PENDING = !SEAL_CONFIGURED
140
+ /** Optional read-indexer for sealed-content discovery (display data; falls back to the full node). */
141
+ export const INDEXER_URL: string = env.VITE_INDEXER_URL || ''
package/src/main.ts CHANGED
@@ -4,7 +4,14 @@ import '@meddleware/design-tokens/seasons.css'
4
4
  import '@meddleware/ui/base.css'
5
5
  import './styles.css'
6
6
  import { useSeason } from '@meddleware/ui'
7
+ import { useNetwork } from '@meddleware/wallet-adapter'
7
8
  import App from './App.vue'
8
9
 
10
+ // The standalone build targets one network; select it in the shared selector so the client, the
11
+ // ids, the key servers and the Walrus endpoints all agree. (Embedded, the host's selector rules.)
12
+ const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
13
+ const built = env.VITE_NETWORK || 'testnet'
14
+ if (built === 'testnet' || built === 'mainnet') useNetwork().setNetwork(built)
15
+
9
16
  useSeason()
10
17
  createApp(App).mount('#app')
package/src/seal.ts CHANGED
@@ -1,31 +1,60 @@
1
- // Wires the shared @meddleware/seal-client into this app: one registry (drives the policy picker)
2
- // and one SealController (encrypt/decrypt over the configured committee). Module singletons.
1
+ // Wires the shared @meddleware/seal-client into this app: a policy registry (drives the picker)
2
+ // and a SealController (encrypt/decrypt over the configured committee), each per network.
3
3
  //
4
4
  // SealController is loaded lazily (dynamic import) so @mysten/seal and @noble/curves are kept
5
5
  // out of the initial bundle — they're only needed when the user triggers encrypt/decrypt.
6
+ import { computed } from 'vue'
6
7
  import { createDefaultRegistry, type PolicyRegistry } from '@meddleware/seal-client'
7
8
  import type { SealController } from '@meddleware/seal-client/controller'
8
9
  import { getSuiClient } from './wallet.js'
9
- import { NETWORK, SEAL_PACKAGE_ID, SEAL_THRESHOLD, SEAL_SERVERS, ACCESS_GATE_PACKAGE_ID } from './config.js'
10
+ import { activeConfig, type SealConfig } from './config.js'
10
11
 
11
- /** The policy registry (nft-gate + time-lock as peers). Iterate `list()` to render the picker. */
12
- export const registry: PolicyRegistry = createDefaultRegistry(ACCESS_GATE_PACKAGE_ID[NETWORK])
12
+ const registries = new Map<string, PolicyRegistry>()
13
+ const controllers = new Map<string, SealController>()
13
14
 
14
- let controller: SealController | null = null
15
+ /** The policy registry for a network (nft-gate + time-lock as peers). */
16
+ export function registryFor(cfg: SealConfig): PolicyRegistry {
17
+ let r = registries.get(cfg.network)
18
+ if (!r) {
19
+ r = createDefaultRegistry(cfg.accessGateOriginalId)
20
+ registries.set(cfg.network, r)
21
+ }
22
+ return r
23
+ }
24
+
25
+ /** The registry for the active network. Iterate `list()` to render the picker. */
26
+ export const registry = computed(() => registryFor(activeConfig.value))
15
27
 
16
- /** Lazily build the SealController from build-time config + the shared registry. */
28
+ /**
29
+ * The SealController for the active network, built on first use.
30
+ *
31
+ * @throws {Error} when sealing is not configured on the active network (the reason is the message).
32
+ */
17
33
  export async function getSealController(): Promise<SealController> {
18
- if (!controller) {
34
+ const cfg = activeConfig.value
35
+ if (cfg.problem || !cfg.seal) throw new Error(cfg.problem ?? `Sealed Storage is not available on ${cfg.network}.`)
36
+ let c = controllers.get(cfg.network)
37
+ if (!c) {
19
38
  const { SealController } = await import('@meddleware/seal-client/controller')
20
- controller = new SealController(
39
+ c = new SealController(
21
40
  {
22
41
  suiClient: getSuiClient(),
23
- packageId: SEAL_PACKAGE_ID,
24
- threshold: SEAL_THRESHOLD,
25
- serverConfigs: SEAL_SERVERS,
42
+ originalId: cfg.seal.originalId,
43
+ publishedAt: cfg.seal.publishedAt,
44
+ threshold: cfg.threshold,
45
+ serverConfigs: cfg.servers,
26
46
  },
27
- registry,
47
+ registryFor(cfg),
28
48
  )
49
+ controllers.set(cfg.network, c)
29
50
  }
30
- return controller
51
+ return c
52
+ }
53
+
54
+ /**
55
+ * Drop every cached SessionKey (wallet disconnected or switched account). A no-op before a
56
+ * controller has been loaded, so calling it never pulls `@mysten/seal` into the bundle.
57
+ */
58
+ export function clearSealSessions(): void {
59
+ for (const c of controllers.values()) c.clearSession()
31
60
  }
@@ -1,45 +1,22 @@
1
- // Discover on-chain SealedContent pointers for a gate by indexing the SealedContentPublished
2
- // event. Uses the gRPC client's listEvents + a client-side gate_id filter (no indexer needed).
3
- import { sealedContentEventType, type SealedContentPointer } from '@meddleware/seal-client'
1
+ // Discover on-chain SealedContent pointers for a gate with seal-client's `listSealedContent`
2
+ // (exact event type at the deployment's original id; BCS-decoded). Reads the read-indexer when
3
+ // VITE_INDEXER_URL is set — display data only, falling back to the full node.
4
+ import { listSealedContent, type SealedContentPointer } from '@meddleware/seal-client'
4
5
  import { getSuiClient } from './wallet.js'
5
- import { SEAL_PACKAGE_ID } from './config.js'
6
+ import { activeConfig, INDEXER_URL } from './config.js'
6
7
 
7
- function normId(id: string): string {
8
- const lower = id.toLowerCase()
9
- return lower.startsWith('0x') ? lower : `0x${lower}`
10
- }
11
-
12
- interface RawEvent {
13
- content_id?: string
14
- gate_id?: string
15
- blob_id?: string
16
- seal_id?: string
17
- label?: string
18
- publisher?: string
19
- }
8
+ /** Most pointers listed for one gate. */
9
+ export const MAX_DISCOVERED = 200
20
10
 
21
- /** All published pointers whose `gate_id` matches, newest first. */
11
+ /** Published pointers for `gateId` on the active network, newest first. */
22
12
  export async function discoverSealedContent(gateId: string): Promise<SealedContentPointer[]> {
23
- if (!SEAL_PACKAGE_ID) throw new Error('Seal policy package is not configured.')
24
- const client = getSuiClient()
25
- // gRPC listEvents returns ascending by default (no cursor → no descending option); reverse at end.
26
- const res = await client.listEvents({
27
- filter: { eventType: sealedContentEventType(SEAL_PACKAGE_ID) },
28
- limit: 200,
13
+ const cfg = activeConfig.value
14
+ if (!cfg.seal) throw new Error(cfg.problem ?? `Sealed Storage is not available on ${cfg.network}.`)
15
+ const page = await listSealedContent(getSuiClient(), {
16
+ originalId: cfg.seal.originalId,
17
+ gateId,
18
+ limit: MAX_DISCOVERED,
19
+ indexer: INDEXER_URL ? { url: INDEXER_URL, network: cfg.network } : undefined,
29
20
  })
30
- const target = normId(gateId)
31
- const out: SealedContentPointer[] = []
32
- for (const ev of res.events) {
33
- const p = ev.json as RawEvent | undefined
34
- if (!p?.gate_id || normId(p.gate_id) !== target) continue
35
- out.push({
36
- contentId: p.content_id ?? '',
37
- gateId: p.gate_id,
38
- blobId: p.blob_id ?? '',
39
- sealId: p.seal_id ?? '',
40
- label: p.label ?? '',
41
- publisher: p.publisher ?? '',
42
- })
43
- }
44
- return out.reverse()
21
+ return page.pointers
45
22
  }
package/src/walrus.ts CHANGED
@@ -1,30 +1,32 @@
1
- // Ciphertext storage via the Walrus HTTP publisher/aggregator. The blob is opaque (already
2
- // Seal-encrypted), so we need no wallet, no wasm, and no @mysten/walrus SDK here — just fetch.
3
- // (Routing uploads through the Meddleware relay to capture the tip/commission is a documented
4
- // follow-up; see the Sealed Storage plan.)
5
- import { WALRUS_PUBLISHER, WALRUS_AGGREGATOR, WALRUS_EPOCHS } from './config.js'
1
+ // Ciphertext storage via the Walrus HTTP publisher/aggregator (@meddleware/walrus-client/http). The
2
+ // blob is opaque (already Seal-encrypted), so no wallet, wasm or @mysten/walrus is involved. This
3
+ // module only applies the active network's endpoints and the app's limits.
4
+ import { readBlob as readViaAggregator, storeBlobViaPublisher } from '@meddleware/walrus-client/http'
5
+ import { activeConfig, WALRUS_EPOCHS, WALRUS_MAX_UPLOAD_BYTES } from './config.js'
6
6
 
7
- interface PublishResponse {
8
- newlyCreated?: { blobObject?: { blobId?: string } }
9
- alreadyCertified?: { blobId?: string }
10
- }
11
-
12
- /** Store bytes on Walrus; returns the blob id. */
13
- export async function storeBlob(bytes: Uint8Array, epochs = WALRUS_EPOCHS): Promise<string> {
14
- const res = await fetch(`${WALRUS_PUBLISHER}/v1/blobs?epochs=${epochs}`, {
15
- method: 'PUT',
16
- body: bytes as BodyInit,
7
+ /**
8
+ * Store the ciphertext as a permanent blob whose `Blob` object goes to `sendObjectTo` (the user, not
9
+ * the publisher). Returns the blob id.
10
+ */
11
+ export async function storeBlob(bytes: Uint8Array, opts: { sendObjectTo: string }): Promise<string> {
12
+ const cfg = activeConfig.value
13
+ if (!cfg.walrusPublisher) {
14
+ throw new Error(
15
+ `No Walrus publisher is configured for ${cfg.network}. Walrus runs no public mainnet publisher; ` +
16
+ `set VITE_WALRUS_PUBLISHER_${cfg.network.toUpperCase()} to an operator-run publisher.`,
17
+ )
18
+ }
19
+ return storeBlobViaPublisher(bytes, {
20
+ publisher: cfg.walrusPublisher,
21
+ epochs: WALRUS_EPOCHS,
22
+ sendObjectTo: opts.sendObjectTo,
23
+ maxBytes: WALRUS_MAX_UPLOAD_BYTES,
17
24
  })
18
- if (!res.ok) throw new Error(`Walrus publisher error ${res.status}: ${await res.text()}`)
19
- const json = (await res.json()) as PublishResponse
20
- const blobId = json.newlyCreated?.blobObject?.blobId ?? json.alreadyCertified?.blobId
21
- if (!blobId) throw new Error('Walrus publisher returned no blob id.')
22
- return blobId
23
25
  }
24
26
 
25
- /** Read bytes back from Walrus by blob id. */
26
- export async function readBlob(blobId: string): Promise<Uint8Array> {
27
- const res = await fetch(`${WALRUS_AGGREGATOR}/v1/blobs/${encodeURIComponent(blobId)}`)
28
- if (!res.ok) throw new Error(`Walrus aggregator error ${res.status}`)
29
- return new Uint8Array(await res.arrayBuffer())
27
+ /** Read the ciphertext back, with the aggregator's strict consistency check. */
28
+ export function readBlob(blobId: string): Promise<Uint8Array> {
29
+ const cfg = activeConfig.value
30
+ if (!cfg.walrusAggregator) throw new Error(`No Walrus aggregator is configured for ${cfg.network}.`)
31
+ return readViaAggregator(blobId, { aggregator: cfg.walrusAggregator })
30
32
  }
package/vite.config.ts CHANGED
@@ -5,4 +5,9 @@ import vue from '@vitejs/plugin-vue'
5
5
  // (plain fetch), so no @mysten/walrus wasm to exclude here.
6
6
  export default defineConfig({
7
7
  plugins: [vue()],
8
+ optimizeDeps: {
9
+ // @meddleware/wallet-adapter ships TS + .vue source and holds the shared wallet singleton; if it
10
+ // were pre-bundled, its .vue files (served raw) would load a second copy of the singleton in dev.
11
+ exclude: ['@meddleware/wallet-adapter'],
12
+ },
8
13
  })