@tuskydp/cli 0.2.1 → 0.4.0

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 (160) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +0 -1
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/auth.d.ts.map +1 -1
  6. package/dist/src/commands/auth.js +8 -5
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/download.d.ts +1 -0
  9. package/dist/src/commands/download.d.ts.map +1 -1
  10. package/dist/src/commands/download.js +35 -22
  11. package/dist/src/commands/download.js.map +1 -1
  12. package/dist/src/commands/export.d.ts +9 -24
  13. package/dist/src/commands/export.d.ts.map +1 -1
  14. package/dist/src/commands/export.js +31 -59
  15. package/dist/src/commands/export.js.map +1 -1
  16. package/dist/src/commands/files.d.ts.map +1 -1
  17. package/dist/src/commands/files.js +91 -12
  18. package/dist/src/commands/files.js.map +1 -1
  19. package/dist/src/commands/folder.d.ts +3 -0
  20. package/dist/src/commands/folder.d.ts.map +1 -0
  21. package/dist/src/commands/folder.js +151 -0
  22. package/dist/src/commands/folder.js.map +1 -0
  23. package/dist/src/commands/mcp.d.ts.map +1 -1
  24. package/dist/src/commands/mcp.js +15 -9
  25. package/dist/src/commands/mcp.js.map +1 -1
  26. package/dist/src/commands/rehydrate.d.ts +1 -0
  27. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  28. package/dist/src/commands/rehydrate.js +15 -7
  29. package/dist/src/commands/rehydrate.js.map +1 -1
  30. package/dist/src/commands/sui.d.ts +3 -0
  31. package/dist/src/commands/sui.d.ts.map +1 -0
  32. package/dist/src/commands/sui.js +64 -0
  33. package/dist/src/commands/sui.js.map +1 -0
  34. package/dist/src/commands/trash.d.ts +3 -0
  35. package/dist/src/commands/trash.d.ts.map +1 -0
  36. package/dist/src/commands/trash.js +109 -0
  37. package/dist/src/commands/trash.js.map +1 -0
  38. package/dist/src/commands/upload.d.ts +4 -0
  39. package/dist/src/commands/upload.d.ts.map +1 -1
  40. package/dist/src/commands/upload.js +82 -27
  41. package/dist/src/commands/upload.js.map +1 -1
  42. package/dist/src/commands/vault.d.ts.map +1 -1
  43. package/dist/src/commands/vault.js +2 -24
  44. package/dist/src/commands/vault.js.map +1 -1
  45. package/dist/src/commands/wallet.d.ts +3 -0
  46. package/dist/src/commands/wallet.d.ts.map +1 -0
  47. package/dist/src/commands/wallet.js +126 -0
  48. package/dist/src/commands/wallet.js.map +1 -0
  49. package/dist/src/commands/webhook.d.ts +3 -0
  50. package/dist/src/commands/webhook.d.ts.map +1 -0
  51. package/dist/src/commands/webhook.js +172 -0
  52. package/dist/src/commands/webhook.js.map +1 -0
  53. package/dist/src/config.d.ts +2 -2
  54. package/dist/src/config.d.ts.map +1 -1
  55. package/dist/src/config.js +2 -3
  56. package/dist/src/config.js.map +1 -1
  57. package/dist/src/index.js +19 -9
  58. package/dist/src/index.js.map +1 -1
  59. package/dist/src/lib/resolve.d.ts.map +1 -1
  60. package/dist/src/lib/resolve.js +4 -5
  61. package/dist/src/lib/resolve.js.map +1 -1
  62. package/dist/src/mcp/context.d.ts +1 -9
  63. package/dist/src/mcp/context.d.ts.map +1 -1
  64. package/dist/src/mcp/context.js +1 -2
  65. package/dist/src/mcp/context.js.map +1 -1
  66. package/dist/src/mcp/server.d.ts.map +1 -1
  67. package/dist/src/mcp/server.js +2 -59
  68. package/dist/src/mcp/server.js.map +1 -1
  69. package/dist/src/mcp/tools/account.d.ts.map +1 -1
  70. package/dist/src/mcp/tools/account.js +1 -3
  71. package/dist/src/mcp/tools/account.js.map +1 -1
  72. package/dist/src/mcp/tools/files.d.ts +2 -3
  73. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  74. package/dist/src/mcp/tools/files.js +46 -49
  75. package/dist/src/mcp/tools/files.js.map +1 -1
  76. package/dist/src/mcp/tools/vaults.js +2 -2
  77. package/dist/src/mcp/tools/vaults.js.map +1 -1
  78. package/dist/src/seal.d.ts +16 -0
  79. package/dist/src/seal.d.ts.map +1 -1
  80. package/dist/src/seal.js +23 -0
  81. package/dist/src/seal.js.map +1 -1
  82. package/dist/src/tui/files-panel.d.ts +31 -2
  83. package/dist/src/tui/files-panel.d.ts.map +1 -1
  84. package/dist/src/tui/files-panel.js +119 -13
  85. package/dist/src/tui/files-panel.js.map +1 -1
  86. package/dist/src/tui/index.d.ts.map +1 -1
  87. package/dist/src/tui/index.js +252 -48
  88. package/dist/src/tui/index.js.map +1 -1
  89. package/dist/src/tui/overview.d.ts.map +1 -1
  90. package/dist/src/tui/overview.js +21 -9
  91. package/dist/src/tui/overview.js.map +1 -1
  92. package/dist/src/tui/trash-screen.d.ts +4 -0
  93. package/dist/src/tui/trash-screen.d.ts.map +1 -0
  94. package/dist/src/tui/trash-screen.js +190 -0
  95. package/dist/src/tui/trash-screen.js.map +1 -0
  96. package/dist/src/tui/vaults-panel.d.ts +8 -0
  97. package/dist/src/tui/vaults-panel.d.ts.map +1 -1
  98. package/dist/src/tui/vaults-panel.js +45 -6
  99. package/dist/src/tui/vaults-panel.js.map +1 -1
  100. package/dist/src/version.d.ts +2 -0
  101. package/dist/src/version.d.ts.map +1 -0
  102. package/dist/src/version.js +21 -0
  103. package/dist/src/version.js.map +1 -0
  104. package/package.json +3 -3
  105. package/src/__tests__/seal.test.ts +7 -54
  106. package/src/commands/account.ts +0 -1
  107. package/src/commands/auth.ts +7 -5
  108. package/src/commands/download.ts +38 -28
  109. package/src/commands/export.ts +37 -81
  110. package/src/commands/files.ts +95 -11
  111. package/src/commands/folder.ts +169 -0
  112. package/src/commands/mcp.ts +16 -10
  113. package/src/commands/rehydrate.ts +15 -8
  114. package/src/commands/sui.ts +69 -0
  115. package/src/commands/trash.ts +121 -0
  116. package/src/commands/upload.ts +98 -31
  117. package/src/commands/vault.ts +2 -23
  118. package/src/commands/wallet.ts +183 -0
  119. package/src/commands/webhook.ts +193 -0
  120. package/src/config.ts +3 -4
  121. package/src/index.ts +19 -10
  122. package/src/lib/resolve.ts +3 -4
  123. package/src/mcp/context.ts +1 -11
  124. package/src/mcp/server.ts +2 -70
  125. package/src/mcp/tools/account.ts +1 -3
  126. package/src/mcp/tools/files.ts +50 -63
  127. package/src/mcp/tools/vaults.ts +3 -3
  128. package/src/seal.ts +34 -1
  129. package/src/tui/files-panel.ts +140 -14
  130. package/src/tui/index.ts +264 -52
  131. package/src/tui/overview.ts +20 -9
  132. package/src/tui/trash-screen.ts +203 -0
  133. package/src/tui/vaults-panel.ts +55 -6
  134. package/src/version.ts +21 -0
  135. package/vitest.config.ts +1 -0
  136. package/dist/src/client.d.ts +0 -120
  137. package/dist/src/client.d.ts.map +0 -1
  138. package/dist/src/client.js +0 -152
  139. package/dist/src/client.js.map +0 -1
  140. package/dist/src/commands/decrypt.d.ts +0 -15
  141. package/dist/src/commands/decrypt.d.ts.map +0 -1
  142. package/dist/src/commands/decrypt.js +0 -224
  143. package/dist/src/commands/decrypt.js.map +0 -1
  144. package/dist/src/commands/encryption.d.ts +0 -3
  145. package/dist/src/commands/encryption.d.ts.map +0 -1
  146. package/dist/src/commands/encryption.js +0 -254
  147. package/dist/src/commands/encryption.js.map +0 -1
  148. package/dist/src/crypto.d.ts +0 -16
  149. package/dist/src/crypto.d.ts.map +0 -1
  150. package/dist/src/crypto.js +0 -95
  151. package/dist/src/crypto.js.map +0 -1
  152. package/dist/src/lib/keyring.d.ts +0 -4
  153. package/dist/src/lib/keyring.d.ts.map +0 -1
  154. package/dist/src/lib/keyring.js +0 -49
  155. package/dist/src/lib/keyring.js.map +0 -1
  156. package/src/__tests__/crypto.test.ts +0 -315
  157. package/src/commands/decrypt.ts +0 -276
  158. package/src/commands/encryption.ts +0 -305
  159. package/src/crypto.ts +0 -130
  160. package/src/lib/keyring.ts +0 -50
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * MCP tools — File operations
3
3
  *
