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 CHANGED
@@ -1,41 +1,57 @@
1
1
  # StoreBase App Publisher CLI
2
2
 
3
- A dependency-free npm CLI for validating and releasing a signed Android APK into a local StoreBase app-list catalog. This package is intentionally isolated under `cli/`; it does not change the StoreBase frontend.
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
- ## Install from this repository
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
- The CLI prompts for the app name, avatar image, version, APK, category, description, and signing keystore. The supported categories match StoreBase Explore:
37
+ ## Runtime configuration
19
38
 
20
- - Education
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
- The keystore password is requested with hidden terminal input. For automation, set `STOREBASE_KEYSTORE_PASSWORD` in the process environment instead. **Do not put the password in a command-line argument.** If the keystore contains more than one private-key alias, the CLI asks which alias to use (or accepts `--alias`).
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 CLI runs `keytool` and `apksigner`, and refuses to add the release unless the APK is signed and its signer certificate matches the selected private-key certificate in the provided keystore. The password is written briefly to a mode-0600 temporary file for `keytool`, then the file is overwritten best-effort and removed. The keystore itself is never copied to the catalog.
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
- ## Non-interactive example
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
- Available options: `--name`, `--avatar`, `--version`, `--apk`, `--category`, `--description`, `--keystore`, `--alias`, `--catalog`, `--assets-dir`, and `--help`.
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
- ## Output
76
+ ## Telegram storage and versions
55
77
 
56
- By default, running from a project directory creates or updates:
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
- - `storebase-app-list.json` — schema version 1, with one latest-version listing per app name/slug.
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
- The catalog entry contains the app name, avatar path, version, APK path, category, description, the public SHA-256 signing-certificate fingerprint, and publish timestamp. Releasing the same app again updates its catalog entry; a different version gets a separate artifact path. The previous artifacts are not removed.
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
- The current StoreBase repository has no app-list API or deployment endpoint. Therefore this CLI creates a **local app-list release artifact only**; it does not upload to a server, publish the npm package, or make the app appear in the existing UI yet. A later frontend/backend change can consume `storebase-app-list.json` without changing this CLI's input contract.
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
 
@@ -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\n\nUsage:\n storebase-publish [options]\n\nOptions (omitted values are prompted interactively):\n --name <name> App name\n --avatar <file> PNG, JPEG, or WebP avatar file\n --version <version> App version\n --apk <file> Signed Android APK\n --category <category> One of the 13 StoreBase categories\n --description <text> App description\n --keystore <file> Signing keystore used for the APK\n --alias <name> Private-key alias, if the keystore has multiple\n --catalog <file> App-list JSON (default: ./storebase-app-list.json)\n --assets-dir <directory> Artifact directory (default: next to the catalog)\n -h, --help Show this help\n\nThe keystore password is entered in a hidden prompt, or read from\nSTOREBASE_KEYSTORE_PASSWORD for unattended use. It is never accepted as a CLI argument.\n`);
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
- const result = await releaseApp(input, {
155
- catalogPath: options.catalog,
156
- assetsDir: options['assets-dir'],
157
- signingCertificateSha256: certificate,
158
- });
159
-
160
- stdout.write(`\nRelease complete: ${result.app.name} ${result.app.version}\n`);
161
- stdout.write(`App list: ${result.catalogPath}\n`);
162
- stdout.write(`APK: ${result.app.apk}\n`);
163
- stdout.write(`Avatar: ${result.app.avatar}\n`);
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
+ };