keychat-save 1.5.0 → 1.6.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 (4) hide show
  1. package/README.md +16 -0
  2. package/index.mjs +3 -0
  3. package/package.json +1 -1
  4. package/repo.mjs +160 -10
package/README.md CHANGED
@@ -98,8 +98,24 @@ keychat-save keycloud get <txid> ~/Downloads
98
98
 
99
99
  # What you have uploaded from this machine
100
100
  keychat-save keycloud list
101
+
102
+ # Publish identifiers for everything already on chain — see below
103
+ keychat-save keycloud catalog
101
104
  ```
102
105
 
106
+ ### Listing without downloading
107
+
108
+ Metadata travels in its own encrypted push, separate from the file. A listing
109
+ therefore costs kilobytes: names, sizes and image thumbnails are read without
110
+ moving a single file body, and the bodies are fetched only when you open one.
111
+
112
+ Anything uploaded before that split has its metadata sealed inside its own body,
113
+ so listing it the old way meant downloading it. `keycloud catalog` fixes that in
114
+ one small transaction: it publishes the identifiers you already hold locally —
115
+ names, sizes, hashes, and your RepoChain snapshots too — encrypted to your key.
116
+ Any device with that key then lists your whole history for a few KB. Cataloguing
117
+ 51 items cost 2 Compute.
118
+
103
119
  Bytes are encrypted directly — never base64, which would add 33% to the size and therefore 33% to
104
120
  the cost. The file's sha256 travels with it and is checked on the way back, so a truncated fetch
105
121
  fails loudly instead of writing a corrupt file.
package/index.mjs CHANGED
@@ -506,6 +506,9 @@ KEYCLOUD — files on chain, sealed to your own key:
506
506
  keycloud put <file> Upload one file in a single transaction
507
507
  keycloud get <txid> <dest> Fetch it back (sha256 verified before writing)
508
508
  keycloud list Files uploaded from this machine
509
+ keycloud catalog Publish identifiers for everything already on
510
+ chain, so any device can list it without
511
+ downloading a single file
509
512
 
510
513
  Each snapshot is ONE transaction. A delta continues the chain automatically and
511
514
  carries the ordered txids of every ancestor, so a single txid restores everything.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Save sessions, files and whole git repositories to the BSV blockchain via KeyChat",
5
5
  "type": "module",
6
6
  "bin": {
package/repo.mjs CHANGED
@@ -907,11 +907,68 @@ async function restore (txid, dest) {
907
907
  // transaction no miner will accept.
908
908
  const CLOUD_PREFIX = '4b434c44' // "KCLD"
909
909
  const CLOUD_INDEX = 'keycloud.json'
910
+ // v2 SPLITS METADATA OUT OF THE FILE'S CIPHERTEXT.
911
+ //
912
+ // v1 [KCLD, 01, pub, encrypt(meta + bytes), ts]
913
+ // v2 [KCLD, 02, pub, encrypt(meta), encrypt(bytes), ts]
914
+ //
915
+ // v1 sealed the filename INSIDE the file's own ciphertext, so listing what you
916
+ // own meant fetching and decrypting every byte you had ever uploaded — ten
917
+ // 10 MB files is a 100 MB download to draw ten labels. A file browser is not
918
+ // buildable on that.
919
+ //
920
+ // In v2 the meta push is a few hundred bytes plus an optional thumbnail, so the
921
+ // bridge can index and serve it alone and the client renders a whole grid
922
+ // without touching a single file body. The bytes are fetched only when someone
923
+ // opens something.
924
+ //
925
+ // BOTH PUSHES STAY ENCRYPTED to the owner's key. Filenames never appear in
926
+ // plaintext on chain — the point is that only the holder of the key learns
927
+ // anything, and a filename is often the most revealing part of a file.
928
+ //
929
+ // v1 files stay readable: cloudGet accepts either shape.
930
+ const CLOUD_VERSION_V1 = '01'
931
+ const CLOUD_VERSION = '02'
910
932
  // Policy ceiling for a single transaction. The payload is the whole tx, so this
911
933
  // is deliberately conservative against the published 100 MB: envelope, ECIES
912
934
  // overhead and the funding input all sit inside the same limit.
913
935
  const CLOUD_MAX_SINGLE_TX = 95 * 1024 * 1024
914
936
 
937
+ const MIME_BY_EXT = {
938
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
939
+ '.webp': 'image/webp', '.heic': 'image/heic', '.tiff': 'image/tiff', '.bmp': 'image/bmp',
940
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
941
+ '.json': 'application/json', '.csv': 'text/csv', '.zip': 'application/zip',
942
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav'
943
+ }
944
+ const mimeFor = name => MIME_BY_EXT[path.extname(name).toLowerCase()] || 'application/octet-stream'
945
+
946
+ /**
947
+ * A 256px JPEG preview, base64, for the file grid to render without ever
948
+ * fetching the original — the same reason Finder feels instant.
949
+ *
950
+ * Uses `sips`, which ships with macOS, rather than adding an image dependency
951
+ * to a package whose only dep is @bsv/sdk. Every failure path returns null and
952
+ * the grid falls back to a type icon: a missing thumbnail must never be able to
953
+ * fail an upload.
954
+ */
955
+ function makeThumb (abs, mime) {
956
+ if (!mime.startsWith('image/')) return null
957
+ const tmp = path.join(TMP, `kcthumb-${Math.random().toString(36).slice(2)}.jpg`)
958
+ try {
959
+ sh('sips', ['-Z', '256', '-s', 'format', 'jpeg', '-s', 'formatOptions', '60', abs, '--out', tmp],
960
+ { stdio: 'ignore' })
961
+ const b = fs.readFileSync(tmp)
962
+ // Sanity bound: a "thumbnail" bigger than this is not a thumbnail, and it
963
+ // would bloat the very push that exists to stay small.
964
+ return b.length > 64 * 1024 ? null : b.toString('base64')
965
+ } catch {
966
+ return null
967
+ } finally {
968
+ try { fs.unlinkSync(tmp) } catch { /* never existed */ }
969
+ }
970
+ }
971
+
915
972
  function recordCloudFile (rec) {
916
973
  const idx = path.join(CONFIG_DIR, CLOUD_INDEX)
917
974
  let all = []
@@ -940,25 +997,39 @@ async function cloudPut (filePath, { dry = false } = {}) {
940
997
  `command yet — nothing was spent.`)
941
998
  }
942
999
  const sha = createHash('sha256').update(bytes).digest('hex')
1000
+ const name = path.basename(abs)
1001
+ const mime = mimeFor(name)
1002
+ const thumb = makeThumb(abs, mime)
943
1003
  const meta = {
944
- v: 1,
1004
+ v: 2,
945
1005
  kind: 'file',
946
- name: path.basename(abs),
1006
+ name,
947
1007
  size: bytes.length,
948
1008
  sha256: sha,
949
- at: Math.floor(Date.now() / 1000)
1009
+ mime,
1010
+ at: Math.floor(Date.now() / 1000),
1011
+ ...(thumb ? { thumb } : {})
950
1012
  }
951
- console.log(`${meta.name} ${(bytes.length / 1048576).toFixed(2)} MB`)
1013
+ console.log(`${name} ${(bytes.length / 1048576).toFixed(2)} MB ${mime}` +
1014
+ (thumb ? ` thumb ${(Buffer.byteLength(thumb, 'base64') / 1024).toFixed(1)} KB` : ''))
952
1015
  console.log(`sha256 ${sha}`)
953
1016
 
954
1017
  process.stdout.write('encrypting ... ')
955
1018
  const t = Date.now()
956
- const cipher = EncryptedMessage.encrypt(
957
- Array.from(framePayload(meta, bytes)), k, PublicKey.fromString(pubHex)
1019
+ const pub = PublicKey.fromString(pubHex)
1020
+ // TWO PUSHES. The meta push is what the bridge indexes and the file grid
1021
+ // reads; the file push is fetched only when someone opens the file. Both are
1022
+ // sealed to the same key — a filename is often the most revealing part of a
1023
+ // file, so it never travels in plaintext.
1024
+ const metaCipher = EncryptedMessage.encrypt(
1025
+ Array.from(Buffer.from(JSON.stringify(meta), 'utf8')), k, pub
958
1026
  )
959
- console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
1027
+ const fileCipher = EncryptedMessage.encrypt(Array.from(bytes), k, pub)
1028
+ console.log(`${((Date.now() - t) / 1000).toFixed(1)}s ` +
1029
+ `meta ${(metaCipher.length / 1024).toFixed(1)} KB, file ${(fileCipher.length / 1048576).toFixed(2)} MB`)
960
1030
  const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
961
- const scriptHex = buildOpReturn([CLOUD_PREFIX, VERSION, pubHex, hex(cipher), timestamp])
1031
+ const scriptHex = buildOpReturn(
1032
+ [CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
962
1033
 
963
1034
  const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
964
1035
  if (dry) {
@@ -993,8 +1064,18 @@ async function cloudGet (txid, dest) {
993
1064
  }
994
1065
  if (!parts || parts[0] !== CLOUD_PREFIX) throw new Error(`${txid} is not a KCLD keycloud transaction`)
995
1066
  if (parts[2] !== pubHex) throw new Error(`${txid} is not encrypted to this identity`)
996
- const plain = Buffer.from(EncryptedMessage.decrypt(Array.from(Buffer.from(parts[3], 'hex')), k))
997
- const { meta, tar: bytes } = unframePayload(plain)
1067
+
1068
+ // BOTH SHAPES. v1 sealed meta and bytes in one ciphertext; v2 splits them so
1069
+ // the meta can be indexed and listed without moving the file. Files uploaded
1070
+ // before the split must keep opening — the version byte says which is which.
1071
+ const dec = p => Buffer.from(EncryptedMessage.decrypt(Array.from(Buffer.from(p, 'hex')), k))
1072
+ let meta, bytes
1073
+ if (parts[1] === CLOUD_VERSION_V1) {
1074
+ ({ meta, tar: bytes } = unframePayload(dec(parts[3])))
1075
+ } else {
1076
+ meta = JSON.parse(dec(parts[3]).toString('utf8'))
1077
+ bytes = dec(parts[4])
1078
+ }
998
1079
 
999
1080
  const got = createHash('sha256').update(bytes).digest('hex')
1000
1081
  if (meta.sha256 && got !== meta.sha256) {
@@ -1008,6 +1089,73 @@ async function cloudGet (txid, dest) {
1008
1089
  console.log(`wrote ${out}`)
1009
1090
  }
1010
1091
 
1092
+ /**
1093
+ * Publish a CATALOG: one small transaction carrying identifiers for things
1094
+ * already on chain.
1095
+ *
1096
+ * The backfill problem: everything uploaded before the v2 split sealed its
1097
+ * metadata inside its own body, so listing a v1 item means downloading it —
1098
+ * 34 MB to read a repo name. The bridge cannot help, because it cannot decrypt.
1099
+ * Re-uploading to get v2 metadata would cost thousands of Compute.
1100
+ *
1101
+ * Arash's fix, and it is the right one: send the IDENTIFIERS as their own
1102
+ * delta. We already hold every name, size and hash in the local indexes
1103
+ * (repos.json, keycloud.json) — the expensive part was only ever getting them
1104
+ * back OUT of chain. One catalog transaction publishes the lot, encrypted to
1105
+ * the same key, and any device can then list a full history without moving a
1106
+ * single body.
1107
+ *
1108
+ * Shipped AS a KCLD v2 envelope on purpose: the bridge already indexes those
1109
+ * and serves their meta push, so this needs no new prefix, no new endpoint and
1110
+ * no new indexer. `kind: 'catalog'` tells a reader to merge the entries rather
1111
+ * than render a file.
1112
+ */
1113
+ async function cloudCatalog ({ dry = false } = {}) {
1114
+ const k = key()
1115
+ const address = k.toPublicKey().toAddress()
1116
+ const pubHex = k.toPublicKey().toString()
1117
+ const lockingScript = new P2PKH().lock(address)
1118
+
1119
+ const read = f => { try { return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, f), 'utf8')) } catch { return [] } }
1120
+ const entries = []
1121
+ for (const r of read('repos.json')) {
1122
+ if (!r.txid) continue
1123
+ entries.push({ t: r.txid, k: 'repo', n: r.repo, h: r.head, s: r.tarSize ?? null, a: r.at ?? null, c: r.cost ?? null })
1124
+ }
1125
+ for (const f of read(CLOUD_INDEX)) {
1126
+ if (!f.txid) continue
1127
+ entries.push({ t: f.txid, k: 'file', n: f.name, m: f.mime ?? null, s: f.size ?? null, a: f.at ?? null, c: f.cost ?? null })
1128
+ }
1129
+ if (!entries.length) throw new Error('nothing to catalog — repos.json and keycloud.json are both empty')
1130
+
1131
+ const meta = { v: 2, kind: 'catalog', at: Math.floor(Date.now() / 1000), count: entries.length, entries }
1132
+ const body = Buffer.from(JSON.stringify(meta), 'utf8')
1133
+ console.log(`catalog ${entries.length} entries ${(body.length / 1024).toFixed(1)} KB`)
1134
+ for (const kind of ['repo', 'file']) {
1135
+ const n = entries.filter(e => e.k === kind).length
1136
+ if (n) console.log(` ${n} ${kind}${n === 1 ? '' : 's'}`)
1137
+ }
1138
+
1139
+ process.stdout.write('encrypting ... ')
1140
+ const pub = PublicKey.fromString(pubHex)
1141
+ const metaCipher = EncryptedMessage.encrypt(Array.from(body), k, pub)
1142
+ // The file push is deliberately empty-but-present: a catalog has no body, and
1143
+ // keeping the six-push shape means every existing v2 reader parses it without
1144
+ // a special case.
1145
+ const fileCipher = EncryptedMessage.encrypt(Array.from(Buffer.alloc(0)), k, pub)
1146
+ console.log(`meta ${(metaCipher.length / 1024).toFixed(1)} KB`)
1147
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
1148
+ const scriptHex = buildOpReturn(
1149
+ [CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
1150
+
1151
+ const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
1152
+ if (dry) {
1153
+ console.log(`\n--dry: nothing broadcast\n payload ${res.txid}`)
1154
+ return
1155
+ }
1156
+ console.log(`\nCATALOG ${entries.length} entries -> ${res.txid} (${res.cost} Compute)`)
1157
+ }
1158
+
1011
1159
  function cloudList () {
1012
1160
  let all = []
1013
1161
  try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, CLOUD_INDEX), 'utf8')) } catch {}
@@ -1065,9 +1213,11 @@ export async function runKeycloud (argv) {
1065
1213
  if (sub === 'put') return cloudPut(args[0], { dry })
1066
1214
  if (sub === 'get') return cloudGet(args[0], args[1])
1067
1215
  if (sub === 'list') return cloudList()
1216
+ if (sub === 'catalog') return cloudCatalog({ dry })
1068
1217
  console.log('usage:')
1069
1218
  console.log(' keychat-save keycloud put <file> [--dry] one tx, sealed to your own key')
1070
1219
  console.log(' keychat-save keycloud get <txid> <dest>')
1071
1220
  console.log(' keychat-save keycloud list')
1221
+ console.log(' keychat-save keycloud catalog publish identifiers for existing on-chain items')
1072
1222
  process.exitCode = 1
1073
1223
  }