4
- * Handles upload with client-side encryption for private vaults,
5
- * SEAL encryption for shared vaults, download with decryption and
6
- * rehydration polling for cold files.
4
+ * Handles upload with SEAL encryption for shared vaults, download
5
+ * with decryption and rehydration polling for cold files.
7
6
  */
8
7
 
9
8
  import { z } from 'zod';
@@ -13,8 +12,7 @@ import { basename, resolve, join } from 'path';
13
12
  import { lookup } from 'mime-types';
14
13
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
14
  import type { McpContext } from '../context.js';
16
- import { encryptBuffer, decryptBuffer } from '../../crypto.js';
17
- import { isSealConfigured, sealEncrypt, sealDecrypt } from '../../seal.js';
15
+ import { isSealConfigured, sealEncrypt, sealDecrypt, sealEncryptMetadata, sealDecryptMetadata } from '../../seal.js';
18
16
  import { wrapToolError } from './helpers.js';
19
17
 
20
18
  // ---------------------------------------------------------------------------
@@ -79,10 +77,9 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
79
77
  const arrayBuf = await response.arrayBuffer();
80
78
  let fileBuffer: Buffer = Buffer.from(arrayBuf as ArrayBuffer);
81
79
 
82
- // Decrypt if needed — dispatch on encryption type
80
+ // Decrypt if needed — SEAL-encrypted (shared vault)
83
81
  if (encryption?.encrypted) {
84
82
  if ('type' in encryption && encryption.type === 'seal') {
85
- // SEAL-encrypted (shared vault)
86
83
  const keypair = ctx.getSuiKeypair();
87
84
  if (!keypair) {
88
85
  throw new Error(
@@ -94,23 +91,6 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
94
91
  const vault = await ctx.sdk.vaults.get(fileInfo.vaultId);
95
92
  const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
96
93
  fileBuffer = Buffer.from(decrypted);
97
- } else {
98
- // Passphrase-encrypted (private vault)
99
- const masterKey = ctx.getMasterKey();
100
- if (!masterKey) {
101
- throw new Error(
102
- 'Encryption passphrase required to decrypt this file. ' +
103
- 'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
104
- );
105
- }
106
- const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
107
- fileBuffer = decryptBuffer(
108
- fileBuffer,
109
- enc.wrappedKey,
110
- enc.iv,
111
- masterKey,
112
- enc.plaintextChecksumSha256 ?? undefined,
113
- );
114
94
  }
115
95
  }
116
96
 
@@ -118,7 +98,33 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
118
98
  }
119
99
 
120
100
  /**
121
- * Encrypt (if private vault) and upload a buffer to Tusky.
101
+ * Resolve the real filename from a FileStatusResponse.
102
+ * Decrypts encryptedMetadata (shared vaults) when present.
103
+ * Falls back to the stored placeholder name on failure.
104
+ */
105
+ async function resolveFilename(
106
+ fileInfo: { name: string; nameEncrypted?: boolean; encryptedMetadata?: string | null; fileId?: string },
107
+ ctx: McpContext,
108
+ ): Promise<string> {
109
+ if (!fileInfo.nameEncrypted) return fileInfo.name;
110
+ try {
111
+ if (fileInfo.encryptedMetadata && fileInfo.fileId) {
112
+ const keypair = ctx.getSuiKeypair();
113
+ if (keypair) {
114
+ const fileObj = await ctx.sdk.files.get(fileInfo.fileId);
115
+ const vault = await ctx.sdk.vaults.get(fileObj.vaultId);
116
+ const meta = await sealDecryptMetadata(fileInfo.encryptedMetadata, vault, keypair);
117
+ return meta.n;
118
+ }
119
+ }
120
+ } catch {
121
+ // Fall through to placeholder
122
+ }
123
+ return fileInfo.name;
124
+ }
125
+
126
+ /**
127
+ * Encrypt (if shared vault) and upload a buffer to Tusky.
122
128
  * Shared by tusky_file_upload (from disk) and tusky_file_create (from content).
123
129
  */
124
130
  async function encryptAndUpload(
@@ -130,37 +136,16 @@ async function encryptAndUpload(
130
136
  ctx: McpContext,
131
137
  ) {
132
138
  const vault = await ctx.sdk.vaults.get(vaultId);
133
- const isPrivate = vault.visibility === 'private';
134
139
  const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
135
140
 
136
141
  let uploadBody: Buffer;
137
142
  let encryptionMeta: {
138
- wrappedKey?: string;
139
- encryptionIv?: string;
140
- plaintextSizeBytes?: number;
141
- plaintextChecksumSha256?: string;
142
143
  sealIdentity?: string;
143
144
  sealEncryptedObject?: string;
145
+ encryptedMetadata?: string;
144
146
  } = {};
145
147
 
146
- if (isPrivate) {
147
- const masterKey = ctx.getMasterKey();
148
- if (!masterKey) {
149
- throw new Error(
150
- 'Encryption passphrase required for private vault uploads. ' +
151
- 'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
152
- );
153
- }
154
-
155
- const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
156
- uploadBody = ciphertext;
157
- encryptionMeta = {
158
- wrappedKey,
159
- encryptionIv: iv,
160
- plaintextSizeBytes: fileBuffer.length,
161
- plaintextChecksumSha256: plaintextChecksum,
162
- };
163
- } else if (isShared) {
148
+ if (isShared) {
164
149
  const keypair = ctx.getSuiKeypair();
165
150
  if (!keypair) {
166
151
  throw new Error(
@@ -175,7 +160,7 @@ async function encryptAndUpload(
175
160
  encryptionMeta = {
176
161
  sealIdentity: sealResult.sealIdentity,
177
162
  sealEncryptedObject: sealResult.sealEncryptedObject,
178
- plaintextSizeBytes: fileBuffer.length,
163
+ encryptedMetadata: await sealEncryptMetadata(fileName, mimeType, vault, fileNonce),
179
164
  };
180
165
  } else {
181
166
  uploadBody = fileBuffer;
@@ -205,7 +190,7 @@ async function encryptAndUpload(
205
190
  // Confirm upload
206
191
  const file = await ctx.sdk.files.confirmUpload(fileId);
207
192
 
208
- return { file, isPrivate, isShared, uploadedSizeBytes: uploadBody.length };
193
+ return { file, isShared, uploadedSizeBytes: uploadBody.length };
209
194
  }
210
195
 
211
196
  // ---------------------------------------------------------------------------
@@ -216,7 +201,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
216
201
  // ── Upload file from disk ──────────────────────────────────────────────
217
202
  server.tool(
218
203
  'tusky_file_upload',
219
- 'Upload a file from a local filesystem path to a vault. Handles encryption transparently for private vaults. Use tusky_file_create instead if you want to upload content directly without a file on disk.',
204
+ 'Upload a file from a local filesystem path to a vault. Handles SEAL encryption transparently for shared vaults. Use tusky_file_create instead if you want to upload content directly without a file on disk.',
220
205
  {
221
206
  filePath: z.string().describe('Absolute or relative path to the local file to upload'),
222
207
  vaultId: z.string().describe('Target vault ID'),
@@ -241,7 +226,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
241
226
  const fileName = basename(resolvedPath);
242
227
  const mimeType = lookup(resolvedPath) || 'application/octet-stream';
243
228
 
244
- const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
229
+ const { file, isShared, uploadedSizeBytes } = await encryptAndUpload(
245
230
  fileBuffer, fileName, mimeType, vaultId, folderId, ctx,
246
231
  );
247
232
 
@@ -249,8 +234,8 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
249
234
  content: [{ type: 'text' as const, text: JSON.stringify({
250
235
  ...file,
251
236
  localPath: resolvedPath,
252
- encrypted: isPrivate || isShared,
253
- encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
237
+ encrypted: isShared,
238
+ encryptionType: isShared ? 'seal' : 'none',
254
239
  uploadedSizeBytes,
255
240
  }, null, 2) }],
256
241
  };
@@ -263,7 +248,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
263
248
  // ── Create file from content ─────────────────────────────────────────
264
249
  server.tool(
265
250
  'tusky_file_create',
266
- 'Create a file in a vault from raw content (text or base64-encoded binary). Use this when you want to upload content directly without needing a file on disk. Handles encryption transparently for private vaults.',
251
+ 'Create a file in a vault from raw content (text or base64-encoded binary). Use this when you want to upload content directly without needing a file on disk. Handles SEAL encryption transparently for shared vaults.',
267
252
  {
268
253
  name: z.string().describe('File name including extension (e.g. "report.md", "data.json", "image.png")'),
269
254
  content: z.string().describe('File content: plain text for text files, or base64-encoded string for binary files'),
@@ -282,15 +267,15 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
282
267
 
283
268
  const mimeType = lookup(name) || 'application/octet-stream';
284
269
 
285
- const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
270
+ const { file, isShared, uploadedSizeBytes } = await encryptAndUpload(
286
271
  fileBuffer, name, mimeType, vaultId, folderId, ctx,
287
272
  );
288
273
 
289
274
  return {
290
275
  content: [{ type: 'text' as const, text: JSON.stringify({
291
276
  ...file,
292
- encrypted: isPrivate || isShared,
293
- encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
277
+ encrypted: isShared,
278
+ encryptionType: isShared ? 'seal' : 'none',
294
279
  uploadedSizeBytes,
295
280
  }, null, 2) }],
296
281
  };
@@ -303,7 +288,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
303
288
  // ── Download file (to disk) ────────────────────────────────────────────
304
289
  server.tool(
305
290
  'tusky_file_download',
306
- 'Download a file by ID and save it to a local path on disk. Handles decryption for private vaults and rehydration for cold files. Use tusky_file_read instead if you just want the file content returned directly.',
291
+ 'Download a file by ID and save it to a local path on disk. Handles SEAL decryption for shared vaults and rehydration for cold files. Use tusky_file_read instead if you just want the file content returned directly.',
307
292
  {
308
293
  fileId: z.string().describe('File ID to download'),
309
294
  outputPath: z.string().describe('Local path to save the file (must be writable)'),
@@ -313,13 +298,14 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
313
298
  const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
314
299
 
315
300
  const fileInfo = await ctx.sdk.files.getStatus(fileId);
301
+ const realName = await resolveFilename({ ...fileInfo, fileId }, ctx);
316
302
  const finalPath = resolve(outputPath);
317
303
 
318
304
  writeFileSync(finalPath, fileBuffer);
319
305
 
320
306
  const result = {
321
307
  fileId,
322
- name: fileInfo.name,
308
+ name: realName,
323
309
  savedTo: finalPath,
324
310
  sizeBytes: fileBuffer.length,
325
311
  mimeType: fileInfo.mimeType,
@@ -338,7 +324,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
338
324
  // ── Read file content (inline) ───────────────────────────────────────
339
325
  server.tool(
340
326
  'tusky_file_read',
341
- 'Read a file\'s content and return it directly. For text files the content is returned as text. For binary files it is returned as base64. Handles decryption for private vaults and rehydration for cold files transparently. Prefer this over tusky_file_download when you need to inspect file content.',
327
+ 'Read a file\'s content and return it directly. For text files the content is returned as text. For binary files it is returned as base64. Handles SEAL decryption for shared vaults and rehydration for cold files transparently. Prefer this over tusky_file_download when you need to inspect file content.',
342
328
  {
343
329
  fileId: z.string().describe('File ID to read'),
344
330
  },
@@ -346,6 +332,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
346
332
  try {
347
333
  const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
348
334
  const fileInfo = await ctx.sdk.files.getStatus(fileId);
335
+ const realName = await resolveFilename({ ...fileInfo, fileId }, ctx);
349
336
 
350
337
  const isText = isTextMimeType(fileInfo.mimeType);
351
338
 
@@ -354,7 +341,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
354
341
  content: [
355
342
  {
356
343
  type: 'text' as const,
357
- text: `--- ${fileInfo.name} (${fileInfo.mimeType}, ${fileBuffer.length} bytes${encryption?.encrypted ? ', decrypted' : ''}) ---\n\n${fileBuffer.toString('utf-8')}`,
344
+ text: `--- ${realName} (${fileInfo.mimeType}, ${fileBuffer.length} bytes${encryption?.encrypted ? ', decrypted' : ''}) ---\n\n${fileBuffer.toString('utf-8')}`,
358
345
  },
359
346
  ],
360
347
  };
@@ -367,7 +354,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
367
354
  type: 'text' as const,
368
355
  text: JSON.stringify({
369
356
  fileId,
370
- name: fileInfo.name,
357
+ name: realName,
371
358
  mimeType: fileInfo.mimeType,
372
359
  sizeBytes: fileBuffer.length,
373
360
  wasEncrypted: encryption?.encrypted ?? false,
@@ -15,8 +15,8 @@ export function registerVaultTools(server: McpServer, ctx: McpContext) {
15
15
  {
16
16
  name: z.string().describe('Vault name'),
17
17
  description: z.string().optional().describe('Vault description'),
18
- visibility: z.enum(['public', 'private', 'shared']).optional().describe(
19
- 'Vault visibility. "private" uses passphrase-based E2E encryption. "shared" uses SEAL protocol for multi-party access. Defaults to "private".',
18
+ visibility: z.enum(['public', 'shared']).optional().describe(
19
+ 'Vault visibility. "shared" uses SEAL protocol for multi-party access. Defaults to "public".',
20
20
  ),
21
21
  },
22
22
  async ({ name, description, visibility }) => {
@@ -24,7 +24,7 @@ export function registerVaultTools(server: McpServer, ctx: McpContext) {
24
24
  const vault = await ctx.sdk.vaults.create({
25
25
  name,
26
26
  description,
27
- visibility: visibility ?? 'private',
27
+ visibility: visibility ?? 'public',
28
28
  });
29
29
  return {
30
30
  content: [{ type: 'text' as const, text: JSON.stringify(vault, null, 2) }],
package/src/seal.ts CHANGED
@@ -18,7 +18,7 @@ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
18
18
  import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
19
19
  import { Transaction } from '@mysten/sui/transactions';
20
20
  import { fromHex } from '@mysten/sui/utils';
21
- import { SEAL_PACKAGE_ID, SEAL_DEFAULT_KEY_SERVER_CONFIGS } from '@tuskydp/shared/constants.js';
21
+ import { SEAL_PACKAGE_ID, SEAL_DEFAULT_KEY_SERVER_CONFIGS, METADATA_NONCE_PREFIX } from '@tuskydp/shared/constants.js';
22
22
  import type { VaultResponse } from '@tuskydp/sdk';
23
23
  import { getSuiPrivateKey } from './config.js';
24
24
 
@@ -193,6 +193,39 @@ export async function sealEncrypt(
193
193
  };
194
194
  }
195
195
 
196
+ /**
197
+ * Encrypt filename + MIME type using SEAL for a shared vault.
198
+ * Uses a distinct nonce (METADATA_NONCE_PREFIX + fileNonce) so the metadata
199
+ * identity differs from the file content identity.
200
+ *
201
+ * Returns base64-encoded full SEAL EncryptedObject (stored in encrypted_metadata column).
202
+ */
203
+ export async function sealEncryptMetadata(
204
+ name: string,
205
+ mimeType: string,
206
+ vault: VaultResponse,
207
+ fileNonce: string,
208
+ ): Promise<string> {
209
+ const plaintext = new TextEncoder().encode(JSON.stringify({ n: name, m: mimeType }));
210
+ const metaNonce = METADATA_NONCE_PREFIX + fileNonce;
211
+ const result = await sealEncrypt(plaintext, vault, metaNonce);
212
+ return Buffer.from(result.encryptedData).toString('base64');
213
+ }
214
+
215
+ /**
216
+ * Decrypt an encryptedMetadata blob produced by sealEncryptMetadata.
217
+ * Returns the original { n: name, m: mimeType }.
218
+ */
219
+ export async function sealDecryptMetadata(
220
+ encryptedBase64: string,
221
+ vault: VaultResponse,
222
+ keypair: import('@mysten/sui/keypairs/ed25519').Ed25519Keypair,
223
+ ): Promise<{ n: string; m: string }> {
224
+ const encryptedData = new Uint8Array(Buffer.from(encryptedBase64, 'base64'));
225
+ const plaintext = await sealDecrypt(encryptedData, vault, keypair);
226
+ return JSON.parse(new TextDecoder().decode(plaintext));
227
+ }
228
+
196
229
  // ---------------------------------------------------------------------------
197
230
  // Decrypt
198
231
  // ---------------------------------------------------------------------------
@@ -2,17 +2,25 @@ import blessed from 'blessed';
2
2
  import type { TuskyClient } from '@tuskydp/sdk';
3
3
  import { formatBytes, formatDate, formatRow, statusColor, statusColorClose, getCurrentTheme } from './helpers.js';
4
4
 
5
+ export interface FolderItem {
6
+ id: string;
7
+ name: string;
8
+ parentId: string | null;
9
+ }
10
+
5
11
  export interface FileItem {
6
12
  id: string;
7
13
  name: string;
8
14
  sizeBytes: number;
9
- plaintextSizeBytes: number | null;
10
15
  mimeType: string;
11
16
  status: string;
12
17
  createdAt: string;
13
18
  walrusBlobId: string | null;
14
19
  walrusBlobObjectId: string | null;
15
20
  encrypted: boolean;
21
+ folderId: string | null;
22
+ autoRenew: boolean;
23
+ ppuEpochEnd: string | null;
16
24
  }
17
25
 
18
26
  export class FilesPanel {
@@ -20,9 +28,15 @@ export class FilesPanel {
20
28
  private screen: blessed.Widgets.Screen;
21
29
  private sdk: TuskyClient;
22
30
  private files: FileItem[] = [];
31
+ private folders: FolderItem[] = [];
32
+ /** Combined display items: folders first, then files */
33
+ private displayItems: Array<{ type: 'folder'; folder: FolderItem } | { type: 'file'; file: FileItem } | { type: 'parent' }> = [];
23
34
  private loading = false;
24
35
  private currentVaultId: string | null = null;
25
36
  private currentVaultName = '';
37
+ private currentFolderId: string | null = null;
38
+ private currentFolderName: string | null = null;
39
+ private folderStack: Array<{ id: string | null; name: string | null }> = [];
26
40
 
27
41
  constructor(
28
42
  screen: blessed.Widgets.Screen,
@@ -63,32 +77,77 @@ export class FilesPanel {
63
77
  });
64
78
  }
65
79
 
66
- async loadForVault(vaultId: string, vaultName: string) {
80
+ async loadForVault(vaultId: string, vaultName: string, folderId?: string | null) {
67
81
  if (this.loading) return;
68
82
  this.loading = true;
69
83
  this.currentVaultId = vaultId;
70
84
  this.currentVaultName = vaultName;
71
- this.list.setLabel(` Files — ${vaultName} `);
72
- this.list.setItems(['Loading files...'] as any);
85
+
86
+ if (folderId !== undefined) {
87
+ this.currentFolderId = folderId;
88
+ }
89
+
90
+ const folderPath = this.currentFolderName
91
+ ? `${vaultName} / ${this.currentFolderName}`
92
+ : vaultName;
93
+ this.list.setLabel(` Files — ${folderPath} `);
94
+ this.list.setItems(['Loading...'] as any);
73
95
  this.screen.render();
74
96
 
75
97
  try {
76
- const { files } = await this.sdk.files.list({ vaultId, limit: 100, sortBy: 'createdAt', order: 'desc' });
77
- this.files = files.map((f) => ({
98
+ // Load folders and files for current context
99
+ const [foldersResult, filesResult] = await Promise.all([
100
+ this.sdk.folders.list({ vaultId, parentId: this.currentFolderId || undefined }).catch(() => []),
101
+ this.sdk.files.list({
102
+ vaultId,
103
+ folderId: this.currentFolderId || undefined,
104
+ limit: 100,
105
+ sortBy: 'createdAt',
106
+ order: 'desc',
107
+ }),
108
+ ]);
109
+
110
+ this.folders = (foldersResult as any[]).map((f) => ({
111
+ id: f.id,
112
+ name: f.name,
113
+ parentId: f.parentId || null,
114
+ }));
115
+
116
+ this.files = filesResult.files.map((f) => ({
78
117
  id: f.id,
79
118
  name: f.name,
80
119
  sizeBytes: f.sizeBytes || 0,
81
- plaintextSizeBytes: f.plaintextSizeBytes || null,
82
120
  mimeType: f.mimeType || '',
83
121
  status: f.status || 'unknown',
84
122
  createdAt: f.createdAt || '',
85
123
  walrusBlobId: f.walrusBlobId || null,
86
124
  walrusBlobObjectId: f.walrusBlobObjectId || null,
87
125
  encrypted: f.encrypted || false,
126
+ folderId: f.folderId || null,
127
+ autoRenew: f.autoRenew || false,
128
+ ppuEpochEnd: f.ppuEpochEnd || null,
88
129
  }));
89
130
 
90
- if (this.files.length === 0) {
91
- this.list.setItems(['(no files — press u to upload)'] as any);
131
+ // Build combined display list
132
+ this.displayItems = [];
133
+
134
+ // Show ".." to go up if we're in a subfolder
135
+ if (this.currentFolderId) {
136
+ this.displayItems.push({ type: 'parent' });
137
+ }
138
+
139
+ // Folders first
140
+ for (const folder of this.folders) {
141
+ this.displayItems.push({ type: 'folder', folder });
142
+ }
143
+
144
+ // Then files
145
+ for (const file of this.files) {
146
+ this.displayItems.push({ type: 'file', file });
147
+ }
148
+
149
+ if (this.displayItems.length === 0) {
150
+ this.list.setItems(['(empty — press u to upload, f to create folder)'] as any);
92
151
  } else {
93
152
  const totalW = (this.list.width as number) - 4;
94
153
  const sizeW = 9;
@@ -97,13 +156,37 @@ export class FilesPanel {
97
156
  const encW = 3;
98
157
  const nameW = Math.max(10, totalW - sizeW - statusW - dateW - encW - 4);
99
158
 
100
- const items = this.files.map((f) => {
101
- const size = formatBytes(f.plaintextSizeBytes ?? f.sizeBytes);
159
+ const items = this.displayItems.map((item) => {
160
+ if (item.type === 'parent') {
161
+ return formatRow([
162
+ { text: '../', tagged: '{yellow-fg}../{/yellow-fg}', width: nameW },
163
+ { text: '', width: sizeW, align: 'right' },
164
+ { text: '', width: statusW },
165
+ { text: '', width: encW },
166
+ { text: '', width: dateW, align: 'right' },
167
+ ], totalW);
168
+ }
169
+ if (item.type === 'folder') {
170
+ const name = item.folder.name;
171
+ const display = name.length > nameW - 2
172
+ ? name.slice(0, nameW - 3) + '...'
173
+ : name + '/';
174
+ return formatRow([
175
+ { text: display, tagged: `{cyan-fg}${display}{/cyan-fg}`, width: nameW },
176
+ { text: '', width: sizeW, align: 'right' },
177
+ { text: 'folder', tagged: '{cyan-fg}folder{/cyan-fg}', width: statusW },
178
+ { text: '', width: encW },
179
+ { text: '', width: dateW, align: 'right' },
180
+ ], totalW);
181
+ }
182
+ // file
183
+ const f = item.file;
184
+ const size = formatBytes(f.sizeBytes);
102
185
  const status = f.status;
103
186
  const statusTagged = `${statusColor(f.status)}${f.status}${statusColorClose(f.status)}`;
104
187
  const date = f.createdAt ? formatDate(f.createdAt) : '';
105
188
  const enc = f.encrypted ? '🔒' : '';
106
- const name = f.name.length > nameW ? f.name.slice(0, nameW - 1) + '' : f.name;
189
+ const name = f.name.length > nameW ? f.name.slice(0, nameW - 1) + '...' : f.name;
107
190
  return formatRow([
108
191
  { text: name, width: nameW },
109
192
  { text: size, width: sizeW, align: 'right' },
@@ -122,18 +205,57 @@ export class FilesPanel {
122
205
  this.screen.render();
123
206
  }
124
207
 
208
+ /** Navigate into a folder or back to parent */
209
+ async navigateToFolder(folderId: string | null, folderName: string | null) {
210
+ if (!this.currentVaultId) return;
211
+
212
+ // Push current location onto stack before navigating
213
+ this.folderStack.push({ id: this.currentFolderId, name: this.currentFolderName });
214
+ this.currentFolderId = folderId;
215
+ this.currentFolderName = folderName;
216
+ await this.loadForVault(this.currentVaultId, this.currentVaultName);
217
+ }
218
+
219
+ /** Navigate back to parent folder */
220
+ async navigateUp() {
221
+ if (!this.currentVaultId || !this.currentFolderId) return;
222
+
223
+ const prev = this.folderStack.pop();
224
+ this.currentFolderId = prev?.id || null;
225
+ this.currentFolderName = prev?.name || null;
226
+ await this.loadForVault(this.currentVaultId, this.currentVaultName);
227
+ }
228
+
229
+ /** Reset folder navigation when switching vaults */
230
+ resetFolderNav() {
231
+ this.currentFolderId = null;
232
+ this.currentFolderName = null;
233
+ this.folderStack = [];
234
+ }
235
+
236
+ getSelectedItem(): { type: 'parent' } | { type: 'folder'; folder: FolderItem } | { type: 'file'; file: FileItem } | null {
237
+ const idx = (this.list as any).selected as number;
238
+ return this.displayItems[idx] || null;
239
+ }
240
+
125
241
  clear() {
126
242
  this.files = [];
243
+ this.folders = [];
244
+ this.displayItems = [];
127
245
  this.currentVaultId = null;
128
246
  this.currentVaultName = '';
247
+ this.currentFolderId = null;
248
+ this.currentFolderName = null;
249
+ this.folderStack = [];
129
250
  this.list.setLabel(' Files ');
130
251
  this.list.setItems(['Select a vault'] as any);
131
252
  this.screen.render();
132
253
  }
133
254
 
134
255
  getSelected(): FileItem | null {
135
- const idx = (this.list as any).selected as number;
136
- return this.files[idx] || null;
256
+ const item = this.getSelectedItem();
257
+ if (item && item.type === 'file') return item.file;
258
+ return null;
137
259
  }
138
260
 
139
261
  getFiles(): FileItem[] {
@@ -148,6 +270,10 @@ export class FilesPanel {
148
270
  return this.currentVaultName;
149
271
  }
150
272
 
273
+ getCurrentFolderId(): string | null {
274
+ return this.currentFolderId;
275
+ }
276
+
151
277
  focus() {
152
278
  this.list.focus();
153
279
  this.list.style.border = { fg: getCurrentTheme().borderFocus } as any;