telstore 0.1.0 → 0.1.2

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/src/downloader.js CHANGED
@@ -4,7 +4,7 @@ import { promisify } from 'node:util'
4
4
 
5
5
  import { returnBigInt } from 'telegram/Helpers.js'
6
6
 
7
- import { DEFAULT_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
7
+ import { DEFAULT_DOWNLOAD_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
8
8
  import { withRetry } from './retry.js'
9
9
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
10
10
 
@@ -57,7 +57,7 @@ export async function downloadToFile(
57
57
  client,
58
58
  message,
59
59
  fd,
60
- { offset, onProgress, retryOptions, concurrency = DEFAULT_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
60
+ { offset, onProgress, retryOptions, concurrency = DEFAULT_DOWNLOAD_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
61
61
  ) {
62
62
  const document = message?.media?.document
63
63
 
package/src/manifest.js CHANGED
@@ -8,7 +8,7 @@ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).t
8
8
  const yyyy = now.getUTCFullYear()
9
9
  const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
10
10
  const dd = String(now.getUTCDate()).padStart(2, '0')
11
- return `telark-${yyyy}${mm}${dd}-${randomHex()}`
11
+ return `telstore-${yyyy}${mm}${dd}-${randomHex()}`
12
12
  }
13
13
 
14
14
  export function chunkFileName(id, i) {
@@ -72,7 +72,7 @@ export function manifestMessageIds(manifest) {
72
72
  throw new Error(
73
73
  `Manifest gives ${JSON.stringify(msgId)} as the message id of chunk ${index + 1}, ` +
74
74
  'which is not a message id. Deleting from this manifest could remove the wrong ' +
75
- 'messages, so telark is not deleting anything.',
75
+ 'messages, so telstore is not deleting anything.',
76
76
  )
77
77
  }
78
78
 
@@ -85,7 +85,7 @@ export function parseManifest(input) {
85
85
 
86
86
  if (manifest.v !== MANIFEST_VERSION) {
87
87
  throw new Error(
88
- `Manifest uses version ${manifest.v}, this build of telark only understands version ${MANIFEST_VERSION}.`,
88
+ `Manifest uses version ${manifest.v}, this build of telstore only understands version ${MANIFEST_VERSION}.`,
89
89
  )
90
90
  }
91
91
 
@@ -147,7 +147,7 @@ export function parseManifest(input) {
147
147
  // uniform: every chunk is chunkSize, except the last one which is the remainder.
148
148
  // A correct total with individually wrong sizes yields a file with a hole or
149
149
  // extra length while every per-chunk sha256 still matches — silently wrong data,
150
- // precisely what telark must never produce.
150
+ // precisely what telstore must never produce.
151
151
  manifest.chunks.forEach((chunk, index) => {
152
152
  const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
153
153
  if (chunk.size !== expected) {
package/src/prompt.js ADDED
@@ -0,0 +1,81 @@
1
+ import readline from 'node:readline/promises'
2
+ import { stdin, stdout, stderr } from 'node:process'
3
+ import { Writable } from 'node:stream'
4
+
5
+ // readline echoes what it reads through its own output, so putting a curtain in front of that
6
+ // output is what makes typing invisible.
7
+ function veiledOutput(output) {
8
+ let hidden = false
9
+
10
+ return {
11
+ hide: () => {
12
+ hidden = true
13
+ },
14
+ show: () => {
15
+ hidden = false
16
+ },
17
+ // The question goes straight to the real output, past the curtain, so it stays on screen
18
+ // while the answer to it does not.
19
+ say: (text) => output.write(text),
20
+ stream: new Writable({
21
+ write(chunk, encoding, callback) {
22
+ if (!hidden) output.write(chunk, encoding)
23
+ callback()
24
+ },
25
+ }),
26
+ }
27
+ }
28
+
29
+ const NO_TERMINAL =
30
+ 'There is no terminal here to type a secret into. Run this where you can type it.'
31
+
32
+ // One readline for a whole conversation, some of whose answers must not stay on the screen.
33
+ // It has to be one: two readlines over a single stdin do not take turns — the first keeps the
34
+ // listener and everything typed after it lands nowhere, so the second reaches end-of-input
35
+ // having read nothing and reports it as Ctrl-D. Verified against a real terminal, because a
36
+ // fake stream takes turns perfectly well and would have called this fine.
37
+ export function createPrompts({ input = stdin, output = stdout } = {}) {
38
+ const veil = veiledOutput(output)
39
+ // terminal follows stdin: true is what stops the tty driver from echoing on its own, which
40
+ // is what leaves the curtain as the only thing between the keyboard and the screen. Forcing
41
+ // it on a pipe would put readline into line editing over input with no terminal behind it.
42
+ const rl = readline.createInterface({ input, output: veil.stream, terminal: Boolean(input.isTTY) })
43
+
44
+ return {
45
+ ask: (question) => rl.question(question),
46
+ async askSecret(question) {
47
+ // A prompt written where nobody can see it, waiting on a stream that will never carry a
48
+ // typed answer, is a hang — the one failure this project refuses to produce anywhere.
49
+ // Reading it from the pipe instead would be worse: the secret would then have come from
50
+ // somewhere that kept a copy of it.
51
+ if (!input.isTTY) throw new Error(NO_TERMINAL)
52
+
53
+ veil.say(question)
54
+ veil.hide()
55
+
56
+ try {
57
+ return await rl.question('')
58
+ } finally {
59
+ veil.show()
60
+ veil.say('\n')
61
+ }
62
+ },
63
+ // Not optional: terminal mode put stdin in raw mode, and leaving it there hands the user
64
+ // back a shell that no longer echoes what they type.
65
+ close: () => rl.close(),
66
+ }
67
+ }
68
+
69
+ // The single-question case. Its question goes to stderr rather than stdout, because
70
+ // "npx telstore token > token.txt" has to still show it, and stdout there carries one thing.
71
+ export async function readSecret(question, { input = stdin, output = stderr } = {}) {
72
+ if (!input.isTTY) throw new Error(NO_TERMINAL)
73
+
74
+ const prompts = createPrompts({ input, output })
75
+
76
+ try {
77
+ return await prompts.askSecret(question)
78
+ } finally {
79
+ prompts.close()
80
+ }
81
+ }
package/src/session.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readSecret as realReadSecret } from './prompt.js'
2
+ import { decodeToken } from './token.js'
3
+
4
+ // How a stored config becomes the three things GramJS needs. There are two shapes on disk —
5
+ // the ordinary login, and the sealed blob `login --token` writes — and this is the one place
6
+ // that knows the difference, so `connect` stays about Telegram and the commands stay about
7
+ // their own narrative.
8
+ export async function unlockConfig(config, { readSecret = realReadSecret } = {}) {
9
+ const { sealed } = config
10
+
11
+ if (!sealed) {
12
+ const { apiId, apiHash, session } = config
13
+
14
+ return { apiId, apiHash, session }
15
+ }
16
+
17
+ const passphrase = await readSecret('Passphrase for the stored session: ')
18
+ // Everything comes out of the blob and nothing from around it. The fields beside it on disk
19
+ // are editable by anyone who can reach the file, and an apiId taken from there would let an
20
+ // edit decide which account a passphrase unlocks.
21
+ const { apiId, apiHash, session } = await decodeToken(sealed, passphrase)
22
+
23
+ return { apiId, apiHash, session }
24
+ }
package/src/settings.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { normalizeChatTarget } from './chat.js'
2
2
  import {
3
3
  DEFAULT_CHUNK_SIZE,
4
- DEFAULT_CONCURRENCY,
4
+ DEFAULT_DOWNLOAD_CONCURRENCY,
5
+ DEFAULT_UPLOAD_CONCURRENCY,
5
6
  MAX_CONCURRENCY,
6
7
  parseSize,
7
8
  } from './chunking.js'
@@ -9,7 +10,7 @@ import { formatBytes } from './progress.js'
9
10
 
10
11
  export const DEFAULT_LIMIT = 20
11
12
 
12
- // The three keys telark writes for itself. Naming them separately is what lets the
13
+ // The three keys telstore writes for itself. Naming them separately is what lets the
13
14
  // unknown-key error say "managed by login" instead of listing a session as something the
14
15
  // user forgot to spell correctly.
15
16
  const MANAGED_BY_LOGIN = new Set(['session', 'apiId', 'apiHash'])
@@ -74,9 +75,9 @@ export const SETTINGS = {
74
75
  format: (value) => String(value),
75
76
  describe: (value) => formatBytes(value),
76
77
  },
77
- concurrency: {
78
- flag: 'concurrency',
79
- default: DEFAULT_CONCURRENCY,
78
+ uploadConcurrency: {
79
+ flag: 'upload-concurrency',
80
+ default: DEFAULT_UPLOAD_CONCURRENCY,
80
81
  parse: (raw, where) =>
81
82
  wholeNumber(
82
83
  raw,
@@ -88,6 +89,20 @@ export const SETTINGS = {
88
89
  ),
89
90
  format: (value) => String(value),
90
91
  },
92
+ downloadConcurrency: {
93
+ flag: 'download-concurrency',
94
+ default: DEFAULT_DOWNLOAD_CONCURRENCY,
95
+ parse: (raw, where) =>
96
+ wholeNumber(
97
+ raw,
98
+ where,
99
+ 1,
100
+ MAX_CONCURRENCY,
101
+ `Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot runs its own download ` +
102
+ 'stream, and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
103
+ ),
104
+ format: (value) => String(value),
105
+ },
91
106
  limit: {
92
107
  flag: 'limit',
93
108
  default: DEFAULT_LIMIT,
@@ -181,10 +196,21 @@ export function resolveSettings(options = {}, config = {}, { file = 'the config
181
196
  export function requireChat(values) {
182
197
  if (values.chat === null || values.chat === undefined) {
183
198
  throw new Error(
184
- 'No destination set — run "npx telark config chat @my_backups" to set one ' +
199
+ 'No destination set — run "npx telstore config chat @my_backups" to set one ' +
185
200
  '("config chat me" for Saved Messages), or pass --to to choose one for this run.',
186
201
  )
187
202
  }
188
203
 
189
204
  return values.chat
190
205
  }
206
+
207
+ // The snapshot of somebody's settings that a session token carries to a machine which has no
208
+ // config file of its own. A stray key would arrive there as a line in `config` telling the
209
+ // reader to remove a setting they never wrote, so keys telstore does not know are left behind
210
+ // rather than carried along. This lives here because this is the one place that knows which
211
+ // settings exist.
212
+ export function knownSettings(stored = {}) {
213
+ return Object.fromEntries(
214
+ SETTING_KEYS.filter((key) => stored[key] !== undefined).map((key) => [key, stored[key]]),
215
+ )
216
+ }
package/src/state.js CHANGED
@@ -96,6 +96,10 @@ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STA
96
96
  // status needs every unfinished backup at once. A state file that cannot be read is skipped
97
97
  // rather than fatal, for the same reason loadState returns null: one corrupt file must not
98
98
  // hide the other backups still waiting to be finished.
99
+ //
100
+ // The key comes back alongside each record because canResume needs it, and the file name is
101
+ // the only place it survives: the record's own path, size and mtime are exactly what a
102
+ // rewritten file makes stale, so recomputing the key from them would always say yes.
99
103
  export async function listStates(configDir = defaultConfigDir()) {
100
104
  let names
101
105
  try {
@@ -110,8 +114,10 @@ export async function listStates(configDir = defaultConfigDir()) {
110
114
  for (const name of names) {
111
115
  if (!name.endsWith('.json')) continue
112
116
 
113
- const state = await loadState(name.slice(0, -'.json'.length), configDir)
114
- if (state) states.push(state)
117
+ const key = name.slice(0, -'.json'.length)
118
+ const state = await loadState(key, configDir)
119
+
120
+ if (state) states.push({ key, state })
115
121
  }
116
122
 
117
123
  return states
@@ -120,11 +126,11 @@ export async function listStates(configDir = defaultConfigDir()) {
120
126
  // delete needs the file a record came from, not just its contents — and the name of that
121
127
  // file is a hash of the path, size and mtime *inside* the record, so recomputing it would
122
128
  // be trusting an untrusted file to say where it lives. A hand-edited path yields a key that
123
- // names no file at all, clearState ignores a file that is not there, and telark reports a
129
+ // names no file at all, clearState ignores a file that is not there, and telstore reports a
124
130
  // record dropped that is still sitting on disk. Matching the id inside each file is the one
125
131
  // way that cannot point at the wrong one.
126
132
  //
127
- // Every record claiming the id is returned rather than the first: two of them means telark
133
+ // Every record claiming the id is returned rather than the first: two of them means telstore
128
134
  // cannot know which to drop, and that is the caller's decision to refuse, not ours to make
129
135
  // by picking one.
130
136
  export async function findStates(backupId, configDir = defaultConfigDir()) {
@@ -149,3 +155,28 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
149
155
 
150
156
  return found
151
157
  }
158
+
159
+ // Whether a record can still be resumed, which is not a question about the record alone:
160
+ // runUpload hashes the file it finds on disk and looks the result up, so a backup is
161
+ // resumable exactly when that hash is still the key this record is filed under. Recomputing
162
+ // through stateKey rather than comparing size and mtime by hand is the point — a second way
163
+ // of asking is a second way to drift, and status would end up promising a resume that upload
164
+ // turns into a brand new backup, stranding every chunk already sent.
165
+ //
166
+ // Never throws. status calls this for every record it prints, and one damaged path must not
167
+ // take the rest of the report down with it.
168
+ export async function canResume(key, state) {
169
+ let stat
170
+
171
+ try {
172
+ stat = await fs.stat(state.path)
173
+ } catch (err) {
174
+ if (err.code === 'ENOENT') return { ok: false, reason: 'missing' }
175
+ return { ok: false, reason: 'unreadable' }
176
+ }
177
+
178
+ if (!stat.isFile()) return { ok: false, reason: 'not-a-file' }
179
+ if (stateKey(state.path, stat.size, stat.mtimeMs) !== key) return { ok: false, reason: 'changed' }
180
+
181
+ return { ok: true }
182
+ }
package/src/token.js ADDED
@@ -0,0 +1,221 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+
4
+ const derive = promisify(scrypt)
5
+
6
+ // A session token is this machine's Telegram login, written down so another machine can use
7
+ // it. Two formats, not one format with a flag inside: a blob that looked encrypted and was
8
+ // not would be telstore lying about what it handed over, and the prefix is the one part a
9
+ // reader can check before knowing anything else about the bytes.
10
+ export const TOKEN_PREFIX_SEALED = 'tls1.'
11
+ export const TOKEN_PREFIX_PLAIN = 'tls0.'
12
+
13
+ const SALT_BYTES = 16
14
+ const IV_BYTES = 12
15
+ const TAG_BYTES = 16
16
+ const KEY_BYTES = 32
17
+ const OVERHEAD = SALT_BYTES + IV_BYTES + TAG_BYTES
18
+
19
+ // Pinned to the prefix above, never carried inside the token. Whoever holds a token can try
20
+ // passphrases offline as fast as their hardware allows, and 64MB per attempt is what makes
21
+ // that expensive; letting a token name its own parameters would let a stranger's N decide how
22
+ // much memory this machine allocates, which is a denial of service that needs no passphrase
23
+ // at all. maxmem is spelled out because Node's own default is 32MB and would refuse these
24
+ // outright, as an error that reads like a bug in telstore.
25
+ const SCRYPT = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }
26
+
27
+ const MAKE_ONE = 'Make a new one with "npx telstore token" on the machine you are logged in on.'
28
+
29
+ // Whitespace is the one damage repaired rather than refused: a chat client or a mail reader
30
+ // wrapping the line is the likeliest thing to happen to a token, whitespace is never part of
31
+ // one, and joining it back up cannot produce a *different* valid token, because the
32
+ // authentication tag still has to match. Anything else is refused — repairing it would be
33
+ // guessing at what somebody meant.
34
+ function tidy(token) {
35
+ return String(token).replace(/\s+/g, '')
36
+ }
37
+
38
+ export function isSealedToken(text) {
39
+ return tidy(text).startsWith(TOKEN_PREFIX_SEALED)
40
+ }
41
+
42
+ function isPlainObject(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ }
45
+
46
+ // A token is untrusted input in the same category as a manifest or a state file. Opening one
47
+ // proves whoever made it knew the passphrase, not that they made it correctly — and the
48
+ // unprotected format proves nothing at all. An apiId of undefined does not throw here; it
49
+ // reaches GramJS and fails much later as something that reads like a network problem.
50
+ export function checkTokenBundle(bundle) {
51
+ if (!isPlainObject(bundle)) {
52
+ throw new Error(
53
+ `The session token holds ${Array.isArray(bundle) ? 'a list' : typeof bundle}, ` +
54
+ `not an account. ${MAKE_ONE}`,
55
+ )
56
+ }
57
+
58
+ if (!Number.isSafeInteger(bundle.apiId) || bundle.apiId < 1) {
59
+ throw new Error(
60
+ `The session token gives ${JSON.stringify(bundle.apiId)} as the api_id, which is not ` +
61
+ `one. ${MAKE_ONE}`,
62
+ )
63
+ }
64
+
65
+ for (const field of ['apiHash', 'session']) {
66
+ if (typeof bundle[field] !== 'string' || bundle[field] === '') {
67
+ throw new Error(`The session token is missing its ${field}. ${MAKE_ONE}`)
68
+ }
69
+ }
70
+
71
+ // The twin of checkConfigShape, refused for the twin's reason: every lookup below a
72
+ // settings that is not an object returns undefined, and telstore would then run on its
73
+ // built-in defaults while the choices carried in the token sat there ignored.
74
+ if (bundle.settings !== undefined && !isPlainObject(bundle.settings)) {
75
+ throw new Error(
76
+ `"settings" in the session token holds ` +
77
+ `${Array.isArray(bundle.settings) ? 'a list' : typeof bundle.settings}, not a group ` +
78
+ `of settings. ${MAKE_ONE}`,
79
+ )
80
+ }
81
+
82
+ return bundle
83
+ }
84
+
85
+ // Both spellings of the same accented passphrase have to derive the same key: macOS hands
86
+ // back one and Linux the other, and the difference would surface as "wrong passphrase" for a
87
+ // passphrase that is right.
88
+ function keyFrom(passphrase, salt) {
89
+ return derive(String(passphrase).normalize('NFC'), salt, KEY_BYTES, SCRYPT)
90
+ }
91
+
92
+ export async function encodeToken(bundle, passphrase) {
93
+ checkTokenBundle(bundle)
94
+
95
+ const json = JSON.stringify(bundle)
96
+
97
+ // An empty passphrase is not a weak secret, it is the absence of one. Saying so in the
98
+ // prefix is what keeps the format honest: the alternative is a token that opens for anyone
99
+ // who reads the channel it travelled through while looking exactly like a protected one.
100
+ if (String(passphrase) === '') {
101
+ return TOKEN_PREFIX_PLAIN + Buffer.from(json, 'utf8').toString('base64url')
102
+ }
103
+
104
+ const salt = randomBytes(SALT_BYTES)
105
+ const iv = randomBytes(IV_BYTES)
106
+ const cipher = createCipheriv('aes-256-gcm', await keyFrom(passphrase, salt), iv)
107
+ const body = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
108
+
109
+ return (
110
+ TOKEN_PREFIX_SEALED +
111
+ Buffer.concat([salt, iv, cipher.getAuthTag(), body]).toString('base64url')
112
+ )
113
+ }
114
+
115
+ function splitToken(token) {
116
+ const text = tidy(token)
117
+
118
+ for (const prefix of [TOKEN_PREFIX_SEALED, TOKEN_PREFIX_PLAIN]) {
119
+ if (text.startsWith(prefix)) return { sealed: prefix === TOKEN_PREFIX_SEALED, encoded: text.slice(prefix.length) }
120
+ }
121
+
122
+ const version = /^([a-z]+\d+)\./.exec(text)
123
+
124
+ if (version) {
125
+ throw new Error(
126
+ `This session token is in format "${version[1]}", which this telstore does not know ` +
127
+ `(it reads "${TOKEN_PREFIX_SEALED.slice(0, -1)}" and ` +
128
+ `"${TOKEN_PREFIX_PLAIN.slice(0, -1)}"). Upgrade telstore, or make a new token with ` +
129
+ 'the version you have.',
130
+ )
131
+ }
132
+
133
+ throw new Error(
134
+ `That does not look like a session token: one starts with "${TOKEN_PREFIX_SEALED}" or ` +
135
+ `"${TOKEN_PREFIX_PLAIN}". ${MAKE_ONE}`,
136
+ )
137
+ }
138
+
139
+ function bodyBytes(encoded) {
140
+ const trimmed = encoded.replace(/=+$/, '')
141
+ const bytes = Buffer.from(trimmed, 'base64url')
142
+
143
+ // Buffer.from silently drops every character outside the alphabet, so a token with a stray
144
+ // quote in it decodes to *something* — which would then fail authentication and be
145
+ // reported as a wrong passphrase. Re-encoding and comparing is what turns that into the
146
+ // sentence that actually helps.
147
+ if (bytes.toString('base64url') !== trimmed) {
148
+ throw new Error(
149
+ 'The session token has characters that do not belong to one — it was probably ' +
150
+ 'truncated or altered on the way here. Copy it again, whole.',
151
+ )
152
+ }
153
+
154
+ return bytes
155
+ }
156
+
157
+ export async function decodeToken(token, passphrase) {
158
+ const { sealed, encoded } = splitToken(token)
159
+ const bytes = bodyBytes(encoded)
160
+
161
+ if (!sealed) {
162
+ return checkTokenBundle(readJson(bytes.toString('utf8')))
163
+ }
164
+
165
+ // Asked here rather than at the top: a token nobody can read is a different problem from a
166
+ // passphrase nobody supplied, and telling someone to type a passphrase for a token that was
167
+ // damaged in transit sends them after the wrong thing.
168
+ if (String(passphrase) === '') {
169
+ throw new Error(
170
+ 'This session token is protected by a passphrase, and none was given. Run the command ' +
171
+ 'again where you can type it.',
172
+ )
173
+ }
174
+
175
+ if (bytes.length <= OVERHEAD) {
176
+ throw new Error(
177
+ `The session token is too short to be one: it carries ${bytes.length} bytes and the ` +
178
+ `salt, nonce and tag alone need ${OVERHEAD}. Copy it again, whole.`,
179
+ )
180
+ }
181
+
182
+ const decipher = createDecipheriv(
183
+ 'aes-256-gcm',
184
+ await keyFrom(passphrase, bytes.subarray(0, SALT_BYTES)),
185
+ bytes.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES),
186
+ )
187
+
188
+ decipher.setAuthTag(bytes.subarray(SALT_BYTES + IV_BYTES, OVERHEAD))
189
+
190
+ let json
191
+ try {
192
+ json = Buffer.concat([
193
+ decipher.update(bytes.subarray(OVERHEAD)),
194
+ decipher.final(),
195
+ ]).toString('utf8')
196
+ } catch {
197
+ // AES-GCM cannot tell a wrong key from altered bytes: both are one failed tag check, and
198
+ // guessing which it was would send half the readers after the wrong thing. Name both, put
199
+ // the likelier one first, and say out loud that telstore is not guessing.
200
+ throw new Error(
201
+ 'Could not open the session token: either the passphrase is wrong or the token was ' +
202
+ 'altered on the way here. Encryption cannot tell those two apart, so telstore will ' +
203
+ 'not guess — check the passphrase first, then copy the token again, whole.',
204
+ )
205
+ }
206
+
207
+ return checkTokenBundle(readJson(json))
208
+ }
209
+
210
+ // Past the tag check the plaintext is ours, so bad JSON here is not somebody else's mistake:
211
+ // it means the token was written by a telstore that disagreed with this one about the format.
212
+ function readJson(json) {
213
+ try {
214
+ return JSON.parse(json)
215
+ } catch (err) {
216
+ throw new Error(
217
+ `The session token opened, but what is inside it is not what telstore writes ` +
218
+ `(${err.message}). ${MAKE_ONE}`,
219
+ )
220
+ }
221
+ }
package/src/uploader.js CHANGED
@@ -5,7 +5,7 @@ import { promisify } from 'node:util'
5
5
  import { Api } from 'telegram'
6
6
  import { readBigIntFromBuffer } from 'telegram/Helpers.js'
7
7
 
8
- import { DEFAULT_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
8
+ import { DEFAULT_UPLOAD_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
9
9
  import { withRetry } from './retry.js'
10
10
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
11
11
 
@@ -13,7 +13,7 @@ const read = promisify(readCallback)
13
13
 
14
14
  // Telegram splits its upload API by file size: only files above 10MB may use the
15
15
  // "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
16
- // node_modules/telegram/client/uploads.js), and telark follows it.
16
+ // node_modules/telegram/client/uploads.js), and telstore follows it.
17
17
  export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
18
18
 
19
19
  async function readExactly(fd, length, position) {
@@ -39,7 +39,7 @@ export async function uploadRange(client, fd, options) {
39
39
  offset,
40
40
  length,
41
41
  fileName,
42
- concurrency = DEFAULT_CONCURRENCY,
42
+ concurrency = DEFAULT_UPLOAD_CONCURRENCY,
43
43
  partSize = PART_SIZE,
44
44
  onProgress,
45
45
  retryOptions,