storebase-cli 0.0.0-stage → 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.
@@ -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
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const { stdin, stdout } = require('node:process');
3
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
4
+
5
+ function fail(message) {
6
+ throw new Error(message);
7
+ }
8
+
9
+ function readHiddenToken(input = stdin, output = stdout) {
10
+ if (!input.isTTY || typeof input.setRawMode !== 'function' || !output.isTTY) {
11
+ return Promise.reject(new Error('Set STOREBASE_GATEWAY_PUBLISH_TOKEN in the environment for non-interactive use.'));
12
+ }
13
+ return new Promise((resolve, reject) => {
14
+ let value = '';
15
+ let finished = false;
16
+ const cleanup = () => {
17
+ input.removeListener('data', onData);
18
+ try { input.setRawMode(false); } catch {}
19
+ input.pause();
20
+ output.write('\n');
21
+ };
22
+ const finish = (error) => {
23
+ if (finished) return;
24
+ finished = true;
25
+ cleanup();
26
+ if (error) reject(error);
27
+ else resolve(value);
28
+ };
29
+ const onData = (chunk) => {
30
+ for (const character of String(chunk)) {
31
+ if (character === '\u0003') return finish(new Error('Cancelled.'));
32
+ if (character === '\r' || character === '\n') return finish();
33
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
34
+ else if (character >= ' ' && value.length < 256) value += character;
35
+ }
36
+ };
37
+ output.write('StoreBase gateway publisher key (input hidden): ');
38
+ input.setRawMode(true);
39
+ input.setEncoding('utf8');
40
+ input.resume();
41
+ input.on('data', onData);
42
+ });
43
+ }
44
+
45
+ async function getPublisherEnvironment(env = process.env, input = stdin, output = stdout) {
46
+ let token = env.STOREBASE_GATEWAY_PUBLISH_TOKEN;
47
+ if (token === undefined || token === '') token = await readHiddenToken(input, output);
48
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
49
+ fail('STOREBASE_GATEWAY_PUBLISH_TOKEN must be a high-entropy 32–256 character token using letters, numbers, underscores, or hyphens.');
50
+ }
51
+ const runtimeEnv = { STOREBASE_GATEWAY_PUBLISH_TOKEN: token };
52
+ if (typeof env.STOREBASE_GATEWAY_URL === 'string' && env.STOREBASE_GATEWAY_URL) {
53
+ runtimeEnv.STOREBASE_GATEWAY_URL = env.STOREBASE_GATEWAY_URL;
54
+ }
55
+ return runtimeEnv;
56
+ }
57
+
58
+ module.exports = { TOKEN_PATTERN, getPublisherEnvironment, readHiddenToken };