Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
paustint marked this conversation as resolved.
AWS_ACCESS_KEY_ID=''
AWS_SECRET_ACCESS_KEY=''
AWS_ENDPOINT_URL='' # e.g. https://<account_id>.r2.cloudflarestorage.com
AWS_REGION='auto'
S3_BUCKET_NAME='desktop-updates'
8 changes: 4 additions & 4 deletions .github/workflows/publish-desktop-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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
8 changes: 4 additions & 4 deletions .github/workflows/publish-desktop-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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
29 changes: 22 additions & 7 deletions apps/api/src/app/services/__tests__/desktop-asset.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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',
);
});
});
Comment thread
paustint marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
}));

Expand All @@ -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',
},
}));

Expand Down Expand Up @@ -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(() => ({
Expand All @@ -244,20 +248,24 @@ 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',
Comment thread
paustint marked this conversation as resolved.
AWS_REGION: 'auto',
S3_BUCKET_NAME: 'test-bucket',
},
logger: {
error: vi.fn(),
warn,
},
}));

// Re-import the function with the new mock
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 () => {
Expand Down
14 changes: 7 additions & 7 deletions apps/api/src/app/services/desktop-asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
Expand All @@ -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,
},
});

Expand Down
6 changes: 5 additions & 1 deletion apps/cron-tasks/src/database-backup-cron/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
2 changes: 0 additions & 2 deletions apps/jetstream-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions electron-builder.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions libs/api-config/src/lib/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 0 additions & 9 deletions scripts/build-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
]) {
Expand All @@ -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.
Expand Down
Loading