storebase-cli 1.0.0 → 1.1.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.
- package/README.md +64 -27
- package/bin/storebase-publish.js +87 -12
- package/bin/storebase-telegram.js +121 -0
- package/lib/gateway-client.js +259 -0
- package/lib/publisher-token.js +58 -0
- package/lib/publisher.js +46 -1
- package/lib/telegram-client.js +178 -0
- package/lib/telegram-storage.js +405 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,41 +1,57 @@
|
|
|
1
1
|
# StoreBase App Publisher CLI
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Node.js CLI for validating signed Android releases and storing APKs, optional deltas, app icons, and version metadata directly in a Telegram broadcast channel. Release manifests are immutable and versioned; the CLI preserves every release and can keep a separate Telegram record identifying an app's default pinned version.
|
|
4
|
+
|
|
5
|
+
The gateway provides encrypted Telegram settings at runtime. The CLI decrypts them in memory, connects directly to Telegram, and never writes Telegram credentials to disk. The gateway does not relay APKs or catalog metadata.
|
|
4
6
|
|
|
5
7
|
## Requirements
|
|
6
8
|
|
|
7
9
|
- Node.js 18 or newer
|
|
8
10
|
- Java `keytool`
|
|
9
11
|
- Android SDK Build Tools `apksigner` (on `PATH`, or under `$ANDROID_SDK_ROOT/build-tools/` or `$ANDROID_HOME/build-tools/`)
|
|
12
|
+
- A Telegram bot that is an administrator of the broadcast channel with permission to post
|
|
13
|
+
- Telegram API ID and API hash, a stored bot MTProto session, and a separate personal-user read session
|
|
14
|
+
|
|
15
|
+
## Install and test
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
For local development:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
cd cli
|
|
21
|
+
npm install
|
|
22
|
+
npm test
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Install the published package globally with:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install --global storebase-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
To install this checkout globally:
|
|
12
32
|
|
|
13
33
|
```sh
|
|
14
34
|
npm install --global ./cli
|
|
15
|
-
storebase-publish
|
|
16
35
|
```
|
|
17
36
|
|
|
18
|
-
|
|
37
|
+
## Runtime configuration
|
|
19
38
|
|
|
20
|
-
-
|
|
21
|
-
- Social
|
|
22
|
-
- Productivity
|
|
23
|
-
- Communication
|
|
24
|
-
- Entertainment
|
|
25
|
-
- Games
|
|
26
|
-
- Business
|
|
27
|
-
- Shopping
|
|
28
|
-
- Finance
|
|
29
|
-
- Utilities
|
|
30
|
-
- Health & Fitness
|
|
31
|
-
- Travel & Navigation
|
|
32
|
-
- AI & Machine Learning
|
|
39
|
+
Gateway mode is the default. `storebase-telegram list`, `metadata`, and `download` use the read-only catalog bootstrap and do not require publisher credentials. `storebase-publish` and `storebase-telegram pin` use the separate publisher bootstrap and require the same high-entropy `STOREBASE_GATEWAY_PUBLISH_TOKEN` in the gateway's secret environment and the CLI runtime. Keep that token in a secret manager or mode-0600 `.env` file; do not commit it. In an interactive terminal, these write commands prompt for it without echoing it when the environment variable is absent; non-interactive runs must set the environment variable. The gateway URL is optional and defaults to `https://storebase.wasmer.app`.
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
```dotenv
|
|
42
|
+
STOREBASE_GATEWAY_URL=https://storebase.wasmer.app
|
|
43
|
+
STOREBASE_GATEWAY_PUBLISH_TOKEN=<same-secret-configured-on-the-gateway>
|
|
44
|
+
```
|
|
35
45
|
|
|
36
|
-
The
|
|
46
|
+
The gateway must also have its existing Telegram environment configured: `STOREBASE_TELEGRAM_API_ID`, `STOREBASE_TELEGRAM_API_HASH`, `STOREBASE_TELEGRAM_SESSION` (the bot's stored MTProto session), `STOREBASE_TELEGRAM_READ_SESSION` (a personal-user session with channel access), and `STOREBASE_TELEGRAM_CHANNEL`. The publisher bootstrap returns only the API ID/hash, bot session, reader session, and channel; it excludes the bot token. The shared publisher token is checked before those settings are returned. Do not place credentials in source code, command-line arguments, or this npm package.
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
At runtime, the CLI creates an ephemeral RSA keypair, requests settings over HTTPS, decrypts the AES-GCM envelope in memory, and passes the values directly to the Telegram client. It does not persist decrypted settings or include them in CLI output. This remains a personal-use design, not a production identity or device-attestation system. Anyone who obtains the publisher token can obtain the encrypted bot session; keep that token restricted and rotate it if exposed.
|
|
49
|
+
|
|
50
|
+
For a direct local Telegram connection instead, place the Telegram variables above in the CLI environment and pass `--telegram-config local`. The gateway remains the default for publishing and catalog commands.
|
|
51
|
+
|
|
52
|
+
## Publish a release
|
|
53
|
+
|
|
54
|
+
The publisher prompts for missing app details. The signing-keystore password is read through hidden terminal input or from `STOREBASE_KEYSTORE_PASSWORD`; it is never accepted as a CLI option. If a keystore has multiple private-key aliases, use `--alias` or select one interactively.
|
|
39
55
|
|
|
40
56
|
```sh
|
|
41
57
|
STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
|
|
@@ -49,18 +65,39 @@ STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
|
|
|
49
65
|
--alias release
|
|
50
66
|
```
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
The CLI runs `keytool` and `apksigner`, and refuses to publish unless the APK signer certificate matches the selected private-key certificate in the provided keystore. The keystore is used only for verification and is never uploaded.
|
|
69
|
+
|
|
70
|
+
For the `StoreBase` app, provide `--android-version-code` on every release. A matching `.sbd` patch can be attached using `--delta <file.sbd> --delta-base-version-code <installed-code>`; the CLI stores the patch in the same Telegram channel and includes its message ID, hash, size, base code, package ID, and release code in the versioned manifest.
|
|
71
|
+
|
|
72
|
+
Supported categories: Education, Social, Productivity, Communication, Entertainment, Games, Business, Shopping, Finance, Utilities, Health & Fitness, Travel & Navigation, and AI & Machine Learning.
|
|
73
|
+
|
|
74
|
+
Use `--storage local` to opt into the older local JSON/files workflow. Its `--catalog` and `--assets-dir` options are only used in local mode.
|
|
53
75
|
|
|
54
|
-
##
|
|
76
|
+
## Telegram storage and versions
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
Each release adds an APK document, an icon document, an optional delta document, then a JSON manifest text post. The manifest is posted last, so an incomplete upload is not listed as a release. The artifacts and metadata stay in Telegram; publishing a later version does not delete older versions.
|
|
57
79
|
|
|
58
|
-
|
|
59
|
-
- `storebase-app-assets/<app-id>/<version>/` — content-addressed APK and avatar files referenced by the JSON manifest.
|
|
80
|
+
Each manifest begins with `STOREBASE_MANIFEST_V1` and includes app metadata, version, publish time, signing-certificate fingerprint, and Telegram message IDs, sizes, and SHA-256 hashes for its artifacts. StoreBase Android releases also include the package name, Android `versionCode`, and optional minimum required code. Publishing the same app/version and content is idempotent; changed content cannot overwrite an existing version.
|
|
60
81
|
|
|
61
|
-
|
|
82
|
+
An optional `STOREBASE_DEFAULT_VERSION_V1` message stores the default-version pointer separately, so pinning a release never edits or replaces its immutable manifest. If no explicit pin exists, the newest release is the default. Use `storebase-telegram pin <app-id> <version>` to set or change the default. The CLI list shows every version and the default; metadata and downloads without `--version` use that default.
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
# See all apps, versions, and each app's default version
|
|
86
|
+
storebase-telegram list
|
|
87
|
+
|
|
88
|
+
# Select a default version; this adds a small metadata record in Telegram
|
|
89
|
+
storebase-telegram pin storebase 1.2.0
|
|
90
|
+
|
|
91
|
+
# Omit --version to use the pinned default (or newest release if unpinned)
|
|
92
|
+
storebase-telegram metadata storebase
|
|
93
|
+
storebase-telegram download storebase --kind apk --out ./downloads
|
|
94
|
+
|
|
95
|
+
# Select a specific historical version
|
|
96
|
+
storebase-telegram metadata storebase --version 1.1.0
|
|
97
|
+
storebase-telegram download storebase --version 1.1.0 --kind all --out ./downloads
|
|
98
|
+
```
|
|
62
99
|
|
|
63
|
-
|
|
100
|
+
Downloads are size- and SHA-256-verified. Filenames include the app ID, version, artifact kind, and content hash to avoid replacing a different release.
|
|
64
101
|
|
|
65
102
|
## Development
|
|
66
103
|
|
package/bin/storebase-publish.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
require('dotenv').config();
|
|
5
|
+
|
|
4
6
|
const { createInterface } = require('node:readline/promises');
|
|
5
7
|
const { stdin, stdout } = require('node:process');
|
|
8
|
+
const { connectTelegramFromGateway } = require('../lib/gateway-client');
|
|
9
|
+
const { getPublisherEnvironment } = require('../lib/publisher-token');
|
|
6
10
|
const {
|
|
7
11
|
CATEGORIES,
|
|
8
12
|
inspectKeystore,
|
|
@@ -10,9 +14,13 @@ const {
|
|
|
10
14
|
validateInput,
|
|
11
15
|
verifyApkSignature,
|
|
12
16
|
} = require('../lib/publisher');
|
|
17
|
+
const { connectTelegram, disconnectTelegram } = require('../lib/telegram-client');
|
|
18
|
+
const { createTelegramStorage } = require('../lib/telegram-storage');
|
|
13
19
|
|
|
14
20
|
const OPTION_NAMES = new Set([
|
|
15
|
-
'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'catalog', 'assets-dir',
|
|
21
|
+
'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'storage', 'catalog', 'assets-dir',
|
|
22
|
+
'telegram-config',
|
|
23
|
+
'android-version-code', 'minimum-android-version-code', 'delta', 'delta-base-version-code',
|
|
16
24
|
]);
|
|
17
25
|
|
|
18
26
|
function parseArguments(args) {
|
|
@@ -34,7 +42,37 @@ function parseArguments(args) {
|
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
function showHelp() {
|
|
37
|
-
stdout.write(`StoreBase app publisher
|
|
45
|
+
stdout.write(`StoreBase app publisher
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
storebase-publish [options]
|
|
49
|
+
|
|
50
|
+
Options (omitted app details are prompted interactively):
|
|
51
|
+
--name <name> App name
|
|
52
|
+
--avatar <file> PNG, JPEG, or WebP avatar file
|
|
53
|
+
--version <version> App version
|
|
54
|
+
--apk <file> Signed Android APK
|
|
55
|
+
--android-version-code <n> Required for StoreBase Android releases
|
|
56
|
+
--minimum-android-version-code <n> Optional required-upgrade floor
|
|
57
|
+
--delta <file.sbd> Optional StoreBase patch artifact
|
|
58
|
+
--delta-base-version-code <n> Installed base code for the patch
|
|
59
|
+
--category <category> One of the 13 StoreBase categories
|
|
60
|
+
--description <text> App description
|
|
61
|
+
--keystore <file> Signing keystore used for the APK
|
|
62
|
+
--alias <name> Private-key alias, if the keystore has multiple
|
|
63
|
+
--storage <backend> telegram (default) or local
|
|
64
|
+
--telegram-config <mode> gateway (default) or local direct Telegram settings
|
|
65
|
+
--catalog <file> Local app-list JSON (local mode only)
|
|
66
|
+
--assets-dir <directory> Local artifact directory (local mode only)
|
|
67
|
+
-h, --help Show this help
|
|
68
|
+
|
|
69
|
+
Gateway mode uses STOREBASE_GATEWAY_URL (optional; defaults to the StoreBase gateway)
|
|
70
|
+
and STOREBASE_GATEWAY_PUBLISH_TOKEN. The gateway sends encrypted Telegram settings
|
|
71
|
+
to this run only; settings are decrypted in memory and are never written to disk.
|
|
72
|
+
Use --telegram-config local only when supplying Telegram settings directly in the environment.
|
|
73
|
+
The bot must be a channel administrator. Keystore passwords are entered in a hidden
|
|
74
|
+
prompt or read from STOREBASE_KEYSTORE_PASSWORD.
|
|
75
|
+
`);
|
|
38
76
|
}
|
|
39
77
|
|
|
40
78
|
function askHidden(label) {
|
|
@@ -115,6 +153,10 @@ async function collectInput(options) {
|
|
|
115
153
|
description: options.description,
|
|
116
154
|
keystore: options.keystore,
|
|
117
155
|
alias: options.alias,
|
|
156
|
+
androidVersionCode: options['android-version-code'],
|
|
157
|
+
minimumAndroidVersionCode: options['minimum-android-version-code'],
|
|
158
|
+
delta: options.delta,
|
|
159
|
+
deltaBaseVersionCode: options['delta-base-version-code'],
|
|
118
160
|
};
|
|
119
161
|
}
|
|
120
162
|
|
|
@@ -144,6 +186,14 @@ async function main() {
|
|
|
144
186
|
const parsed = parseArguments(process.argv.slice(2));
|
|
145
187
|
if (parsed.help) return showHelp();
|
|
146
188
|
const options = parsed.options;
|
|
189
|
+
options.storage = options.storage || 'telegram';
|
|
190
|
+
if (!['telegram', 'local'].includes(options.storage)) {
|
|
191
|
+
throw new Error('--storage must be either telegram or local.');
|
|
192
|
+
}
|
|
193
|
+
options['telegram-config'] = options['telegram-config'] || 'gateway';
|
|
194
|
+
if (!['gateway', 'local'].includes(options['telegram-config'])) {
|
|
195
|
+
throw new Error('--telegram-config must be either gateway or local.');
|
|
196
|
+
}
|
|
147
197
|
const rawInput = await collectInput(options);
|
|
148
198
|
const input = await validateInput(rawInput);
|
|
149
199
|
|
|
@@ -151,16 +201,41 @@ async function main() {
|
|
|
151
201
|
const entries = await inspectKeystore(input.keystorePath, password);
|
|
152
202
|
const entry = await selectEntry(entries, input.alias);
|
|
153
203
|
const certificate = verifyApkSignature(input.apkPath, entry);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
204
|
+
if (options.storage === 'local') {
|
|
205
|
+
if (input.androidVersionCode !== undefined || input.deltaPath) {
|
|
206
|
+
throw new Error('StoreBase Android update metadata and delta artifacts must be published through Telegram storage.');
|
|
207
|
+
}
|
|
208
|
+
const result = await releaseApp(input, {
|
|
209
|
+
catalogPath: options.catalog,
|
|
210
|
+
assetsDir: options['assets-dir'],
|
|
211
|
+
signingCertificateSha256: certificate,
|
|
212
|
+
});
|
|
213
|
+
stdout.write(`\nLocal release complete: ${result.app.name} ${result.app.version}\n`);
|
|
214
|
+
stdout.write(`App list: ${result.catalogPath}\n`);
|
|
215
|
+
stdout.write(`APK: ${result.app.apk}\n`);
|
|
216
|
+
stdout.write(`Avatar: ${result.app.avatar}\n`);
|
|
217
|
+
} else {
|
|
218
|
+
const connection = options['telegram-config'] === 'local'
|
|
219
|
+
? await connectTelegram()
|
|
220
|
+
: await connectTelegramFromGateway(await getPublisherEnvironment());
|
|
221
|
+
try {
|
|
222
|
+
const storage = createTelegramStorage(connection.client, connection.channelEntity, {
|
|
223
|
+
readerClient: connection.readerClient,
|
|
224
|
+
readerChannel: connection.readerChannel,
|
|
225
|
+
writerChannel: connection.channelEntity,
|
|
226
|
+
channelRef: connection.channel,
|
|
227
|
+
});
|
|
228
|
+
const result = await storage.publish(input, certificate);
|
|
229
|
+
stdout.write(`\nTelegram release ${result.idempotent ? 'already exists' : 'published'}: ${input.name} ${input.version}\n`);
|
|
230
|
+
stdout.write(`Channel: ${connection.channel}\n`);
|
|
231
|
+
stdout.write(`Manifest message ID: ${result.messageId}\n`);
|
|
232
|
+
stdout.write(`APK message ID: ${result.manifest.artifacts.apk.messageId}\n`);
|
|
233
|
+
stdout.write(`Avatar message ID: ${result.manifest.artifacts.avatar.messageId}\n`);
|
|
234
|
+
if (result.manifest.artifacts.delta) stdout.write(`Delta message ID: ${result.manifest.artifacts.delta.messageId}\n`);
|
|
235
|
+
} finally {
|
|
236
|
+
await disconnectTelegram(connection);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
164
239
|
stdout.write('The keystore was used only to verify the APK signature; it was not copied.\n');
|
|
165
240
|
}
|
|
166
241
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
require('dotenv').config();
|
|
5
|
+
|
|
6
|
+
const { stdout, stderr } = require('node:process');
|
|
7
|
+
const {
|
|
8
|
+
connectTelegramFromGateway,
|
|
9
|
+
connectTelegramReaderFromGateway,
|
|
10
|
+
} = require('../lib/gateway-client');
|
|
11
|
+
const { getPublisherEnvironment } = require('../lib/publisher-token');
|
|
12
|
+
const {
|
|
13
|
+
connectTelegram,
|
|
14
|
+
connectTelegramReader,
|
|
15
|
+
disconnectTelegram,
|
|
16
|
+
} = require('../lib/telegram-client');
|
|
17
|
+
const { createTelegramStorage } = require('../lib/telegram-storage');
|
|
18
|
+
|
|
19
|
+
function showHelp() {
|
|
20
|
+
stdout.write(`StoreBase Telegram catalog tools
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
storebase-telegram list [--telegram-config gateway|local]
|
|
24
|
+
storebase-telegram metadata <app-id> [--version <version>] [--telegram-config gateway|local]
|
|
25
|
+
storebase-telegram download <app-id> [--version <version>] [--kind apk|avatar|delta|all] [--out <directory>] [--telegram-config gateway|local]
|
|
26
|
+
storebase-telegram pin <app-id> <version> [--telegram-config gateway|local]
|
|
27
|
+
|
|
28
|
+
Catalog reads use the read-only gateway bootstrap. pin and APK publishing use the
|
|
29
|
+
separate token-protected publisher bootstrap. With no --version, metadata and
|
|
30
|
+
downloads use the app's pinned default, or the newest release if none is pinned.
|
|
31
|
+
Gateway settings are decrypted in memory only.
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseOptions(args) {
|
|
36
|
+
const options = {};
|
|
37
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
38
|
+
const arg = args[index];
|
|
39
|
+
if (!arg.startsWith('--')) throw new Error(`Unexpected argument: ${arg}`);
|
|
40
|
+
const equals = arg.indexOf('=');
|
|
41
|
+
const key = arg.slice(2, equals < 0 ? undefined : equals);
|
|
42
|
+
if (!['version', 'kind', 'out', 'telegram-config'].includes(key)) throw new Error(`Unknown option: --${key}`);
|
|
43
|
+
const value = equals < 0 ? args[++index] : arg.slice(equals + 1);
|
|
44
|
+
if (typeof value !== 'string' || !value.trim() || value.startsWith('--')) throw new Error(`Option --${key} requires a value.`);
|
|
45
|
+
options[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function releaseConnection(mode, needsWrite) {
|
|
51
|
+
if (mode === 'local') return needsWrite ? connectTelegram() : connectTelegramReader();
|
|
52
|
+
if (mode !== 'gateway') throw new Error('--telegram-config must be either gateway or local.');
|
|
53
|
+
return needsWrite
|
|
54
|
+
? connectTelegramFromGateway(await getPublisherEnvironment())
|
|
55
|
+
: connectTelegramReaderFromGateway();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
const [command, ...args] = process.argv.slice(2);
|
|
60
|
+
if (!command || command === '--help' || command === '-h') return showHelp();
|
|
61
|
+
if (!['list', 'metadata', 'download', 'pin'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
62
|
+
const needsApp = ['metadata', 'download', 'pin'].includes(command);
|
|
63
|
+
const appId = needsApp ? args.shift() : undefined;
|
|
64
|
+
if (needsApp && (!appId || appId.startsWith('--'))) throw new Error(`${command} requires an app ID.`);
|
|
65
|
+
const pinVersion = command === 'pin' ? args.shift() : undefined;
|
|
66
|
+
if (command === 'pin' && (!pinVersion || pinVersion.startsWith('--'))) throw new Error('pin requires an app ID and version.');
|
|
67
|
+
const options = parseOptions(args);
|
|
68
|
+
const mode = options['telegram-config'] || 'gateway';
|
|
69
|
+
const connection = await releaseConnection(mode, command === 'pin');
|
|
70
|
+
try {
|
|
71
|
+
const storage = createTelegramStorage(connection.client, connection.channelEntity, {
|
|
72
|
+
readerClient: connection.readerClient,
|
|
73
|
+
readerChannel: connection.readerChannel,
|
|
74
|
+
writerChannel: connection.channelEntity,
|
|
75
|
+
});
|
|
76
|
+
if (command === 'list') {
|
|
77
|
+
const apps = await storage.getCatalog();
|
|
78
|
+
if (!apps.length) {
|
|
79
|
+
stdout.write('No StoreBase releases were found in the configured Telegram channel.\n');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const rows = apps.map(({ appId: id, app, defaultVersion, defaultPinned, versions }) => ({
|
|
83
|
+
appId: id,
|
|
84
|
+
name: app.name,
|
|
85
|
+
category: app.category,
|
|
86
|
+
defaultVersion,
|
|
87
|
+
defaultPinned,
|
|
88
|
+
versions: versions.map(({ manifest, messageId }) => ({
|
|
89
|
+
version: manifest.version,
|
|
90
|
+
publishedAt: manifest.publishedAt,
|
|
91
|
+
manifestMessageId: messageId,
|
|
92
|
+
})),
|
|
93
|
+
}));
|
|
94
|
+
stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (command === 'pin') {
|
|
98
|
+
const result = await storage.pinVersion(appId, pinVersion);
|
|
99
|
+
stdout.write(`Default version ${result.idempotent ? 'already set' : 'set'}: ${appId} ${pinVersion}\n`);
|
|
100
|
+
stdout.write(`Telegram message ID: ${result.messageId}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (command === 'metadata') {
|
|
104
|
+
const [release, apps] = await Promise.all([storage.getMetadata(appId, options.version), storage.getCatalog()]);
|
|
105
|
+
const app = apps.find((entry) => entry.appId === appId);
|
|
106
|
+
stdout.write(`${JSON.stringify({ ...release, defaultVersion: app?.defaultVersion }, null, 2)}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const kind = options.kind || 'all';
|
|
110
|
+
const result = await storage.download(appId, options.version, kind, options.out || './storebase-downloads');
|
|
111
|
+
stdout.write(`Release: ${result.release.manifest.app.name} ${result.release.manifest.version}\n`);
|
|
112
|
+
for (const artifact of result.downloaded) stdout.write(`${artifact.kind}: ${artifact.path} (${artifact.size} bytes, sha256 ${artifact.sha256})\n`);
|
|
113
|
+
} finally {
|
|
114
|
+
await disconnectTelegram(connection);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((error) => {
|
|
119
|
+
stderr.write(`storebase-telegram: ${error.message}\n`);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
createDecipheriv,
|
|
5
|
+
generateKeyPairSync,
|
|
6
|
+
privateDecrypt,
|
|
7
|
+
constants,
|
|
8
|
+
sign,
|
|
9
|
+
randomUUID,
|
|
10
|
+
} = require('node:crypto');
|
|
11
|
+
|
|
12
|
+
const PROTOCOL = 'STOREBASE_GATEWAY_V1';
|
|
13
|
+
const APP_ID = 'com.nexuss0781.storebase';
|
|
14
|
+
const CATALOG_BOOTSTRAP_PATH = '/api/v1/catalog/bootstrap';
|
|
15
|
+
const PUBLISHER_BOOTSTRAP_PATH = '/api/v1/publisher/bootstrap';
|
|
16
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
|
|
17
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + 64 * 1024;
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
throw new Error(message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function base64Bytes(value, label, maxBytes) {
|
|
24
|
+
if (typeof value !== 'string' || !value || value.length > Math.ceil(maxBytes * 4 / 3) + 8) {
|
|
25
|
+
fail(`Gateway response has an invalid ${label}.`);
|
|
26
|
+
}
|
|
27
|
+
const decoded = Buffer.from(value, 'base64');
|
|
28
|
+
if (!decoded.length || decoded.length > maxBytes || decoded.toString('base64') !== value) {
|
|
29
|
+
fail(`Gateway response has an invalid ${label}.`);
|
|
30
|
+
}
|
|
31
|
+
return decoded;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonicalRequest({ appId, requestId, timestamp, publicKey, publishToken, path }) {
|
|
35
|
+
const fields = [PROTOCOL, 'POST', path, appId, requestId, String(timestamp), publicKey];
|
|
36
|
+
if (path === PUBLISHER_BOOTSTRAP_PATH) fields.push(publishToken || '');
|
|
37
|
+
return fields.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalPublisherRequest(request) {
|
|
41
|
+
return canonicalRequest({ ...request, path: PUBLISHER_BOOTSTRAP_PATH });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function envelopeAad(envelope) {
|
|
45
|
+
return [PROTOCOL, APP_ID, envelope.requestId, envelope.issuedAt].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateCatalogSettings(settings) {
|
|
49
|
+
const telegram = settings?.telegram;
|
|
50
|
+
if (!settings || settings.schemaVersion !== 1 || settings.settingsVersion !== 1
|
|
51
|
+
|| !telegram || !Number.isSafeInteger(telegram.apiId) || telegram.apiId <= 0
|
|
52
|
+
|| typeof telegram.apiHash !== 'string' || !telegram.apiHash
|
|
53
|
+
|| typeof telegram.readSession !== 'string' || !telegram.readSession
|
|
54
|
+
|| typeof telegram.channel !== 'string' || !telegram.channel.trim()
|
|
55
|
+
|| Object.hasOwn(telegram, 'botSession') || Object.hasOwn(telegram, 'botToken')) {
|
|
56
|
+
fail('Gateway returned invalid read-only Telegram settings.');
|
|
57
|
+
}
|
|
58
|
+
return settings;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validatePublisherSettings(settings) {
|
|
62
|
+
const telegram = settings?.telegram;
|
|
63
|
+
if (!settings || settings.schemaVersion !== 1 || settings.settingsVersion !== 1
|
|
64
|
+
|| !telegram || !Number.isSafeInteger(telegram.apiId) || telegram.apiId <= 0
|
|
65
|
+
|| typeof telegram.apiHash !== 'string' || !telegram.apiHash
|
|
66
|
+
|| typeof telegram.botSession !== 'string' || !telegram.botSession
|
|
67
|
+
|| typeof telegram.readSession !== 'string' || !telegram.readSession
|
|
68
|
+
|| typeof telegram.channel !== 'string' || !telegram.channel.trim()
|
|
69
|
+
|| Object.hasOwn(telegram, 'botToken')) {
|
|
70
|
+
fail('Gateway returned invalid Telegram publisher settings.');
|
|
71
|
+
}
|
|
72
|
+
return settings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decryptSettings(envelope, privateKey, requestId, validator = validatePublisherSettings) {
|
|
76
|
+
if (!envelope || envelope.protocol !== PROTOCOL || envelope.appId !== APP_ID
|
|
77
|
+
|| envelope.requestId !== requestId || typeof envelope.issuedAt !== 'string'
|
|
78
|
+
|| envelope.keyWrap !== 'RSA-OAEP-SHA256' || envelope.contentEncryption !== 'AES-256-GCM') {
|
|
79
|
+
fail('Gateway returned an invalid or mismatched encrypted settings envelope.');
|
|
80
|
+
}
|
|
81
|
+
const wrappedKey = base64Bytes(envelope.encryptedKey, 'wrapped key', 16 * 1024);
|
|
82
|
+
const iv = base64Bytes(envelope.iv, 'initialization vector', 32);
|
|
83
|
+
const ciphertext = base64Bytes(envelope.ciphertext, 'ciphertext', MAX_RESPONSE_BYTES);
|
|
84
|
+
const tag = base64Bytes(envelope.tag, 'authentication tag', 32);
|
|
85
|
+
if (iv.length !== 12 || tag.length !== 16) fail('Gateway returned an invalid encryption envelope.');
|
|
86
|
+
|
|
87
|
+
let contentKey;
|
|
88
|
+
let plaintext;
|
|
89
|
+
try {
|
|
90
|
+
contentKey = privateDecrypt({
|
|
91
|
+
key: privateKey,
|
|
92
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
93
|
+
oaepHash: 'sha256',
|
|
94
|
+
}, wrappedKey);
|
|
95
|
+
if (contentKey.length !== 32) fail('Gateway returned an invalid content key.');
|
|
96
|
+
const decipher = createDecipheriv('aes-256-gcm', contentKey, iv);
|
|
97
|
+
decipher.setAAD(Buffer.from(envelopeAad(envelope), 'utf8'));
|
|
98
|
+
decipher.setAuthTag(tag);
|
|
99
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
100
|
+
return validator(JSON.parse(plaintext.toString('utf8')));
|
|
101
|
+
} catch {
|
|
102
|
+
fail('Could not authenticate or decrypt the gateway settings response.');
|
|
103
|
+
} finally {
|
|
104
|
+
contentKey?.fill(0);
|
|
105
|
+
plaintext?.fill(0);
|
|
106
|
+
wrappedKey.fill(0);
|
|
107
|
+
iv.fill(0);
|
|
108
|
+
ciphertext.fill(0);
|
|
109
|
+
tag.fill(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveGatewayUrl(env, pathname) {
|
|
114
|
+
const configured = env.STOREBASE_GATEWAY_URL || 'https://storebase.wasmer.app';
|
|
115
|
+
let base;
|
|
116
|
+
try {
|
|
117
|
+
base = new URL(configured);
|
|
118
|
+
} catch {
|
|
119
|
+
fail('STOREBASE_GATEWAY_URL must be a valid HTTPS URL.');
|
|
120
|
+
}
|
|
121
|
+
const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(base.hostname);
|
|
122
|
+
if ((base.protocol !== 'https:' && !loopback) || base.username || base.password || base.search || base.hash) {
|
|
123
|
+
fail('STOREBASE_GATEWAY_URL must use HTTPS and must not contain credentials, query parameters, or a fragment.');
|
|
124
|
+
}
|
|
125
|
+
return new URL(pathname, base);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function fetchEncryptedSettings(env, pathname, validator, options = {}) {
|
|
129
|
+
const publisher = pathname === PUBLISHER_BOOTSTRAP_PATH;
|
|
130
|
+
const publishToken = publisher ? env.STOREBASE_GATEWAY_PUBLISH_TOKEN : undefined;
|
|
131
|
+
if (publisher && (typeof publishToken !== 'string' || !TOKEN_PATTERN.test(publishToken))) {
|
|
132
|
+
fail('Set STOREBASE_GATEWAY_PUBLISH_TOKEN to the high-entropy publisher access token.');
|
|
133
|
+
}
|
|
134
|
+
const endpoint = resolveGatewayUrl(env, pathname);
|
|
135
|
+
const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
136
|
+
const publicKey = keyPair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
|
137
|
+
const requestId = (options.randomUUID || randomUUID)();
|
|
138
|
+
const timestamp = (options.now || Date.now)();
|
|
139
|
+
const unsigned = {
|
|
140
|
+
appId: APP_ID,
|
|
141
|
+
requestId,
|
|
142
|
+
timestamp,
|
|
143
|
+
publicKey,
|
|
144
|
+
...(publisher ? { publishToken } : {}),
|
|
145
|
+
};
|
|
146
|
+
const signature = sign(
|
|
147
|
+
'RSA-SHA256',
|
|
148
|
+
Buffer.from(canonicalRequest({ ...unsigned, path: pathname }), 'utf8'),
|
|
149
|
+
keyPair.privateKey,
|
|
150
|
+
).toString('base64');
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 15_000);
|
|
153
|
+
timeout.unref?.();
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const response = await (options.fetch || globalThis.fetch)(endpoint, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
redirect: 'error',
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
161
|
+
body: JSON.stringify({ ...unsigned, signature }),
|
|
162
|
+
});
|
|
163
|
+
const length = Number(response.headers?.get?.('content-length'));
|
|
164
|
+
if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) fail('Gateway response exceeded the allowed size.');
|
|
165
|
+
if (!response.ok) fail(`Gateway bootstrap failed with HTTP ${response.status}.`);
|
|
166
|
+
const text = await response.text();
|
|
167
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('Gateway response exceeded the allowed size.');
|
|
168
|
+
let envelope;
|
|
169
|
+
try { envelope = JSON.parse(text); } catch { fail('Gateway returned an invalid response.'); }
|
|
170
|
+
return decryptSettings(envelope, keyPair.privateKey, requestId, validator);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (/^Gateway |^Set STOREBASE_|^Could not authenticate|^Gateway response/.test(error.message || '')) throw error;
|
|
173
|
+
if (error?.name === 'AbortError') fail('Gateway bootstrap timed out.');
|
|
174
|
+
fail('Could not connect to the StoreBase gateway bootstrap.');
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function fetchPublisherSettings(env = process.env, options = {}) {
|
|
181
|
+
return fetchEncryptedSettings(env, PUBLISHER_BOOTSTRAP_PATH, validatePublisherSettings, options);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchCatalogSettings(env = process.env, options = {}) {
|
|
185
|
+
return fetchEncryptedSettings(env, CATALOG_BOOTSTRAP_PATH, validateCatalogSettings, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function scrubConnectionError(error, runtimeEnv) {
|
|
189
|
+
let message = String(error?.message || 'Telegram connection failed.');
|
|
190
|
+
for (const secret of Object.values(runtimeEnv)) {
|
|
191
|
+
if (secret && secret.length > 8) message = message.split(secret).join('[redacted]');
|
|
192
|
+
}
|
|
193
|
+
return new Error(message);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function connectTelegramFromGateway(env = process.env, options = {}) {
|
|
197
|
+
const settings = await (options.fetchSettings || fetchPublisherSettings)(env, options);
|
|
198
|
+
const telegram = settings.telegram;
|
|
199
|
+
const runtimeEnv = {
|
|
200
|
+
STOREBASE_TELEGRAM_API_ID: String(telegram.apiId),
|
|
201
|
+
STOREBASE_TELEGRAM_API_HASH: telegram.apiHash,
|
|
202
|
+
STOREBASE_TELEGRAM_SESSION: telegram.botSession,
|
|
203
|
+
STOREBASE_TELEGRAM_READ_SESSION: telegram.readSession,
|
|
204
|
+
STOREBASE_TELEGRAM_CHANNEL: telegram.channel,
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const connector = options.connectTelegram || require('./telegram-client').connectTelegram;
|
|
208
|
+
const connection = await connector(runtimeEnv, { requireReader: true });
|
|
209
|
+
delete connection.session;
|
|
210
|
+
delete connection.readSession;
|
|
211
|
+
delete connection.hasStoredSession;
|
|
212
|
+
return connection;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw scrubConnectionError(error, runtimeEnv);
|
|
215
|
+
} finally {
|
|
216
|
+
for (const key of Object.keys(runtimeEnv)) runtimeEnv[key] = '';
|
|
217
|
+
telegram.apiHash = '';
|
|
218
|
+
telegram.botSession = '';
|
|
219
|
+
telegram.readSession = '';
|
|
220
|
+
telegram.channel = '';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function connectTelegramReaderFromGateway(env = process.env, options = {}) {
|
|
225
|
+
const settings = await (options.fetchSettings || fetchCatalogSettings)(env, options);
|
|
226
|
+
const telegram = settings.telegram;
|
|
227
|
+
const runtimeEnv = {
|
|
228
|
+
STOREBASE_TELEGRAM_API_ID: String(telegram.apiId),
|
|
229
|
+
STOREBASE_TELEGRAM_API_HASH: telegram.apiHash,
|
|
230
|
+
STOREBASE_TELEGRAM_READ_SESSION: telegram.readSession,
|
|
231
|
+
STOREBASE_TELEGRAM_CHANNEL: telegram.channel,
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
const connector = options.connectTelegramReader || require('./telegram-client').connectTelegramReader;
|
|
235
|
+
return await connector(runtimeEnv);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw scrubConnectionError(error, runtimeEnv);
|
|
238
|
+
} finally {
|
|
239
|
+
for (const key of Object.keys(runtimeEnv)) runtimeEnv[key] = '';
|
|
240
|
+
telegram.apiHash = '';
|
|
241
|
+
telegram.readSession = '';
|
|
242
|
+
telegram.channel = '';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
APP_ID,
|
|
248
|
+
CATALOG_BOOTSTRAP_PATH,
|
|
249
|
+
PUBLISHER_BOOTSTRAP_PATH,
|
|
250
|
+
canonicalPublisherRequest,
|
|
251
|
+
canonicalRequest,
|
|
252
|
+
connectTelegramFromGateway,
|
|
253
|
+
connectTelegramReaderFromGateway,
|
|
254
|
+
decryptSettings,
|
|
255
|
+
fetchCatalogSettings,
|
|
256
|
+
fetchPublisherSettings,
|
|
257
|
+
validateCatalogSettings,
|
|
258
|
+
validatePublisherSettings,
|
|
259
|
+
};
|