@tuskydp/cli 0.1.2 → 0.2.1

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.
Files changed (54) hide show
  1. package/bin/tusky-dev.sh +5 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +32 -2
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/download.d.ts.map +1 -1
  6. package/dist/src/commands/download.js +27 -7
  7. package/dist/src/commands/download.js.map +1 -1
  8. package/dist/src/commands/upload.d.ts.map +1 -1
  9. package/dist/src/commands/upload.js +23 -2
  10. package/dist/src/commands/upload.js.map +1 -1
  11. package/dist/src/commands/vault.d.ts.map +1 -1
  12. package/dist/src/commands/vault.js +145 -7
  13. package/dist/src/commands/vault.js.map +1 -1
  14. package/dist/src/config.d.ts +5 -0
  15. package/dist/src/config.d.ts.map +1 -1
  16. package/dist/src/config.js +7 -0
  17. package/dist/src/config.js.map +1 -1
  18. package/dist/src/index.js +3 -1
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/mcp/context.d.ts +11 -2
  21. package/dist/src/mcp/context.d.ts.map +1 -1
  22. package/dist/src/mcp/context.js +3 -2
  23. package/dist/src/mcp/context.js.map +1 -1
  24. package/dist/src/mcp/server.d.ts.map +1 -1
  25. package/dist/src/mcp/server.js +13 -0
  26. package/dist/src/mcp/server.js.map +1 -1
  27. package/dist/src/mcp/tools/files.d.ts +2 -1
  28. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  29. package/dist/src/mcp/tools/files.js +49 -12
  30. package/dist/src/mcp/tools/files.js.map +1 -1
  31. package/dist/src/mcp/tools/sharedVaults.d.ts +7 -0
  32. package/dist/src/mcp/tools/sharedVaults.d.ts.map +1 -0
  33. package/dist/src/mcp/tools/sharedVaults.js +90 -0
  34. package/dist/src/mcp/tools/sharedVaults.js.map +1 -0
  35. package/dist/src/mcp/tools/vaults.js +1 -1
  36. package/dist/src/mcp/tools/vaults.js.map +1 -1
  37. package/dist/src/seal.d.ts +58 -0
  38. package/dist/src/seal.d.ts.map +1 -0
  39. package/dist/src/seal.js +207 -0
  40. package/dist/src/seal.js.map +1 -0
  41. package/package.json +16 -13
  42. package/src/__tests__/seal.test.ts +357 -0
  43. package/src/commands/account.ts +35 -2
  44. package/src/commands/download.ts +34 -13
  45. package/src/commands/upload.ts +25 -2
  46. package/src/commands/vault.ts +158 -7
  47. package/src/config.ts +8 -0
  48. package/src/index.ts +3 -1
  49. package/src/mcp/context.ts +13 -2
  50. package/src/mcp/server.ts +13 -0
  51. package/src/mcp/tools/files.ts +61 -19
  52. package/src/mcp/tools/sharedVaults.ts +122 -0
  53. package/src/mcp/tools/vaults.ts +2 -2
  54. package/src/seal.ts +266 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * SEAL encryption service for shared vaults (headless/CLI mode).
