@meddleware/seal-ui 0.0.3
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/CHANGELOG.md +15 -0
- package/LICENSE +14 -0
- package/README.md +54 -0
- package/index.html +13 -0
- package/package.json +59 -0
- package/src/App.vue +44 -0
- package/src/components/SealView.vue +559 -0
- package/src/config.ts +69 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +11 -0
- package/src/main.ts +7 -0
- package/src/seal.ts +31 -0
- package/src/sealed-content.ts +45 -0
- package/src/styles.css +29 -0
- package/src/wallet.ts +52 -0
- package/src/walrus.ts +30 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +8 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@meddleware/seal-ui` are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-09-02
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Initial release. Sealed Storage SPA:
|
|
11
|
+
- Encrypt a file under a registry-driven policy (nft-gate, time-lock), store the ciphertext on
|
|
12
|
+
Walrus (HTTP publisher), and download a portable manifest.
|
|
13
|
+
- Decrypt from a manifest: fetch ciphertext, mint a Seal SessionKey via one wallet signature, and
|
|
14
|
+
download the plaintext.
|
|
15
|
+
- Testnet-first with a mainnet-pending notice (Seal committee mode is testnet-only).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MeddleWare
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @meddleware/seal-ui
|
|
2
|
+
|
|
3
|
+
Sealed Storage — a standalone Vue 3 SPA for client-side [Seal](https://seal-docs.wal.app/) encryption
|
|
4
|
+
with access-gated, decentralised [Walrus](https://walrus.xyz) storage on Sui.
|
|
5
|
+
|
|
6
|
+
Encrypt a file under a policy (access-gate NFT ownership, time-lock, …), store the ciphertext on
|
|
7
|
+
Walrus, and share a small **manifest**. Anyone with the manifest can decrypt — but only if the
|
|
8
|
+
on-chain policy allows (e.g. they hold a valid gate pass, or the unlock time has passed).
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
1. **Encrypt** in the browser via `@meddleware/seal-client` (threshold Seal). No key material ever
|
|
13
|
+
touches a server.
|
|
14
|
+
2. **Store** the opaque ciphertext on Walrus over HTTP; keep the returned blob id in the manifest.
|
|
15
|
+
3. **Decrypt** by signing one wallet message (mints a short-lived Seal SessionKey); a threshold
|
|
16
|
+
committee of independent key servers releases key shares only if the on-chain `seal_approve`
|
|
17
|
+
passes.
|
|
18
|
+
|
|
19
|
+
The policies live in the [`seal_policies`](https://github.com/meddleware-org/seal-policies-sui) Move
|
|
20
|
+
package; the client seam is [`@meddleware/seal-client`](https://github.com/meddleware-org/seal-client).
|
|
21
|
+
|
|
22
|
+
## Configure (build-time `VITE_*`)
|
|
23
|
+
|
|
24
|
+
| Var | Meaning |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `VITE_NETWORK` | `testnet` (default) or `mainnet` (pending) |
|
|
27
|
+
| `VITE_SEAL_PACKAGE_ID_{NET}` | Published `seal_policies` package id |
|
|
28
|
+
| `VITE_SEAL_SERVER_OBJECT_IDS_{NET}` | CSV of key-server object ids (the committee) |
|
|
29
|
+
| `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 |
|
|
33
|
+
|
|
34
|
+
## Develop
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# @meddleware/seal-client must be linked or published:
|
|
38
|
+
( cd ../seal-client && npm link )
|
|
39
|
+
npm install && npm link @meddleware/seal-client
|
|
40
|
+
npm run dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Build
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm run build # vue-tsc + vite → dist/
|
|
47
|
+
docker build -t seal-ui .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Committee mode is testnet-only today; mainnet stays disabled with an in-app notice until it ships.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
BSD Zero Clause License (`0BSD`). See [LICENSE](LICENSE).
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<link rel="icon" href="/favicon.ico">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Sealed Storage — Meddleware</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meddleware/seal-ui",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Standalone Vue 3 SPA for Sealed Storage — client-side Seal encryption + access-gated, decentralised Walrus storage on Sui.",
|
|
5
|
+
"author": "MeddleWare <meddleware@proton.me>",
|
|
6
|
+
"license": "0BSD",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/meddleware-org/seal-ui.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"*.css",
|
|
14
|
+
"*.vue"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"default": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"index.html",
|
|
26
|
+
"vite.config.ts",
|
|
27
|
+
"tsconfig.json",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "vite",
|
|
32
|
+
"build": "vue-tsc --noEmit && vite build",
|
|
33
|
+
"preview": "vite preview",
|
|
34
|
+
"type-check": "vue-tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@meddleware/design-tokens": "^0.1.2",
|
|
41
|
+
"@meddleware/seal-client": "^0.0.2",
|
|
42
|
+
"@meddleware/ui": "^0.1.6",
|
|
43
|
+
"@meddleware/wallet-adapter": "^0.0.2",
|
|
44
|
+
"@mysten/seal": "^1.4.7",
|
|
45
|
+
"@mysten/sui": "^2.28.0",
|
|
46
|
+
"@mysten/wallet-standard": "^0.20.0",
|
|
47
|
+
"vue": "^3.5.40"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "~24.12.2",
|
|
51
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
52
|
+
"typescript": "~6.0.0",
|
|
53
|
+
"vite": "^8.1.5",
|
|
54
|
+
"vue-tsc": "~3.3.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": "^22.18.0 || >=24.12.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/App.vue
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Standalone shell for the Sealed Storage SPA: app header + footer wrapping the core tool view.
|
|
3
|
+
// The core UI lives in SealView.vue (also exported for inline embedding in the dashboard).
|
|
4
|
+
import { AppHeader, AppFooter, ColorModeControl, useColorMode } from '@meddleware/ui'
|
|
5
|
+
import { NETWORK } from './config.js'
|
|
6
|
+
import SealView from './components/SealView.vue'
|
|
7
|
+
|
|
8
|
+
const { mode, set } = useColorMode('dark')
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="app">
|
|
13
|
+
<AppHeader variant="dark">
|
|
14
|
+
<template #brand>
|
|
15
|
+
<span>🔒 Sealed Storage</span>
|
|
16
|
+
</template>
|
|
17
|
+
<template #actions>
|
|
18
|
+
<span class="badge">{{ NETWORK }}</span>
|
|
19
|
+
<ColorModeControl :model-value="mode" @update:model-value="set" />
|
|
20
|
+
</template>
|
|
21
|
+
</AppHeader>
|
|
22
|
+
|
|
23
|
+
<SealView />
|
|
24
|
+
|
|
25
|
+
<AppFooter />
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style scoped>
|
|
30
|
+
.app {
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.badge {
|
|
37
|
+
font-size: 0.75rem;
|
|
38
|
+
padding: 0.2rem 0.5rem;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
background: var(--surface);
|
|
41
|
+
color: var(--muted);
|
|
42
|
+
border: 1px solid var(--border);
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Core Sealed Storage tool UI (Encrypt / Decrypt / Unlock), free of any app shell.
|
|
3
|
+
// Rendered standalone by seal-ui's App.vue and inline by the dashboard. Wallet state comes from
|
|
4
|
+
// the shared @meddleware/wallet-adapter singleton (via ./wallet.js): connecting here or in any
|
|
5
|
+
// other inline tool view (or the dashboard header) reflects everywhere.
|
|
6
|
+
//
|
|
7
|
+
// Styles are scoped to this component so the dashboard can import it without pulling seal-ui's
|
|
8
|
+
// global stylesheet (which restyles body / #app / bare inputs). The standalone app keeps those
|
|
9
|
+
// globals via main.ts → styles.css.
|
|
10
|
+
import { computed, onMounted, ref } from 'vue'
|
|
11
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
12
|
+
import {
|
|
13
|
+
buildPublishSealedContentTx,
|
|
14
|
+
type SealedManifest,
|
|
15
|
+
type SealPolicyProvider,
|
|
16
|
+
type SealedContentPointer,
|
|
17
|
+
} from '@meddleware/seal-client'
|
|
18
|
+
import { useWallet } from '../wallet.js'
|
|
19
|
+
import { registry, getSealController } from '../seal.js'
|
|
20
|
+
import { storeBlob, readBlob } from '../walrus.js'
|
|
21
|
+
import { discoverSealedContent } from '../sealed-content.js'
|
|
22
|
+
import { NETWORK, MAINNET_PENDING, SEAL_CONFIGURED, SEAL_PACKAGE_ID } from '../config.js'
|
|
23
|
+
|
|
24
|
+
const { wallets, account, connect, disconnect, signPersonalMessage, signAndExecute } = useWallet()
|
|
25
|
+
|
|
26
|
+
const providers = registry.list() as SealPolicyProvider[]
|
|
27
|
+
const disabled = computed(() => MAINNET_PENDING || !SEAL_CONFIGURED)
|
|
28
|
+
|
|
29
|
+
type Tab = 'encrypt' | 'decrypt' | 'unlock'
|
|
30
|
+
const tab = ref<Tab>('encrypt')
|
|
31
|
+
|
|
32
|
+
// Deep-link from access-gate-ui: ?gate=<id> preselects the nft-gate policy + gate.
|
|
33
|
+
onMounted(() => {
|
|
34
|
+
const gate = new URLSearchParams(location.search).get('gate')
|
|
35
|
+
if (gate && providers.some((p) => p.type === 'nft-gate')) {
|
|
36
|
+
encType.value = 'nft-gate'
|
|
37
|
+
encValues.value = { ...encValues.value, gateId: gate }
|
|
38
|
+
unlockGateId.value = gate
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
const busy = ref(false)
|
|
42
|
+
const status = ref<string | null>(null)
|
|
43
|
+
const errorMsg = ref<string | null>(null)
|
|
44
|
+
|
|
45
|
+
function coerce(kind: string, raw: string | boolean): unknown {
|
|
46
|
+
if (kind === 'boolean') return Boolean(raw)
|
|
47
|
+
if (kind === 'datetime') return Date.parse(String(raw))
|
|
48
|
+
return String(raw)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function triggerDownload(blob: Blob, name: string): void {
|
|
52
|
+
const url = URL.createObjectURL(blob)
|
|
53
|
+
const a = document.createElement('a')
|
|
54
|
+
a.href = url
|
|
55
|
+
a.download = name
|
|
56
|
+
a.click()
|
|
57
|
+
URL.revokeObjectURL(url)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Encrypt ──────────────────────────────────────────────────────────────────
|
|
61
|
+
const encType = ref(providers[0]?.type ?? '')
|
|
62
|
+
const encProvider = computed(() => providers.find((p) => p.type === encType.value) ?? null)
|
|
63
|
+
const encValues = ref<Record<string, string | boolean>>({})
|
|
64
|
+
const encFile = ref<File | null>(null)
|
|
65
|
+
const encLabel = ref('')
|
|
66
|
+
const manifest = ref<SealedManifest | null>(null)
|
|
67
|
+
|
|
68
|
+
function onEncFile(e: Event): void {
|
|
69
|
+
encFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function performEncrypt(): Promise<void> {
|
|
73
|
+
errorMsg.value = null
|
|
74
|
+
status.value = null
|
|
75
|
+
manifest.value = null
|
|
76
|
+
const provider = encProvider.value
|
|
77
|
+
if (!provider) return
|
|
78
|
+
if (!encFile.value) {
|
|
79
|
+
errorMsg.value = 'Choose a file to encrypt.'
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
busy.value = true
|
|
83
|
+
try {
|
|
84
|
+
const params: Record<string, unknown> = {}
|
|
85
|
+
for (const f of provider.describe().encryptFields) {
|
|
86
|
+
const v = encValues.value[f.name]
|
|
87
|
+
if (f.required && (v === undefined || v === '')) throw new Error(`${f.label} is required.`)
|
|
88
|
+
if (v !== undefined && v !== '') params[f.name] = coerce(f.kind, v)
|
|
89
|
+
}
|
|
90
|
+
status.value = 'Encrypting…'
|
|
91
|
+
const data = new Uint8Array(await encFile.value.arrayBuffer())
|
|
92
|
+
const { id, ciphertext } = await (await getSealController()).encrypt(provider.type, params, data)
|
|
93
|
+
status.value = 'Storing ciphertext on Walrus…'
|
|
94
|
+
const blobId = await storeBlob(ciphertext)
|
|
95
|
+
manifest.value = {
|
|
96
|
+
policyType: provider.type,
|
|
97
|
+
id,
|
|
98
|
+
blobId,
|
|
99
|
+
network: NETWORK,
|
|
100
|
+
params,
|
|
101
|
+
label: encLabel.value || encFile.value.name,
|
|
102
|
+
}
|
|
103
|
+
status.value = 'Sealed. Keep the manifest below — you need it to decrypt.'
|
|
104
|
+
} catch (e) {
|
|
105
|
+
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
106
|
+
} finally {
|
|
107
|
+
busy.value = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function downloadManifest(): void {
|
|
112
|
+
if (!manifest.value) return
|
|
113
|
+
const blob = new Blob([JSON.stringify(manifest.value, null, 2)], { type: 'application/json' })
|
|
114
|
+
triggerDownload(blob, `${manifest.value.label ?? 'sealed'}.seal.json`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Decrypt ──────────────────────────────────────────────────────────────────
|
|
118
|
+
const decManifestText = ref('')
|
|
119
|
+
const decValues = ref<Record<string, string | boolean>>({})
|
|
120
|
+
const decManifest = computed<SealedManifest | null>(() => {
|
|
121
|
+
if (!decManifestText.value.trim()) return null
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(decManifestText.value) as SealedManifest
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
const decProvider = computed(() =>
|
|
129
|
+
decManifest.value ? (providers.find((p) => p.type === decManifest.value?.policyType) ?? null) : null,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async function onManifestFile(e: Event): Promise<void> {
|
|
133
|
+
const f = (e.target as HTMLInputElement).files?.[0]
|
|
134
|
+
if (f) decManifestText.value = await f.text()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function performDecrypt(): Promise<void> {
|
|
138
|
+
errorMsg.value = null
|
|
139
|
+
status.value = null
|
|
140
|
+
const m = decManifest.value
|
|
141
|
+
const provider = decProvider.value
|
|
142
|
+
if (!m || !provider) {
|
|
143
|
+
errorMsg.value = 'Paste or upload a valid manifest.'
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (!account.value) {
|
|
147
|
+
errorMsg.value = 'Connect your wallet to decrypt.'
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
busy.value = true
|
|
151
|
+
try {
|
|
152
|
+
const params: Record<string, unknown> = { ...(m.params ?? {}) }
|
|
153
|
+
for (const f of provider.describe().decryptFields) {
|
|
154
|
+
const v = decValues.value[f.name]
|
|
155
|
+
if (v !== undefined && v !== '') params[f.name] = coerce(f.kind, v)
|
|
156
|
+
if (f.required && (params[f.name] === undefined || params[f.name] === '')) {
|
|
157
|
+
throw new Error(`${f.label} is required.`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
status.value = 'Fetching ciphertext from Walrus…'
|
|
161
|
+
const ciphertext = await readBlob(m.blobId)
|
|
162
|
+
status.value = 'Requesting decryption keys — approve the signature in your wallet…'
|
|
163
|
+
const plaintext = await (await getSealController()).decrypt(m.policyType, params, m.id, ciphertext, {
|
|
164
|
+
address: account.value.address,
|
|
165
|
+
signPersonalMessage,
|
|
166
|
+
})
|
|
167
|
+
triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), m.label ?? 'decrypted')
|
|
168
|
+
status.value = 'Decrypted — download started.'
|
|
169
|
+
} catch (e) {
|
|
170
|
+
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
171
|
+
} finally {
|
|
172
|
+
busy.value = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Publish an on-chain discovery pointer (nft-gate content only) ─────────────
|
|
177
|
+
const publishDigest = ref<string | null>(null)
|
|
178
|
+
|
|
179
|
+
async function performPublish(): Promise<void> {
|
|
180
|
+
errorMsg.value = null
|
|
181
|
+
const m = manifest.value
|
|
182
|
+
if (!m || m.policyType !== 'nft-gate' || !m.params?.gateId) {
|
|
183
|
+
errorMsg.value = 'On-chain publishing is available for access-gate content only.'
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (!account.value) {
|
|
187
|
+
errorMsg.value = 'Connect your wallet to publish.'
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
busy.value = true
|
|
191
|
+
try {
|
|
192
|
+
status.value = 'Publishing on-chain pointer — approve the transaction in your wallet…'
|
|
193
|
+
const tx = new Transaction()
|
|
194
|
+
buildPublishSealedContentTx(tx, SEAL_PACKAGE_ID, {
|
|
195
|
+
gateId: String(m.params.gateId),
|
|
196
|
+
blobId: m.blobId,
|
|
197
|
+
sealId: m.id,
|
|
198
|
+
label: m.label ?? '',
|
|
199
|
+
})
|
|
200
|
+
const { digest } = await signAndExecute(tx)
|
|
201
|
+
publishDigest.value = digest
|
|
202
|
+
status.value = 'Published — gate pass-holders can now discover and unlock this content.'
|
|
203
|
+
} catch (e) {
|
|
204
|
+
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
205
|
+
} finally {
|
|
206
|
+
busy.value = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Unlock: discover a gate's sealed content and decrypt ──────────────────────
|
|
211
|
+
const unlockGateId = ref('')
|
|
212
|
+
const discovered = ref<SealedContentPointer[]>([])
|
|
213
|
+
const unlockNftId = ref('')
|
|
214
|
+
const unlockSoulbound = ref(false)
|
|
215
|
+
|
|
216
|
+
async function performDiscover(): Promise<void> {
|
|
217
|
+
errorMsg.value = null
|
|
218
|
+
discovered.value = []
|
|
219
|
+
if (!unlockGateId.value) {
|
|
220
|
+
errorMsg.value = 'Enter a gate id to search.'
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
busy.value = true
|
|
224
|
+
try {
|
|
225
|
+
status.value = 'Searching for sealed content…'
|
|
226
|
+
discovered.value = await discoverSealedContent(unlockGateId.value)
|
|
227
|
+
status.value = discovered.value.length
|
|
228
|
+
? `Found ${discovered.value.length} item(s).`
|
|
229
|
+
: 'No sealed content published for this gate.'
|
|
230
|
+
} catch (e) {
|
|
231
|
+
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
232
|
+
} finally {
|
|
233
|
+
busy.value = false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function performUnlock(item: SealedContentPointer): Promise<void> {
|
|
238
|
+
errorMsg.value = null
|
|
239
|
+
if (!account.value) {
|
|
240
|
+
errorMsg.value = 'Connect your wallet to decrypt.'
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (!unlockNftId.value) {
|
|
244
|
+
errorMsg.value = 'Enter your pass NFT id.'
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
busy.value = true
|
|
248
|
+
try {
|
|
249
|
+
status.value = 'Fetching ciphertext from Walrus…'
|
|
250
|
+
const ciphertext = await readBlob(item.blobId)
|
|
251
|
+
status.value = 'Requesting decryption keys — approve the signature in your wallet…'
|
|
252
|
+
const plaintext = await (await getSealController()).decrypt(
|
|
253
|
+
'nft-gate',
|
|
254
|
+
{ gateId: item.gateId, nftId: unlockNftId.value, soulbound: unlockSoulbound.value },
|
|
255
|
+
item.sealId,
|
|
256
|
+
ciphertext,
|
|
257
|
+
{ address: account.value.address, signPersonalMessage },
|
|
258
|
+
)
|
|
259
|
+
triggerDownload(new Blob([plaintext.slice().buffer as ArrayBuffer]), item.label || 'decrypted')
|
|
260
|
+
status.value = 'Decrypted — download started.'
|
|
261
|
+
} catch (e) {
|
|
262
|
+
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
263
|
+
} finally {
|
|
264
|
+
busy.value = false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const shortAddr = computed(() => {
|
|
269
|
+
const a = account.value?.address
|
|
270
|
+
return a ? `${a.slice(0, 6)}…${a.slice(-4)}` : ''
|
|
271
|
+
})
|
|
272
|
+
</script>
|
|
273
|
+
|
|
274
|
+
<template>
|
|
275
|
+
<div class="page">
|
|
276
|
+
<p class="muted">Client-side encrypted, access-gated storage on Walrus + Sui.</p>
|
|
277
|
+
|
|
278
|
+
<div v-if="MAINNET_PENDING" class="notice notice--warn">
|
|
279
|
+
Mainnet support is pending — Seal committee mode is currently testnet-only. Switch to testnet to
|
|
280
|
+
seal content.
|
|
281
|
+
</div>
|
|
282
|
+
<div v-else-if="!SEAL_CONFIGURED" class="notice notice--warn">
|
|
283
|
+
This deployment has no Seal policy package or key-server committee configured
|
|
284
|
+
(<code>VITE_SEAL_PACKAGE_ID_*</code> / <code>VITE_SEAL_SERVER_OBJECT_IDS_*</code>).
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<section class="card" style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
|
|
288
|
+
<template v-if="account">
|
|
289
|
+
<span class="muted">Connected: {{ shortAddr }}</span>
|
|
290
|
+
<button class="link" @click="disconnect">Disconnect</button>
|
|
291
|
+
</template>
|
|
292
|
+
<template v-else>
|
|
293
|
+
<span class="muted">Wallet needed only to decrypt.</span>
|
|
294
|
+
<span>
|
|
295
|
+
<button
|
|
296
|
+
v-for="w in wallets"
|
|
297
|
+
:key="w.name"
|
|
298
|
+
class="primary"
|
|
299
|
+
style="margin-left:0.4rem"
|
|
300
|
+
@click="connect(w)"
|
|
301
|
+
>
|
|
302
|
+
Connect {{ w.name }}
|
|
303
|
+
</button>
|
|
304
|
+
<span v-if="!wallets.length" class="muted">No Sui wallet detected.</span>
|
|
305
|
+
</span>
|
|
306
|
+
</template>
|
|
307
|
+
</section>
|
|
308
|
+
|
|
309
|
+
<nav class="tabs">
|
|
310
|
+
<button :class="{ active: tab === 'encrypt' }" @click="tab = 'encrypt'">Encrypt</button>
|
|
311
|
+
<button :class="{ active: tab === 'decrypt' }" @click="tab = 'decrypt'">Decrypt</button>
|
|
312
|
+
<button :class="{ active: tab === 'unlock' }" @click="tab = 'unlock'">Unlock</button>
|
|
313
|
+
</nav>
|
|
314
|
+
|
|
315
|
+
<div v-if="errorMsg" class="notice notice--error">{{ errorMsg }}</div>
|
|
316
|
+
|
|
317
|
+
<!-- Encrypt -->
|
|
318
|
+
<section v-show="tab === 'encrypt'" class="card">
|
|
319
|
+
<label class="field">
|
|
320
|
+
<span>Policy</span>
|
|
321
|
+
<select v-model="encType" :disabled="disabled">
|
|
322
|
+
<option v-for="p in providers" :key="p.type" :value="p.type">
|
|
323
|
+
{{ p.describe().label }}
|
|
324
|
+
</option>
|
|
325
|
+
</select>
|
|
326
|
+
</label>
|
|
327
|
+
<p v-if="encProvider" class="muted">{{ encProvider.describe().help }}</p>
|
|
328
|
+
|
|
329
|
+
<template v-if="encProvider">
|
|
330
|
+
<label
|
|
331
|
+
v-for="f in encProvider.describe().encryptFields"
|
|
332
|
+
:key="f.name"
|
|
333
|
+
class="field"
|
|
334
|
+
:class="{ checkbox: f.kind === 'boolean' }"
|
|
335
|
+
>
|
|
336
|
+
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
337
|
+
<input v-if="f.kind === 'datetime'" type="datetime-local" v-model="encValues[f.name]" />
|
|
338
|
+
<input v-else-if="f.kind === 'boolean'" type="checkbox" v-model="encValues[f.name]" />
|
|
339
|
+
<input v-else type="text" v-model="encValues[f.name]" :placeholder="f.help" />
|
|
340
|
+
</label>
|
|
341
|
+
</template>
|
|
342
|
+
|
|
343
|
+
<label class="field">
|
|
344
|
+
<span>Label (optional)</span>
|
|
345
|
+
<input type="text" v-model="encLabel" placeholder="A name to recognise this later" />
|
|
346
|
+
</label>
|
|
347
|
+
|
|
348
|
+
<label class="field">
|
|
349
|
+
<span>File</span>
|
|
350
|
+
<input type="file" @change="onEncFile" />
|
|
351
|
+
</label>
|
|
352
|
+
|
|
353
|
+
<button class="primary" :disabled="busy || disabled" @click="performEncrypt">
|
|
354
|
+
{{ busy ? 'Working…' : 'Encrypt & store' }}
|
|
355
|
+
</button>
|
|
356
|
+
|
|
357
|
+
<div v-if="manifest" style="margin-top:1rem">
|
|
358
|
+
<p class="muted">Manifest — save this; it's required to decrypt (it holds no secrets):</p>
|
|
359
|
+
<pre class="manifest">{{ JSON.stringify(manifest, null, 2) }}</pre>
|
|
360
|
+
<button class="link" @click="downloadManifest">Download manifest</button>
|
|
361
|
+
|
|
362
|
+
<div v-if="manifest.policyType === 'nft-gate'" style="margin-top:0.75rem">
|
|
363
|
+
<p class="muted">
|
|
364
|
+
Optional: publish an on-chain pointer so this gate's pass-holders can discover and unlock
|
|
365
|
+
this content in the Unlock tab (no need to share the manifest).
|
|
366
|
+
</p>
|
|
367
|
+
<button class="primary" :disabled="busy || !account" @click="performPublish">
|
|
368
|
+
{{ busy ? 'Working…' : 'Publish discovery pointer' }}
|
|
369
|
+
</button>
|
|
370
|
+
<span v-if="!account" class="muted"> — connect a wallet first.</span>
|
|
371
|
+
<p v-if="publishDigest" class="muted">Published in tx {{ publishDigest.slice(0, 10) }}…</p>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</section>
|
|
375
|
+
|
|
376
|
+
<!-- Unlock -->
|
|
377
|
+
<section v-show="tab === 'unlock'" class="card">
|
|
378
|
+
<p class="muted">
|
|
379
|
+
Discover content sealed to an access gate and decrypt it with a pass you hold.
|
|
380
|
+
</p>
|
|
381
|
+
<label class="field">
|
|
382
|
+
<span>Access gate ID</span>
|
|
383
|
+
<input type="text" v-model="unlockGateId" placeholder="0x… gate object id" />
|
|
384
|
+
</label>
|
|
385
|
+
<button class="primary" :disabled="busy || !unlockGateId" @click="performDiscover">
|
|
386
|
+
{{ busy ? 'Working…' : 'Find sealed content' }}
|
|
387
|
+
</button>
|
|
388
|
+
|
|
389
|
+
<div v-if="discovered.length" style="margin-top:1rem">
|
|
390
|
+
<label class="field">
|
|
391
|
+
<span>Your pass NFT ID *</span>
|
|
392
|
+
<input type="text" v-model="unlockNftId" placeholder="0x… your AccessNFT for this gate" />
|
|
393
|
+
</label>
|
|
394
|
+
<label class="field checkbox">
|
|
395
|
+
<input type="checkbox" v-model="unlockSoulbound" />
|
|
396
|
+
<span>Pass is soulbound</span>
|
|
397
|
+
</label>
|
|
398
|
+
|
|
399
|
+
<div v-for="item in discovered" :key="item.contentId" class="card" style="margin:0.5rem 0">
|
|
400
|
+
<strong>{{ item.label || '(untitled)' }}</strong>
|
|
401
|
+
<p class="muted" style="word-break:break-all">blob {{ item.blobId }}</p>
|
|
402
|
+
<button class="primary" :disabled="busy || !account || !unlockNftId" @click="performUnlock(item)">
|
|
403
|
+
Unlock & download
|
|
404
|
+
</button>
|
|
405
|
+
<span v-if="!account" class="muted"> — connect a wallet to decrypt.</span>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</section>
|
|
409
|
+
|
|
410
|
+
<!-- Decrypt -->
|
|
411
|
+
<section v-show="tab === 'decrypt'" class="card">
|
|
412
|
+
<label class="field">
|
|
413
|
+
<span>Manifest (paste JSON or upload)</span>
|
|
414
|
+
<textarea v-model="decManifestText" placeholder='{ "policyType": "...", "id": "...", "blobId": "..." }'></textarea>
|
|
415
|
+
</label>
|
|
416
|
+
<input type="file" accept="application/json,.json" @change="onManifestFile" />
|
|
417
|
+
|
|
418
|
+
<template v-if="decProvider">
|
|
419
|
+
<p class="muted" style="margin-top:0.75rem">
|
|
420
|
+
Policy: {{ decProvider.describe().label }} — {{ decProvider.describe().help }}
|
|
421
|
+
</p>
|
|
422
|
+
<label
|
|
423
|
+
v-for="f in decProvider.describe().decryptFields"
|
|
424
|
+
:key="f.name"
|
|
425
|
+
class="field"
|
|
426
|
+
:class="{ checkbox: f.kind === 'boolean' }"
|
|
427
|
+
>
|
|
428
|
+
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
429
|
+
<input v-if="f.kind === 'datetime'" type="datetime-local" v-model="decValues[f.name]" />
|
|
430
|
+
<input v-else-if="f.kind === 'boolean'" type="checkbox" v-model="decValues[f.name]" />
|
|
431
|
+
<input
|
|
432
|
+
v-else
|
|
433
|
+
type="text"
|
|
434
|
+
v-model="decValues[f.name]"
|
|
435
|
+
:placeholder="decManifest?.params?.[f.name] != null ? String(decManifest.params[f.name]) : f.help"
|
|
436
|
+
/>
|
|
437
|
+
</label>
|
|
438
|
+
</template>
|
|
439
|
+
<p v-else-if="decManifestText.trim()" class="muted">Unrecognised or invalid manifest.</p>
|
|
440
|
+
|
|
441
|
+
<button class="primary" :disabled="busy || !decProvider" @click="performDecrypt">
|
|
442
|
+
{{ busy ? 'Working…' : 'Decrypt' }}
|
|
443
|
+
</button>
|
|
444
|
+
</section>
|
|
445
|
+
|
|
446
|
+
<p v-if="status" class="status muted">{{ status }}</p>
|
|
447
|
+
|
|
448
|
+
<p class="muted disclaimer">
|
|
449
|
+
Decryption keys are released by a threshold committee of independent key servers. If enough
|
|
450
|
+
servers are unreachable, decryption pauses — storage and retrieval are unaffected.
|
|
451
|
+
</p>
|
|
452
|
+
</div>
|
|
453
|
+
</template>
|
|
454
|
+
|
|
455
|
+
<style scoped>
|
|
456
|
+
.page {
|
|
457
|
+
max-width: 780px;
|
|
458
|
+
margin: 0 auto;
|
|
459
|
+
flex: 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.notice {
|
|
463
|
+
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
464
|
+
border-radius: 10px;
|
|
465
|
+
padding: 0.7rem 0.9rem;
|
|
466
|
+
margin: 0.75rem 0;
|
|
467
|
+
font-size: 0.9rem;
|
|
468
|
+
}
|
|
469
|
+
.notice--warn { background: color-mix(in srgb, #d29922 15%, transparent); }
|
|
470
|
+
.notice--error { background: color-mix(in srgb, #f85149 18%, transparent); }
|
|
471
|
+
|
|
472
|
+
.tabs {
|
|
473
|
+
display: flex;
|
|
474
|
+
gap: 0.25rem;
|
|
475
|
+
margin: 1rem 0;
|
|
476
|
+
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
|
477
|
+
}
|
|
478
|
+
.tabs button {
|
|
479
|
+
background: transparent;
|
|
480
|
+
border: 0;
|
|
481
|
+
color: inherit;
|
|
482
|
+
padding: 0.5rem 0.9rem;
|
|
483
|
+
cursor: pointer;
|
|
484
|
+
opacity: 0.7;
|
|
485
|
+
border-bottom: 2px solid transparent;
|
|
486
|
+
}
|
|
487
|
+
.tabs button.active {
|
|
488
|
+
opacity: 1;
|
|
489
|
+
border-bottom-color: var(--accent, #c0503f);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.card {
|
|
493
|
+
background: var(--surface, #1e1917);
|
|
494
|
+
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
|
495
|
+
border-radius: 12px;
|
|
496
|
+
padding: 1rem 1.1rem;
|
|
497
|
+
margin: 0.75rem 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
label.field {
|
|
501
|
+
display: flex;
|
|
502
|
+
flex-direction: column;
|
|
503
|
+
gap: 0.3rem;
|
|
504
|
+
margin: 0.6rem 0;
|
|
505
|
+
font-size: 0.9rem;
|
|
506
|
+
}
|
|
507
|
+
label.field.checkbox {
|
|
508
|
+
flex-direction: row;
|
|
509
|
+
align-items: center;
|
|
510
|
+
gap: 0.5rem;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
input[type='text'],
|
|
514
|
+
input[type='datetime-local'],
|
|
515
|
+
textarea,
|
|
516
|
+
select {
|
|
517
|
+
background: var(--bg, #14100f);
|
|
518
|
+
color: inherit;
|
|
519
|
+
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
|
520
|
+
border-radius: 8px;
|
|
521
|
+
padding: 0.5rem 0.6rem;
|
|
522
|
+
font: inherit;
|
|
523
|
+
width: 100%;
|
|
524
|
+
box-sizing: border-box;
|
|
525
|
+
}
|
|
526
|
+
textarea { min-height: 6rem; resize: vertical; font-family: ui-monospace, monospace; font-size: 0.8rem; }
|
|
527
|
+
|
|
528
|
+
button.primary {
|
|
529
|
+
background: var(--accent, #c0503f);
|
|
530
|
+
color: #fff;
|
|
531
|
+
border: 0;
|
|
532
|
+
border-radius: 8px;
|
|
533
|
+
padding: 0.55rem 1.1rem;
|
|
534
|
+
font-weight: 600;
|
|
535
|
+
cursor: pointer;
|
|
536
|
+
}
|
|
537
|
+
button.primary:disabled { opacity: 0.5; cursor: default; }
|
|
538
|
+
|
|
539
|
+
button.link {
|
|
540
|
+
background: transparent;
|
|
541
|
+
border: 0;
|
|
542
|
+
color: var(--accent, #c0503f);
|
|
543
|
+
cursor: pointer;
|
|
544
|
+
padding: 0;
|
|
545
|
+
font: inherit;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
pre.manifest {
|
|
549
|
+
background: var(--bg, #14100f);
|
|
550
|
+
border-radius: 8px;
|
|
551
|
+
padding: 0.7rem;
|
|
552
|
+
overflow-x: auto;
|
|
553
|
+
font-size: 0.75rem;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.muted { color: var(--muted, #a89b96); font-size: 0.85rem; }
|
|
557
|
+
.status { font-size: 0.85rem; margin-top: 0.5rem; }
|
|
558
|
+
.disclaimer { margin-top: 2rem; }
|
|
559
|
+
</style>
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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.
|
|
3
|
+
import type { KeyServerConfig } from '@meddleware/seal-client'
|
|
4
|
+
|
|
5
|
+
export type Network = 'testnet' | 'mainnet'
|
|
6
|
+
|
|
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
|
+
}
|
|
15
|
+
|
|
16
|
+
function csv(v: string | undefined): string[] {
|
|
17
|
+
return (v ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
18
|
+
}
|
|
19
|
+
|
|
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
|
+
|
|
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
|
+
/** Threshold `t` in the t-of-n committee (default 2). */
|
|
34
|
+
export const SEAL_THRESHOLD = Number(env.VITE_SEAL_THRESHOLD || '2')
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The key-server committee. Object ids and aggregator URLs are supplied as parallel CSV lists
|
|
38
|
+
* (index-aligned); aggregatorUrl is required for committee-mode servers.
|
|
39
|
+
*/
|
|
40
|
+
export const SEAL_SERVERS: KeyServerConfig[] = (() => {
|
|
41
|
+
const ids = csv(netEnv('VITE_SEAL_SERVER_OBJECT_IDS'))
|
|
42
|
+
const aggs = csv(netEnv('VITE_SEAL_AGGREGATOR_URLS'))
|
|
43
|
+
return ids.map((objectId, i) => ({ objectId, weight: 1, aggregatorUrl: aggs[i] }))
|
|
44
|
+
})()
|
|
45
|
+
|
|
46
|
+
/** Walrus HTTP publisher/aggregator for storing/reading the opaque ciphertext blob. */
|
|
47
|
+
export const WALRUS_PUBLISHER: string =
|
|
48
|
+
netEnv('VITE_WALRUS_PUBLISHER') ||
|
|
49
|
+
(NETWORK === 'mainnet'
|
|
50
|
+
? 'https://publisher.walrus.space'
|
|
51
|
+
: 'https://publisher.walrus-testnet.walrus.space')
|
|
52
|
+
|
|
53
|
+
export const WALRUS_AGGREGATOR: string =
|
|
54
|
+
netEnv('VITE_WALRUS_AGGREGATOR') ||
|
|
55
|
+
(NETWORK === 'mainnet'
|
|
56
|
+
? 'https://aggregator.walrus.space'
|
|
57
|
+
: 'https://aggregator.walrus-testnet.walrus.space')
|
|
58
|
+
|
|
59
|
+
/** Default blob lifetime (Walrus storage epochs). */
|
|
60
|
+
export const WALRUS_EPOCHS = Number(env.VITE_WALRUS_EPOCHS || '5')
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Seal committee mode is testnet-only today. On mainnet the app shows a "pending" notice and
|
|
64
|
+
* disables sealing until the committee is available there.
|
|
65
|
+
*/
|
|
66
|
+
export const MAINNET_PENDING = NETWORK === 'mainnet'
|
|
67
|
+
|
|
68
|
+
/** True when the on-chain policy package + committee are configured for the active network. */
|
|
69
|
+
export const SEAL_CONFIGURED = Boolean(SEAL_PACKAGE_ID) && SEAL_SERVERS.length > 0
|
package/src/env.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @meddleware/seal-ui — library entry.
|
|
2
|
+
//
|
|
3
|
+
// Exports the core Sealed Storage tool view (no app shell) for inline embedding in the dashboard.
|
|
4
|
+
// The standalone SPA (App.vue + main.ts) is unaffected and still builds/deploys as before.
|
|
5
|
+
//
|
|
6
|
+
// SealView's styles are scoped, so consumers need only the shared design tokens + UI base at
|
|
7
|
+
// their entry (not seal-ui's global styles.css):
|
|
8
|
+
// import '@meddleware/design-tokens/tokens.css'
|
|
9
|
+
// import '@meddleware/ui/base.css'
|
|
10
|
+
|
|
11
|
+
export { default as SealView } from './components/SealView.vue'
|
package/src/main.ts
ADDED
package/src/seal.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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.
|
|
3
|
+
//
|
|
4
|
+
// SealController is loaded lazily (dynamic import) so @mysten/seal and @noble/curves are kept
|
|
5
|
+
// out of the initial bundle — they're only needed when the user triggers encrypt/decrypt.
|
|
6
|
+
import { createDefaultRegistry, type PolicyRegistry } from '@meddleware/seal-client'
|
|
7
|
+
import type { SealController } from '@meddleware/seal-client/controller'
|
|
8
|
+
import { getSuiClient } from './wallet.js'
|
|
9
|
+
import { NETWORK, SEAL_PACKAGE_ID, SEAL_THRESHOLD, SEAL_SERVERS } from './config.js'
|
|
10
|
+
|
|
11
|
+
/** The policy registry (nft-gate + time-lock as peers). Iterate `list()` to render the picker. */
|
|
12
|
+
export const registry: PolicyRegistry = createDefaultRegistry()
|
|
13
|
+
|
|
14
|
+
let controller: SealController | null = null
|
|
15
|
+
|
|
16
|
+
/** Lazily build the SealController from build-time config + the shared registry. */
|
|
17
|
+
export async function getSealController(): Promise<SealController> {
|
|
18
|
+
if (!controller) {
|
|
19
|
+
const { SealController } = await import('@meddleware/seal-client/controller')
|
|
20
|
+
controller = new SealController(
|
|
21
|
+
{
|
|
22
|
+
suiClient: getSuiClient(NETWORK),
|
|
23
|
+
packageId: SEAL_PACKAGE_ID,
|
|
24
|
+
threshold: SEAL_THRESHOLD,
|
|
25
|
+
serverConfigs: SEAL_SERVERS,
|
|
26
|
+
},
|
|
27
|
+
registry,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return controller
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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'
|
|
4
|
+
import { getSuiClient } from './wallet.js'
|
|
5
|
+
import { SEAL_PACKAGE_ID } from './config.js'
|
|
6
|
+
|
|
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
|
+
}
|
|
20
|
+
|
|
21
|
+
/** All published pointers whose `gate_id` matches, newest first. */
|
|
22
|
+
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,
|
|
29
|
+
})
|
|
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()
|
|
45
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Sealed Storage standalone-app globals. Colours come from @meddleware/design-tokens.
|
|
2
|
+
*
|
|
3
|
+
* Only genuinely global element styling lives here (body reset, #app centering). The tool UI's
|
|
4
|
+
* component styles (.card, .field, .primary, .tabs, .notice, .manifest, .muted, inputs, …) are
|
|
5
|
+
* SCOPED inside src/components/SealView.vue so the view can be embedded in the dashboard without
|
|
6
|
+
* this stylesheet — importing it globally would restyle the host's body / #app / bare inputs.
|
|
7
|
+
* This file is imported only by the standalone entry (src/main.ts). */
|
|
8
|
+
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
16
|
+
background: var(--bg, #14100f);
|
|
17
|
+
color: var(--text, #f3ece9);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#app {
|
|
21
|
+
max-width: 780px;
|
|
22
|
+
margin: 0 auto;
|
|
23
|
+
padding: 1.5rem 1.25rem 4rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
h1 {
|
|
27
|
+
font-size: 1.4rem;
|
|
28
|
+
margin: 0;
|
|
29
|
+
}
|
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Thin seal-ui shim over the shared @meddleware/wallet-adapter singleton.
|
|
2
|
+
//
|
|
3
|
+
// The adapter is network-agnostic (RPC URL passed per call); this shim binds seal-ui's RPC_URLS
|
|
4
|
+
// so call sites keep the ergonomics (`getSuiClient()` defaulting to the active network,
|
|
5
|
+
// `signAndExecute(tx)`). Because the adapter is a module singleton, the wallet connection is
|
|
6
|
+
// shared with any other tool view rendered in the same window (e.g. the dashboard).
|
|
7
|
+
import {
|
|
8
|
+
useWallet as useWalletBase,
|
|
9
|
+
getSuiClient as getSuiClientBase,
|
|
10
|
+
buildExecutor as buildExecutorBase,
|
|
11
|
+
} from '@meddleware/wallet-adapter'
|
|
12
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
13
|
+
import { NETWORK, RPC_URLS, type Network } from './config.js'
|
|
14
|
+
|
|
15
|
+
/** Memoised Sui JSON-RPC client for the network (defaults to the active network). */
|
|
16
|
+
export function getSuiClient(network: Network = NETWORK) {
|
|
17
|
+
return getSuiClientBase(network, RPC_URLS[network])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Sign a personal message with the connected wallet (mints a Seal SessionKey). */
|
|
21
|
+
export function signPersonalMessage(message: Uint8Array): Promise<{ signature: string }> {
|
|
22
|
+
return useWalletBase().signPersonalMessage(message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Sign + execute a PTB with the connected wallet, returning the transaction digest. */
|
|
26
|
+
export async function signAndExecute(
|
|
27
|
+
tx: Transaction,
|
|
28
|
+
network: Network = NETWORK,
|
|
29
|
+
): Promise<{ digest: string }> {
|
|
30
|
+
const executor = await buildExecutorBase(network, RPC_URLS[network])
|
|
31
|
+
return executor.signAndExecute(tx)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wallet composable bound to seal-ui's network config. Delegates to the shared adapter singleton;
|
|
36
|
+
* Seal needs `sui:signPersonalMessage` (SessionKey) and `sui:signTransaction` (publish pointer),
|
|
37
|
+
* so both are requested for discovery.
|
|
38
|
+
*/
|
|
39
|
+
export function useWallet() {
|
|
40
|
+
const base = useWalletBase({ requiredFeatures: ['sui:signPersonalMessage', 'sui:signTransaction'] })
|
|
41
|
+
return {
|
|
42
|
+
wallets: base.wallets,
|
|
43
|
+
currentWallet: base.currentWallet,
|
|
44
|
+
account: base.account,
|
|
45
|
+
connecting: base.connecting,
|
|
46
|
+
error: base.error,
|
|
47
|
+
connect: base.connect,
|
|
48
|
+
disconnect: base.disconnect,
|
|
49
|
+
signPersonalMessage,
|
|
50
|
+
signAndExecute,
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/walrus.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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'
|
|
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,
|
|
17
|
+
})
|
|
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
|
+
}
|
|
24
|
+
|
|
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())
|
|
30
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "vite.config.ts"]
|
|
18
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
|
|
4
|
+
// @mysten/seal is pure JS (no wasm). Ciphertext storage uses the Walrus HTTP publisher/aggregator
|
|
5
|
+
// (plain fetch), so no @mysten/walrus wasm to exclude here.
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
})
|