diff --git a/.env.example b/.env.example index b1c2e65fb..9a5dba333 100644 --- a/.env.example +++ b/.env.example @@ -154,3 +154,12 @@ NX_PUBLIC_STRIPE_PUBLIC_KEY='' BASIC_AUTH_USERNAME='api' BASIC_AUTH_PASSWORD='test' + +# S3-compatible object storage for desktop release artifacts (Cloudflare R2). +# Used by the API to read the update feed and by electron-builder to publish. +# Standard AWS_* names are auto-detected by the AWS SDK/CLI and S3-compatible tooling. +AWS_ACCESS_KEY_ID='' +AWS_SECRET_ACCESS_KEY='' +AWS_ENDPOINT_URL='' # e.g. https://.r2.cloudflarestorage.com +AWS_REGION='auto' +S3_BUCKET_NAME='desktop-updates' diff --git a/.github/workflows/publish-desktop-macos.yml b/.github/workflows/publish-desktop-macos.yml index a50ea2157..da3b471fc 100644 --- a/.github/workflows/publish-desktop-macos.yml +++ b/.github/workflows/publish-desktop-macos.yml @@ -43,7 +43,7 @@ jobs: # Developer ID cert — electron-builder imports this into a temp keychain CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # Backblaze B2 for hosting the update feed + # S3-compatible release storage creds (Backblaze B2 during the Cloudflare R2 sunset) AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -92,12 +92,12 @@ jobs: JETSTREAM_POSTGRES_DBURI: "postgresql://placeholder:placeholder@localhost:5432/placeholder" # Builds the nx desktop projects and assembles dist/desktop-build, - # writing the .env (Apple + Backblaze creds) that electron-builder reads. + # writing the .env (Apple + S3-compatible storage creds) that electron-builder reads. - name: Build desktop bundle run: pnpm build:desktop - # electron-builder --mac -p always: signs, notarizes, and publishes the - # dmg/zip installers + update metadata to the Backblaze feed. + # electron-builder --mac -p always: signs, notarizes, uploads the dmg/zip installers + # + update metadata to the Backblaze bucket, and points clients at the release feed subdomain. - name: Package, sign, notarize, and publish macOS installers working-directory: dist/desktop-build run: pnpm publish:mac diff --git a/.github/workflows/publish-desktop-windows.yml b/.github/workflows/publish-desktop-windows.yml index dd66528ef..080d19857 100644 --- a/.github/workflows/publish-desktop-windows.yml +++ b/.github/workflows/publish-desktop-windows.yml @@ -40,7 +40,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - # Backblaze B2 for hosting the update feed + # S3-compatible release storage creds (Backblaze B2 during the Cloudflare R2 sunset) AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -77,12 +77,12 @@ jobs: JETSTREAM_POSTGRES_DBURI: "postgresql://placeholder:placeholder@localhost:5432/placeholder" # Builds the nx desktop projects and assembles dist/desktop-build, - # writing the .env (Azure + Backblaze creds) that electron-builder reads. + # writing the .env (Azure + S3-compatible storage creds) that electron-builder reads. - name: Build desktop bundle run: pnpm build:desktop - # electron-builder --win -p always: signs via Azure Trusted Signing and - # publishes installers + update metadata to the Backblaze feed. + # electron-builder --win -p always: signs via Azure Trusted Signing, uploads installers + # + update metadata to the Backblaze bucket, and points clients at the release feed subdomain. - name: Package, sign, and publish Windows installers working-directory: dist/desktop-build run: pnpm publish:win diff --git a/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts b/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts index 700640fe2..f3520ace1 100644 --- a/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts +++ b/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts @@ -3,10 +3,11 @@ import { getLatestDesktopVersion } from '../desktop-asset.service'; const apiConfigMock = vi.hoisted(() => ({ ENV: { - BACKBLAZE_ACCESS_KEY_ID: '', - BACKBLAZE_BUCKET_NAME: 'bucket', - BACKBLAZE_REGION: 'us-west-001', - BACKBLAZE_SECRET_ACCESS_KEY: '', + AWS_ACCESS_KEY_ID: '', + AWS_SECRET_ACCESS_KEY: '', + AWS_ENDPOINT_URL: 'https://s3.example.com', + AWS_REGION: 'auto', + S3_BUCKET_NAME: 'bucket', }, logger: { error: vi.fn(), @@ -19,15 +20,29 @@ vi.mock('@jetstream/api-config', () => apiConfigMock); describe('getLatestDesktopVersion', () => { beforeEach(() => { vi.clearAllMocks(); - apiConfigMock.ENV.BACKBLAZE_ACCESS_KEY_ID = ''; - apiConfigMock.ENV.BACKBLAZE_SECRET_ACCESS_KEY = ''; + apiConfigMock.ENV.AWS_ACCESS_KEY_ID = ''; + apiConfigMock.ENV.AWS_SECRET_ACCESS_KEY = ''; + apiConfigMock.ENV.AWS_ENDPOINT_URL = 'https://s3.example.com'; }); it('returns null instead of throwing when release storage credentials are unavailable', async () => { await expect(getLatestDesktopVersion({ platform: 'windows', arch: 'x64' })).resolves.toBeNull(); expect(apiConfigMock.logger.warn).toHaveBeenCalledWith( - 'BackBlaze credentials are not set; desktop downloads are unavailable for windows/x64', + 'Object storage credentials are not set; desktop downloads are unavailable for windows/x64', + ); + }); + + it('returns null when the storage endpoint URL is not configured', async () => { + apiConfigMock.ENV.AWS_ACCESS_KEY_ID = 'access-key'; + apiConfigMock.ENV.AWS_SECRET_ACCESS_KEY = 'secret-key'; + apiConfigMock.ENV.AWS_ENDPOINT_URL = ''; + + // Use a distinct platform/arch so the cached null from the previous test does not short-circuit the credentials check + await expect(getLatestDesktopVersion({ platform: 'macos', arch: 'arm64' })).resolves.toBeNull(); + + expect(apiConfigMock.logger.warn).toHaveBeenCalledWith( + 'Object storage credentials are not set; desktop downloads are unavailable for macos/arm64', ); }); }); diff --git a/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts.skip b/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts.skip index fa67eb8fa..3af284997 100644 --- a/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts.skip +++ b/apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts.skip @@ -14,10 +14,11 @@ vi.mock('@aws-sdk/client-s3', () => { vi.mock('@jetstream/api-config', () => ({ ENV: { - BACKBLAZE_ACCESS_KEY_ID: 'test-access-key', - BACKBLAZE_SECRET_ACCESS_KEY: 'test-secret-key', - BACKBLAZE_REGION: 'us-west-001', - BACKBLAZE_BUCKET_NAME: 'test-bucket', + AWS_ACCESS_KEY_ID: 'test-access-key', + AWS_SECRET_ACCESS_KEY: 'test-secret-key', + AWS_ENDPOINT_URL: 'https://s3.example.com', + AWS_REGION: 'auto', + S3_BUCKET_NAME: 'test-bucket', }, })); @@ -43,10 +44,11 @@ describe('desktop-asset.service', () => { vi.doMock('@jetstream/api-config', () => ({ ENV: { - BACKBLAZE_ACCESS_KEY_ID: 'test-access-key', - BACKBLAZE_SECRET_ACCESS_KEY: 'test-secret-key', - BACKBLAZE_REGION: 'us-west-001', - BACKBLAZE_BUCKET_NAME: 'test-bucket', + AWS_ACCESS_KEY_ID: 'test-access-key', + AWS_SECRET_ACCESS_KEY: 'test-secret-key', + AWS_ENDPOINT_URL: 'https://s3.example.com', + AWS_REGION: 'auto', + S3_BUCKET_NAME: 'test-bucket', }, })); @@ -231,10 +233,12 @@ releaseDate: '2025-09-22T13:36:41.519Z'`; consoleErrorSpy.mockRestore(); }); - it('should throw error when BackBlaze credentials are not set', async () => { + it('should return null when object storage credentials are not set', async () => { // Reset modules and re-mock with no credentials vi.resetModules(); + const warn = vi.fn(); + vi.doMock('@aws-sdk/client-s3', () => ({ GetObjectCommand: vi.fn((input) => ({ input })), S3Client: vi.fn(() => ({ @@ -244,10 +248,15 @@ releaseDate: '2025-09-22T13:36:41.519Z'`; vi.doMock('@jetstream/api-config', () => ({ ENV: { - BACKBLAZE_ACCESS_KEY_ID: undefined, - BACKBLAZE_SECRET_ACCESS_KEY: undefined, - BACKBLAZE_REGION: 'us-west-001', - BACKBLAZE_BUCKET_NAME: 'test-bucket', + AWS_ACCESS_KEY_ID: undefined, + AWS_SECRET_ACCESS_KEY: undefined, + AWS_ENDPOINT_URL: 'https://s3.example.com', + AWS_REGION: 'auto', + S3_BUCKET_NAME: 'test-bucket', + }, + logger: { + error: vi.fn(), + warn, }, })); @@ -255,9 +264,8 @@ releaseDate: '2025-09-22T13:36:41.519Z'`; const module = require('../desktop-asset.service'); const getLatestDesktopVersionWithoutCreds = module.getLatestDesktopVersion; - await expect(getLatestDesktopVersionWithoutCreds({ platform: 'windows', arch: 'x64' })).rejects.toThrow( - 'BackBlaze credentials are not set in environment variables', - ); + await expect(getLatestDesktopVersionWithoutCreds({ platform: 'windows', arch: 'x64' })).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith('Object storage credentials are not set; desktop downloads are unavailable for windows/x64'); }); it('should handle empty files array in response', async () => { diff --git a/apps/api/src/app/services/desktop-asset.service.ts b/apps/api/src/app/services/desktop-asset.service.ts index 8cba8a815..0e22e90c3 100644 --- a/apps/api/src/app/services/desktop-asset.service.ts +++ b/apps/api/src/app/services/desktop-asset.service.ts @@ -41,7 +41,7 @@ async function getAndParseVersionFile(s3Client: S3Client, key: string) { return s3Client .send( new GetObjectCommand({ - Bucket: ENV.BACKBLAZE_BUCKET_NAME, + Bucket: ENV.S3_BUCKET_NAME, Key: key, }), ) @@ -58,18 +58,18 @@ export async function getLatestDesktopVersion({ arch, platform }: PlatformArch): return cached.data; } - if (!ENV.BACKBLAZE_ACCESS_KEY_ID || !ENV.BACKBLAZE_SECRET_ACCESS_KEY) { - logger.warn(`BackBlaze credentials are not set; desktop downloads are unavailable for ${platform}/${arch}`); + if (!ENV.AWS_ACCESS_KEY_ID || !ENV.AWS_SECRET_ACCESS_KEY || !ENV.AWS_ENDPOINT_URL) { + logger.warn(`Object storage credentials are not set; desktop downloads are unavailable for ${platform}/${arch}`); versionCache.set(cacheKey, { data: null, expiry: Date.now() + CACHE_DURATION_MS }); return null; } const s3Client = new S3Client({ - endpoint: `https://s3.${ENV.BACKBLAZE_REGION}.backblazeb2.com`, - region: ENV.BACKBLAZE_REGION, + endpoint: ENV.AWS_ENDPOINT_URL, + region: ENV.AWS_REGION, credentials: { - accessKeyId: ENV.BACKBLAZE_ACCESS_KEY_ID, - secretAccessKey: ENV.BACKBLAZE_SECRET_ACCESS_KEY, + accessKeyId: ENV.AWS_ACCESS_KEY_ID, + secretAccessKey: ENV.AWS_SECRET_ACCESS_KEY, }, }); diff --git a/apps/cron-tasks/src/database-backup-cron/backup.sh b/apps/cron-tasks/src/database-backup-cron/backup.sh index 5f74e1c61..de6f27392 100644 --- a/apps/cron-tasks/src/database-backup-cron/backup.sh +++ b/apps/cron-tasks/src/database-backup-cron/backup.sh @@ -5,7 +5,6 @@ set -o errexit -o nounset -o pipefail -export AWS_ENDPOINT_URL="https://s3.$AWS_REGION.backblazeb2.com" export AWS_PAGER="" # validate required environment variables @@ -34,6 +33,11 @@ if [ -z "$AWS_REGION" ]; then exit 1 fi +if [ -z "${AWS_ENDPOINT_URL:-}" ]; then + echo "Error: AWS_ENDPOINT_URL is not set" >&2 + exit 1 +fi + echo "Using S3 bucket: $S3_BUCKET_NAME in region: $AWS_REGION" echo "Endpoint URL: $AWS_ENDPOINT_URL" diff --git a/apps/jetstream-desktop/package.json b/apps/jetstream-desktop/package.json index f84b9891a..d2400bbeb 100644 --- a/apps/jetstream-desktop/package.json +++ b/apps/jetstream-desktop/package.json @@ -12,8 +12,6 @@ "postinstall": "electron-builder install-app-deps", "build:mac": "electron-builder build --mac --config electron-builder.config.js", "build:win": "electron-builder build --win --config electron-builder.config.js", - "publish:mac:dev": "electron-builder build --mac --config electron-builder.config.js --publish always --config.publish.provider=s3 --config.publish.endpoint=http://localhost:9000 --config.publish.bucket=desktop-updates --config.publish.path=jetstream/releases", - "publish:win:dev": "electron-builder build --win --config electron-builder.config.js --publish always --config.publish.provider=s3 --config.publish.endpoint=http://localhost:9000 --config.publish.bucket=desktop-updates --config.publish.path=jetstream/releases", "publish:mac": "electron-builder build --mac --config electron-builder.config.js -p always", "publish:win": "electron-builder build --win --config electron-builder.config.js -p always" }, diff --git a/electron-builder.config.js b/electron-builder.config.js index 963cabbd3..668a76f0a 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -183,6 +183,14 @@ const config = { publish: ENV.IS_CODESIGNING_ENABLED && ENV.AWS_ACCESS_KEY_ID && ENV.AWS_SECRET_ACCESS_KEY ? [ + // Primary feed clients read from — a subdomain we control, decoupled from any + // storage vendor. Backed by Backblaze today, Cloudflare R2 after the DNS cutover. + { + provider: 'generic', + url: 'https://release-updates.getjetstream.app/jetstream/releases', + }, + // Upload target during the sunset: keep publishing to Backblaze so existing clients + // (pinned to the raw B2 endpoint in their baked app-update.yml) keep updating. { provider: 's3', // Local testing with MinIO diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index bc47a3852..4ed26f41e 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -281,12 +281,14 @@ const envSchema = z.object({ }), /** - * BackBlaze B2 + * S3-compatible object storage for desktop release artifacts (Cloudflare R2). + * Standard AWS_* names are auto-detected by the AWS SDK/CLI and S3-compatible tooling. */ - BACKBLAZE_ACCESS_KEY_ID: z.string().default(''), - BACKBLAZE_SECRET_ACCESS_KEY: z.string().default(''), - BACKBLAZE_BUCKET_NAME: z.string().default('desktop-updates'), - BACKBLAZE_REGION: z.string().default('us-east-005'), + AWS_ACCESS_KEY_ID: z.string().default(''), + AWS_SECRET_ACCESS_KEY: z.string().default(''), + AWS_ENDPOINT_URL: z.string().default(''), + AWS_REGION: z.string().default('auto'), + S3_BUCKET_NAME: z.string().default('desktop-updates'), }); const parseResults = envSchema.safeParse({ diff --git a/scripts/build-electron.mjs b/scripts/build-electron.mjs index 5c7c2333e..dea1e75a9 100644 --- a/scripts/build-electron.mjs +++ b/scripts/build-electron.mjs @@ -169,8 +169,6 @@ async function build() { await remove(join(TARGET_DIR, 'node_modules/.cache')); // Create .env file with the secrets electron-builder needs at package/publish time. - // Resolve values up front so the Backblaze->AWS fallback below is based on actual - // env values rather than substring-matching the serialized file content. const envValues = {}; for (const key of [ 'IS_CODESIGNING_ENABLED', @@ -183,8 +181,6 @@ async function build() { 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', - 'BACKBLAZE_ACCESS_KEY_ID', - 'BACKBLAZE_SECRET_ACCESS_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', ]) { @@ -193,11 +189,6 @@ async function build() { } } - // electron-builder publishes to the S3-compatible Backblaze bucket via the AWS_* - // credentials, so fall back to the Backblaze keys when AWS_* aren't provided. - envValues.AWS_ACCESS_KEY_ID ??= process.env.BACKBLAZE_ACCESS_KEY_ID; - envValues.AWS_SECRET_ACCESS_KEY ??= process.env.BACKBLAZE_SECRET_ACCESS_KEY; - // Serialize as a double-quoted value, escaping the backslash (the escape char) // first so the remaining escapes are unambiguous, then newlines and quotes. This // keeps secrets containing spaces, '#', quotes, or newlines from corrupting the file.