3
+ *
4
+ * Ported from packages/web/src/lib/seal.ts, adapted for headless use
5
+ * with an Ed25519 keypair instead of a browser wallet signer.
6
+ *
7
+ * Handles client-side encrypt/decrypt using the @mysten/seal SDK.
8
+ * - Encrypt: uses the vault's Whitelist object ID as the identity namespace
9
+ * - Decrypt: creates a SessionKey, signs with Ed25519 keypair, builds seal_approve PTB
10
+ *
11
+ * Requires TUSKYDP_SUI_PRIVATE_KEY env var for decrypt operations.
12
+ * Encrypt only requires a Sui RPC client (no signing needed).
13
+ */
14
+ import { SealClient, SessionKey, EncryptedObject } from '@mysten/seal';
15
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
16
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
17
+ import { Transaction } from '@mysten/sui/transactions';
18
+ import { fromHex } from '@mysten/sui/utils';
19
+ import { getSuiPrivateKey } from './config.js';
20
+ // ---------------------------------------------------------------------------
21
+ // Lazy singletons
22
+ // ---------------------------------------------------------------------------
23
+ let _suiClient = null;
24
+ let _suiKeypair = null;
25
+ let _keypairLoaded = false;
26
+ function getSuiNetwork() {
27
+ const tuskyEnv = process.env.TUSKY_ENV || 'production';
28
+ const rpcOverride = process.env.SUI_RPC_URL;
29
+ const network = tuskyEnv === 'development' ? 'testnet' : 'mainnet';
30
+ const url = rpcOverride || (network === 'testnet'
31
+ ? 'https://fullnode.testnet.sui.io:443'
32
+ : 'https://fullnode.mainnet.sui.io:443');
33
+ return { url, network };
34
+ }
35
+ function getSuiClient() {
36
+ if (!_suiClient) {
37
+ const { url, network } = getSuiNetwork();
38
+ _suiClient = new SuiJsonRpcClient({ url, network });
39
+ }
40
+ return _suiClient;
41
+ }
42
+ /**
43
+ * Load the Sui keypair from TUSKYDP_SUI_PRIVATE_KEY env var.
44
+ * Returns null if the env var is not set.
45
+ * Cached after first call.
46
+ */
47
+ export function getSuiKeypair() {
48
+ if (_keypairLoaded)
49
+ return _suiKeypair;
50
+ _keypairLoaded = true;
51
+ const privateKey = getSuiPrivateKey();
52
+ if (!privateKey)
53
+ return null;
54
+ try {
55
+ _suiKeypair = Ed25519Keypair.fromSecretKey(privateKey);
56
+ }
57
+ catch {
58
+ console.error('Invalid TUSKYDP_SUI_PRIVATE_KEY — could not load Ed25519 keypair.');
59
+ return null;
60
+ }
61
+ return _suiKeypair;
62
+ }
63
+ /**
64
+ * Get the Sui address for the loaded keypair, or null.
65
+ */
66
+ export function getSuiAddress() {
67
+ const kp = getSuiKeypair();
68
+ return kp ? kp.toSuiAddress() : null;
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // SealClient factory
72
+ // ---------------------------------------------------------------------------
73
+ const _sealClients = new Map();
74
+ function getSealClient(serverConfigs) {
75
+ const cacheKey = serverConfigs.map((c) => c.objectId).join(',');
76
+ let client = _sealClients.get(cacheKey);
77
+ if (!client) {
78
+ client = new SealClient({
79
+ suiClient: getSuiClient(),
80
+ serverConfigs,
81
+ verifyKeyServers: true,
82
+ });
83
+ _sealClients.set(cacheKey, client);
84
+ }
85
+ return client;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Identity helpers
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * Build SEAL identity bytes for a file in a shared vault.
92
+ * Format: [whitelist_object_id_bytes][file_nonce_hex]
93
+ * Must match the web implementation in packages/web/src/lib/seal.ts.
94
+ */
95
+ export function buildSealId(whitelistObjectId, fileNonce) {
96
+ const wlBytes = whitelistObjectId.replace(/^0x/, '');
97
+ const nonceHex = Array.from(new TextEncoder().encode(fileNonce))
98
+ .map((b) => b.toString(16).padStart(2, '0'))
99
+ .join('');
100
+ return wlBytes + nonceHex;
101
+ }
102
+ /**
103
+ * Check if a vault is SEAL-configured (has the necessary on-chain objects).
104
+ */
105
+ export function isSealConfigured(vault) {
106
+ return !!(vault.visibility === 'shared' &&
107
+ vault.sealAllowlistObjectId &&
108
+ vault.sealPackageId &&
109
+ vault.sealKeyServerIds?.length);
110
+ }
111
+ /**
112
+ * Encrypt file data using SEAL for a shared vault.
113
+ *
114
+ * Does NOT require a Sui keypair — encryption is purely a client-side
115
+ * operation using the SEAL key servers and vault's allowlist identity.
116
+ */
117
+ export async function sealEncrypt(data, vault, fileNonce) {
118
+ if (!vault.sealAllowlistObjectId || !vault.sealPackageId) {
119
+ throw new Error('Vault is not configured for SEAL encryption (missing allowlist or package ID)');
120
+ }
121
+ const threshold = vault.sealThreshold ?? 1;
122
+ const serverConfigs = (vault.sealKeyServerIds ?? []).map((id) => ({
123
+ objectId: id,
124
+ weight: 1,
125
+ }));
126
+ if (serverConfigs.length === 0) {
127
+ throw new Error('No SEAL key servers configured for this vault');
128
+ }
129
+ const sealClient = getSealClient(serverConfigs);
130
+ const sealIdentity = buildSealId(vault.sealAllowlistObjectId, fileNonce);
131
+ const { encryptedObject } = await sealClient.encrypt({
132
+ threshold,
133
+ packageId: vault.sealPackageId,
134
+ id: sealIdentity,
135
+ data,
136
+ });
137
+ // Store compact metadata for the API (same format as web app).
138
+ // The full BCS-encoded EncryptedObject (ciphertext) goes to S3.
139
+ const sealEncryptedObject = Buffer.from(JSON.stringify({
140
+ id: sealIdentity,
141
+ packageId: vault.sealPackageId,
142
+ threshold,
143
+ })).toString('base64');
144
+ return {
145
+ encryptedData: encryptedObject,
146
+ sealIdentity,
147
+ sealEncryptedObject,
148
+ };
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Decrypt
152
+ // ---------------------------------------------------------------------------
153
+ const SESSION_TTL_MIN = 10;
154
+ /**
155
+ * Decrypt SEAL-encrypted file data.
156
+ *
157
+ * Requires a Sui keypair (TUSKYDP_SUI_PRIVATE_KEY) to sign the SessionKey
158
+ * and authorize decryption through the SEAL key servers.
159
+ */
160
+ export async function sealDecrypt(encryptedData, vault, keypair) {
161
+ if (!vault.sealPackageId || !vault.sealAllowlistObjectId) {
162
+ throw new Error('Vault is not configured for SEAL decryption');
163
+ }
164
+ const serverConfigs = (vault.sealKeyServerIds ?? []).map((id) => ({
165
+ objectId: id,
166
+ weight: 1,
167
+ }));
168
+ const suiClient = getSuiClient();
169
+ const sealClient = getSealClient(serverConfigs);
170
+ const userAddress = keypair.toSuiAddress();
171
+ // Parse the encrypted object to extract the identity
172
+ const encryptedObject = EncryptedObject.parse(encryptedData);
173
+ // Create a session key — in CLI mode we create a fresh one each time
174
+ // (unlike the web app which caches per vault for 10 minutes).
175
+ const sessionKey = await SessionKey.create({
176
+ address: userAddress,
177
+ packageId: vault.sealPackageId,
178
+ ttlMin: SESSION_TTL_MIN,
179
+ suiClient: suiClient,
180
+ });
181
+ // Sign the session key with the Ed25519 keypair
182
+ const personalMessage = sessionKey.getPersonalMessage();
183
+ const { signature } = await keypair.signPersonalMessage(personalMessage);
184
+ await sessionKey.setPersonalMessageSignature(signature);
185
+ // Build the seal_approve transaction kind bytes
186
+ const tx = new Transaction();
187
+ tx.setSender(userAddress);
188
+ tx.moveCall({
189
+ target: `${vault.sealPackageId}::shared_vault::seal_approve`,
190
+ arguments: [
191
+ tx.pure.vector('u8', Array.from(fromHex(encryptedObject.id))),
192
+ tx.object(vault.sealAllowlistObjectId),
193
+ ],
194
+ });
195
+ const txBytes = await tx.build({
196
+ client: suiClient,
197
+ onlyTransactionKind: true,
198
+ });
199
+ // Decrypt via SEAL key servers
200
+ const decrypted = await sealClient.decrypt({
201
+ data: encryptedData,
202
+ sessionKey,
203
+ txBytes,
204
+ });
205
+ return decrypted;
206
+ }
207
+ //# sourceMappingURL=seal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seal.js","sourceRoot":"","sources":["../../src/seal.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,21 +1,15 @@
1
1
  {
2
2
  "name": "@tuskydp/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
- "tusky": "./dist/bin/tuskydp.js"
7
- },
8
- "scripts": {
9
- "dev": "tsx src/index.ts",
10
- "build": "tsc",
11
- "typecheck": "tsc --noEmit",
12
- "test": "vitest run",
13
- "test:watch": "vitest"
6
+ "tusky": "./dist/bin/tuskydp.js",
7
+ "tusky-dev": "./bin/tusky-dev.sh"
14
8
  },
15
9
  "dependencies": {
16
10
  "@modelcontextprotocol/sdk": "^1.27.1",
17
- "@tuskydp/sdk": "0.1.2",
18
- "@tuskydp/shared": "0.1.2",
11
+ "@mysten/seal": "^1.1.1",
12
+ "@mysten/sui": "^2.9.0",
19
13
  "blessed": "^0.1.81",
20
14
  "chalk": "^5.4.1",
21
15
  "cli-table3": "^0.6.0",
@@ -24,7 +18,9 @@
24
18
  "inquirer": "^12.0.0",
25
19
  "mime-types": "^3.0.0",
26
20
  "ora": "^8.0.0",
27
- "zod": "3"
21
+ "zod": "3",
22
+ "@tuskydp/sdk": "0.2.0",
23
+ "@tuskydp/shared": "0.2.0"
28
24
  },
29
25
  "devDependencies": {
30
26
  "@types/blessed": "^0.1.27",
@@ -32,5 +28,12 @@
32
28
  "@types/node": "^22.0.0",
33
29
  "tsx": "^4.0.0",
34
30
  "typescript": "^5.6.0"
31
+ },
32
+ "scripts": {
33
+ "dev": "tsx src/index.ts",
34
+ "build": "tsc",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
35
38
  }
36
- }
39
+ }
@@ -0,0 +1,357 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { buildSealId, isSealConfigured, getSuiKeypair, getSuiAddress } from '../seal.js';
3
+ import type { VaultResponse, EncryptionInfo } from '@tuskydp/sdk';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // buildSealId
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('buildSealId', () => {
10
+ it('concatenates whitelist object ID (without 0x) and nonce hex', () => {
11
+ const whitelistObjectId = '0xabcdef1234567890';
12
+ const fileNonce = 'test';
13
+ const result = buildSealId(whitelistObjectId, fileNonce);
14
+
15
+ // 'test' in hex: 74 65 73 74
16
+ expect(result).toBe('abcdef123456789074657374');
17
+ });
18
+
19
+ it('strips 0x prefix from whitelist object ID', () => {
20
+ const result = buildSealId('0x1234', 'a');
21
+ expect(result.startsWith('1234')).toBe(true);
22
+ expect(result).not.toContain('0x');
23
+ });
24
+
25
+ it('handles whitelist ID without 0x prefix', () => {
26
+ const result = buildSealId('aabb', 'x');
27
+ // 'x' = 0x78
28
+ expect(result).toBe('aabb78');
29
+ });
30
+
31
+ it('handles empty nonce', () => {
32
+ const result = buildSealId('0xaabb', '');
33
+ expect(result).toBe('aabb');
34
+ });
35
+
36
+ it('handles UUID nonce', () => {
37
+ const nonce = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
38
+ const result = buildSealId('0x1234', nonce);
39
+ expect(result.startsWith('1234')).toBe(true);
40
+ // UUID is 36 chars, each encoded as 2-char hex = 72 hex chars + '1234' prefix = 76
41
+ expect(result.length).toBe(4 + 36 * 2);
42
+ });
43
+
44
+ it('matches web implementation format', () => {
45
+ // Verify the encoding matches the web app's buildSealId:
46
+ // nonceHex = Array.from(new TextEncoder().encode(fileNonce))
47
+ // .map(b => b.toString(16).padStart(2, '0')).join('')
48
+ const nonce = 'ABC';
49
+ const result = buildSealId('0xff', nonce);
50
+ // 'A'=0x41, 'B'=0x42, 'C'=0x43
51
+ expect(result).toBe('ff414243');
52
+ });
53
+
54
+ it('produces deterministic output', () => {
55
+ const a = buildSealId('0x1234', 'hello');
56
+ const b = buildSealId('0x1234', 'hello');
57
+ expect(a).toBe(b);
58
+ });
59
+
60
+ it('produces different output for different nonces', () => {
61
+ const a = buildSealId('0x1234', 'nonce-1');
62
+ const b = buildSealId('0x1234', 'nonce-2');
63
+ expect(a).not.toBe(b);
64
+ });
65
+
66
+ it('produces different output for different whitelist IDs', () => {
67
+ const a = buildSealId('0x1111', 'nonce');
68
+ const b = buildSealId('0x2222', 'nonce');
69
+ expect(a).not.toBe(b);
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // isSealConfigured
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe('isSealConfigured', () => {
78
+ const baseVault: VaultResponse = {
79
+ id: 'vault-1',
80
+ name: 'test',
81
+ slug: 'test',
82
+ description: null,
83
+ visibility: 'shared',
84
+ isDefault: false,
85
+ fileCount: 0,
86
+ totalSizeBytes: 0,
87
+ sealAllowlistObjectId: '0xabc',
88
+ sealPackageId: '0xpkg',
89
+ sealKeyServerIds: ['0xserver1'],
90
+ sealThreshold: 1,
91
+ createdAt: '2025-01-01T00:00:00Z',
92
+ updatedAt: '2025-01-01T00:00:00Z',
93
+ deletedAt: null,
94
+ };
95
+
96
+ it('returns true for fully configured shared vault', () => {
97
+ expect(isSealConfigured(baseVault)).toBe(true);
98
+ });
99
+
100
+ it('returns true with multiple key servers', () => {
101
+ expect(isSealConfigured({
102
+ ...baseVault,
103
+ sealKeyServerIds: ['0xserver1', '0xserver2', '0xserver3'],
104
+ sealThreshold: 2,
105
+ })).toBe(true);
106
+ });
107
+
108
+ it('returns false for private vault', () => {
109
+ expect(isSealConfigured({ ...baseVault, visibility: 'private' })).toBe(false);
110
+ });
111
+
112
+ it('returns false for public vault', () => {
113
+ expect(isSealConfigured({ ...baseVault, visibility: 'public' })).toBe(false);
114
+ });
115
+
116
+ it('returns false if sealAllowlistObjectId is missing', () => {
117
+ expect(isSealConfigured({ ...baseVault, sealAllowlistObjectId: null })).toBe(false);
118
+ });
119
+
120
+ it('returns false if sealAllowlistObjectId is undefined', () => {
121
+ expect(isSealConfigured({ ...baseVault, sealAllowlistObjectId: undefined })).toBe(false);
122
+ });
123
+
124
+ it('returns false if sealPackageId is missing', () => {
125
+ expect(isSealConfigured({ ...baseVault, sealPackageId: null })).toBe(false);
126
+ });
127
+
128
+ it('returns false if sealKeyServerIds is empty', () => {
129
+ expect(isSealConfigured({ ...baseVault, sealKeyServerIds: [] })).toBe(false);
130
+ });
131
+
132
+ it('returns false if sealKeyServerIds is null', () => {
133
+ expect(isSealConfigured({ ...baseVault, sealKeyServerIds: null })).toBe(false);
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // getSuiKeypair / getSuiAddress
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe('getSuiKeypair', () => {
142
+ const originalEnv = process.env;
143
+
144
+ beforeEach(() => {
145
+ process.env = { ...originalEnv };
146
+ });
147
+
148
+ afterEach(() => {
149
+ process.env = originalEnv;
150
+ });
151
+
152
+ // getSuiKeypair uses a module-level cache. We test the exported helpers
153
+ // since we can't easily reset module state between tests.
154
+
155
+ it('returns null when TUSKYDP_SUI_PRIVATE_KEY is not set', async () => {
156
+ delete process.env.TUSKYDP_SUI_PRIVATE_KEY;
157
+ const mod = await import('../seal.js');
158
+ const addr = mod.getSuiAddress();
159
+ if (addr === null) {
160
+ expect(addr).toBeNull();
161
+ } else {
162
+ expect(typeof addr).toBe('string');
163
+ }
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // EncryptionInfo discriminated union (SDK type tests)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe('EncryptionInfo type discrimination', () => {
172
+ it('narrows passphrase encryption correctly', () => {
173
+ const enc: EncryptionInfo = {
174
+ type: 'passphrase',
175
+ encrypted: true,
176
+ wrappedKey: 'abc123',
177
+ iv: 'def456',
178
+ plaintextSizeBytes: 1024,
179
+ plaintextChecksumSha256: 'sha256hash',
180
+ };
181
+
182
+ expect(enc.encrypted).toBe(true);
183
+ if (enc.encrypted && 'type' in enc && enc.type === 'passphrase') {
184
+ expect(enc.wrappedKey).toBe('abc123');
185
+ expect(enc.iv).toBe('def456');
186
+ expect(enc.plaintextSizeBytes).toBe(1024);
187
+ expect(enc.plaintextChecksumSha256).toBe('sha256hash');
188
+ }
189
+ });
190
+
191
+ it('narrows SEAL encryption correctly', () => {
192
+ const enc: EncryptionInfo = {
193
+ type: 'seal',
194
+ encrypted: true,
195
+ sealIdentity: 'identity123',
196
+ sealEncryptedObject: 'base64meta',
197
+ };
198
+
199
+ expect(enc.encrypted).toBe(true);
200
+ if (enc.encrypted && 'type' in enc && enc.type === 'seal') {
201
+ expect(enc.sealIdentity).toBe('identity123');
202
+ expect(enc.sealEncryptedObject).toBe('base64meta');
203
+ }
204
+ });
205
+
206
+ it('narrows unencrypted correctly', () => {
207
+ const enc: EncryptionInfo = { encrypted: false };
208
+ expect(enc.encrypted).toBe(false);
209
+ });
210
+
211
+ it('type guard works for download dispatch pattern', () => {
212
+ // This tests the exact pattern used in download.ts and MCP files.ts
213
+ const sealEnc: EncryptionInfo = {
214
+ type: 'seal',
215
+ encrypted: true,
216
+ sealIdentity: 'id1',
217
+ sealEncryptedObject: 'obj1',
218
+ };
219
+
220
+ const passphraseEnc: EncryptionInfo = {
221
+ type: 'passphrase',
222
+ encrypted: true,
223
+ wrappedKey: 'key1',
224
+ iv: 'iv1',
225
+ plaintextSizeBytes: 100,
226
+ plaintextChecksumSha256: null,
227
+ };
228
+
229
+ const noEnc: EncryptionInfo = { encrypted: false };
230
+
231
+ // Test the dispatch pattern
232
+ function getEncType(enc: EncryptionInfo): string {
233
+ if (!enc.encrypted) return 'none';
234
+ if ('type' in enc && enc.type === 'seal') return 'seal';
235
+ return 'passphrase';
236
+ }
237
+
238
+ expect(getEncType(sealEnc)).toBe('seal');
239
+ expect(getEncType(passphraseEnc)).toBe('passphrase');
240
+ expect(getEncType(noEnc)).toBe('none');
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Upload branching logic (unit-level)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe('upload encryption branching', () => {
249
+ // These tests verify the conditional logic that determines which
250
+ // encryption path to take, extracted from upload.ts/MCP files.ts
251
+
252
+ const sharedVault: VaultResponse = {
253
+ id: 'v-shared',
254
+ name: 'Shared Vault',
255
+ slug: 'shared-vault',
256
+ description: null,
257
+ visibility: 'shared',
258
+ isDefault: false,
259
+ fileCount: 0,
260
+ totalSizeBytes: 0,
261
+ sealAllowlistObjectId: '0xallowlist',
262
+ sealPackageId: '0xpackage',
263
+ sealKeyServerIds: ['0xserver1'],
264
+ sealThreshold: 1,
265
+ createdAt: '2025-01-01T00:00:00Z',
266
+ updatedAt: '2025-01-01T00:00:00Z',
267
+ deletedAt: null,
268
+ };
269
+
270
+ const privateVault: VaultResponse = {
271
+ ...sharedVault,
272
+ id: 'v-private',
273
+ name: 'Private Vault',
274
+ slug: 'private-vault',
275
+ visibility: 'private',
276
+ sealAllowlistObjectId: null,
277
+ sealPackageId: null,
278
+ sealKeyServerIds: null,
279
+ sealThreshold: null,
280
+ };
281
+
282
+ const publicVault: VaultResponse = {
283
+ ...privateVault,
284
+ id: 'v-public',
285
+ name: 'Public Vault',
286
+ slug: 'public-vault',
287
+ visibility: 'public',
288
+ };
289
+
290
+ const sharedVaultNoSeal: VaultResponse = {
291
+ ...sharedVault,
292
+ id: 'v-shared-noseal',
293
+ sealAllowlistObjectId: null,
294
+ sealPackageId: null,
295
+ sealKeyServerIds: null,
296
+ sealThreshold: null,
297
+ };
298
+
299
+ function getEncryptionPath(vault: VaultResponse): 'passphrase' | 'seal' | 'none' {
300
+ const isPrivate = vault.visibility === 'private';
301
+ const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
302
+ if (isPrivate) return 'passphrase';
303
+ if (isShared) return 'seal';
304
+ return 'none';
305
+ }
306
+
307
+ it('routes private vault to passphrase encryption', () => {
308
+ expect(getEncryptionPath(privateVault)).toBe('passphrase');
309
+ });
310
+
311
+ it('routes shared vault with SEAL config to SEAL encryption', () => {
312
+ expect(getEncryptionPath(sharedVault)).toBe('seal');
313
+ });
314
+
315
+ it('routes public vault to no encryption', () => {
316
+ expect(getEncryptionPath(publicVault)).toBe('none');
317
+ });
318
+
319
+ it('routes shared vault without SEAL config to no encryption', () => {
320
+ // A shared vault that hasn't been configured with SEAL on-chain
321
+ // should not attempt SEAL encryption
322
+ expect(getEncryptionPath(sharedVaultNoSeal)).toBe('none');
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // sealEncryptedObject metadata format
328
+ // ---------------------------------------------------------------------------
329
+
330
+ describe('SEAL metadata format', () => {
331
+ it('sealEncryptedObject is base64-encoded JSON with id, packageId, threshold', () => {
332
+ // The format produced by sealEncrypt and expected by the API
333
+ const metadata = {
334
+ id: 'abc123identity',
335
+ packageId: '0xpackage',
336
+ threshold: 1,
337
+ };
338
+ const encoded = Buffer.from(JSON.stringify(metadata)).toString('base64');
339
+ const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8'));
340
+
341
+ expect(decoded.id).toBe('abc123identity');
342
+ expect(decoded.packageId).toBe('0xpackage');
343
+ expect(decoded.threshold).toBe(1);
344
+ });
345
+
346
+ it('metadata round-trips through base64', () => {
347
+ const original = {
348
+ id: buildSealId('0xdeadbeef', 'test-nonce'),
349
+ packageId: '0x10665bec0c95576917bed949bfe978d97e7e9986e10040d7104b4fd3a47b0736',
350
+ threshold: 1,
351
+ };
352
+ const b64 = Buffer.from(JSON.stringify(original)).toString('base64');
353
+ const restored = JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
354
+
355
+ expect(restored).toEqual(original);
356
+ });
357
+ });
@@ -4,6 +4,13 @@ import { cliConfig, getApiUrl, getApiKey } from '../config.js';
4
4
  import { createSDKClient, AuthClient } from '../sdk.js';
5
5
  import { formatBytes } from '../lib/output.js';
6
6
 
7
+ /** Map vault visibility to a display label. */
8
+ function visibilityIcon(v: string): string {
9
+ if (v === 'private') return '[private]';
10
+ if (v === 'shared') return '[shared]';
11
+ return '[public]';
12
+ }
13
+
7
14
  export function registerAccountCommands(program: Command) {
8
15
  const account = program.command('account').description('Account management');
9
16
 
@@ -29,6 +36,9 @@ export function registerAccountCommands(program: Command) {
29
36
  console.log(`Plan: ${acct.planName}`);
30
37
  console.log(`Storage Used: ${acct.storageUsedFormatted} / ${acct.storageLimitFormatted} (${usagePct}%)`);
31
38
  console.log(`Encryption: ${acct.encryptionSetupComplete ? chalk.green('Set up') : chalk.yellow('Not set up')}`);
39
+ if ((acct as any).suiAddress) {
40
+ console.log(`Sui Address: ${(acct as any).suiAddress}`);
41
+ }
32
42
  });
33
43
 
34
44
  account.command('plan')
@@ -67,13 +77,36 @@ export function registerAccountCommands(program: Command) {
67
77
 
68
78
  console.log(chalk.bold('Storage Usage by Vault:\n'));
69
79
  for (const v of vaults) {
70
- const icon = v.visibility === 'private' ? '[private]' : '[public]';
71
- console.log(` ${v.name} ${icon} — ${v.fileCount} files, ${formatBytes(v.totalSizeBytes)}`);
80
+ console.log(` ${v.name} ${visibilityIcon(v.visibility)} ${v.fileCount} files, ${formatBytes(v.totalSizeBytes)}`);
72
81
  }
73
82
  console.log('');
74
83
  console.log(` Total: ${formatBytes(acct.storageUsedBytes)} / ${formatBytes(acct.storageLimitBytes)}`);
75
84
  });
76
85
 
86
+ // ── link-sui ────────────────────────────────────────────────────────
87
+ account.command('link-sui <sui-address>')
88
+ .description('Link a Sui wallet address to your account (required for shared vaults)')
89
+ .action(async (suiAddress: string) => {
90
+ const apiUrl = getApiUrl(program.opts().apiUrl);
91
+ const apiKey = getApiKey(program.opts().apiKey);
92
+ const sdk = createSDKClient(apiUrl, apiKey);
93
+
94
+ await sdk.sharedVaults.linkSuiAddress(suiAddress);
95
+ console.log(chalk.green(`Sui address ${suiAddress} linked to account.`));
96
+ });
97
+
98
+ // ── unlink-sui ──────────────────────────────────────────────────────
99
+ account.command('unlink-sui')
100
+ .description('Unlink your Sui wallet address')
101
+ .action(async () => {
102
+ const apiUrl = getApiUrl(program.opts().apiUrl);
103
+ const apiKey = getApiKey(program.opts().apiKey);
104
+ const sdk = createSDKClient(apiUrl, apiKey);
105
+
106
+ await sdk.sharedVaults.unlinkSuiAddress();
107
+ console.log(chalk.green('Sui address unlinked from account.'));
108
+ });
109
+
77
110
  // Default action for `tuskydp account` with no subcommand
78
111
  account.action(async () => {
79
112
  // Run `info` subcommand