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
8 changes: 8 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ jobs:
run: pnpm --filter @cipherbox/web build:bundle
env:
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
# Unset leaves the content gateway dormant: reads fail closed as
# unavailable rather than reaching for an unconfigured endpoint.
VITE_READ_ACCELERATOR_URL: ${{ vars.VITE_READ_ACCELERATOR_URL }}
VITE_PUBLIC_GATEWAYS: ${{ vars.VITE_PUBLIC_GATEWAYS }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
VITE_FARO_URL: ${{ vars.VITE_FARO_URL }}
Expand Down Expand Up @@ -177,6 +182,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down Expand Up @@ -270,6 +276,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down Expand Up @@ -345,6 +352,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/desktop-staging-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down Expand Up @@ -160,6 +161,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down Expand Up @@ -234,6 +236,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: ${{ vars.STAGING_API_URL }}
VITE_ENVIRONMENT: staging
VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/web-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,14 @@ jobs:
pnpm --filter @cipherbox/web build
env:
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: http://localhost:3000

- name: Create .env files for Web E2E
run: |
# Web app .env (for Vite dev server)
echo "VITE_WEB3AUTH_CLIENT_ID=${{ vars.VITE_WEB3AUTH_CLIENT_ID }}" > apps/web/.env
echo "VITE_WEB3AUTH_VERIFIER=${{ vars.VITE_WEB3AUTH_VERIFIER }}" >> apps/web/.env
echo "VITE_API_URL=http://localhost:3000" >> apps/web/.env
# API .env (for NestJS)
echo "NODE_ENV=test" > apps/api/.env
Expand Down Expand Up @@ -167,6 +169,7 @@ jobs:
REDIS_PORT: 6379
# Web app configuration (needed for Web3Auth initialization)
VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }}
VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }}
VITE_API_URL: http://localhost:3000
# Test-login endpoint (bypasses Core Kit, uses deterministic keypair)
TEST_LOGIN_SECRET: e2e-test-secret-ci-only
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/auth/coreKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit';
import { tssLib } from '@toruslabs/tss-dkls-lib';
import { environment } from '../engine/config';
import { environment, loginEnv } from '../engine/config';
import type { LoginSecretExporter } from '../engine/loginHandoff';

/** How a session was established; also the `authStore` login method. */
Expand Down Expand Up @@ -84,11 +84,7 @@ class Web3AuthSession implements CoreKitSession {

/** Builds this tab's Core Kit session from the build-time environment. */
export function createCoreKitSession(env: Partial<ImportMetaEnv>): CoreKitSession {
const clientId = env.VITE_WEB3AUTH_CLIENT_ID;
const verifier = env.VITE_WEB3AUTH_VERIFIER;
if (!clientId || !verifier) {
throw new Error('VITE_WEB3AUTH_CLIENT_ID and VITE_WEB3AUTH_VERIFIER must both be configured');
}
const { clientId, verifier } = loginEnv(env);

const coreKit = new Web3AuthMPCCoreKit({
web3AuthClientId: clientId,
Expand Down
108 changes: 107 additions & 1 deletion apps/web/src/engine/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { engineHostConfig, environment } from './config';
import { engineHostConfig, environment, loginEnv, missingDeployEnv } from './config';

const artifact = {
wasmModuleUrl: '/assets/cipherbox_wasm-deadbeef.js',
Expand Down Expand Up @@ -45,6 +45,112 @@ describe('engineHostConfig', () => {
'http://localhost:3000'
);
});

it('carries the read accelerator and the public gateway fallbacks through', () => {
const config = engineHostConfig(
{
VITE_READ_ACCELERATOR_URL: 'https://accelerator.example.test',
VITE_PUBLIC_GATEWAYS: ' https://a.example.test , https://b.example.test ',
},
artifact
);
expect(config.acceleratorBaseUrl).toBe('https://accelerator.example.test');
expect(config.publicGateways).toEqual(['https://a.example.test', 'https://b.example.test']);
});

it('leaves the content gateway unset rather than defaulting it', () => {
const config = engineHostConfig({}, artifact);
expect(config.acceleratorBaseUrl).toBeUndefined();
expect(config.publicGateways).toEqual([]);
expect(engineHostConfig({ VITE_PUBLIC_GATEWAYS: ' , ' }, artifact).publicGateways).toEqual([]);
for (const unset of ['', ' ']) {
// Whitespace would configure a gateway source with an unusable base URL —
// reads then fail per-request instead of staying dormant.
expect(
engineHostConfig({ VITE_READ_ACCELERATOR_URL: unset }, artifact).acceleratorBaseUrl
).toBeUndefined();
}
});

it('trims a configured API origin, which is concatenated into request URLs', () => {
expect(
engineHostConfig({ VITE_API_URL: ' https://api.example.test\n' }, artifact).apiBaseUrl
).toBe('https://api.example.test');
});

it('never defaults a whitespace API origin to localhost', () => {
// Defaulting here would point a misconfigured deployment at whatever answers
// on the user's machine; the engine's own edge check refuses a blank base.
expect(engineHostConfig({ VITE_API_URL: ' ' }, artifact).apiBaseUrl).toBe('');
});
});

describe('missingDeployEnv', () => {
const deployed = {
VITE_ENVIRONMENT: 'staging',
VITE_WEB3AUTH_CLIENT_ID: 'client',
VITE_WEB3AUTH_VERIFIER: 'verifier',
VITE_API_URL: 'https://api.example.test',
};

it('names the variables a deployed build is missing', () => {
expect(missingDeployEnv({ VITE_ENVIRONMENT: 'staging' })).toEqual([
'VITE_WEB3AUTH_CLIENT_ID',
'VITE_WEB3AUTH_VERIFIER',
'VITE_API_URL',
]);
// A variable substituted as blank is as unusable as an absent one, and a
// whitespace-only one is blank — a repo variable set to a stray space or
// newline must not sail through the gate this build exists to fail on.
for (const blank of ['', ' ', '\n']) {
expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_VERIFIER: blank })).toEqual([
'VITE_WEB3AUTH_VERIFIER',
]);
expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_CLIENT_ID: blank })).toEqual([
'VITE_WEB3AUTH_CLIENT_ID',
]);
}
});

it('refuses a deployed build with no API origin, which would default to localhost', () => {
for (const blank of ['', ' ']) {
expect(missingDeployEnv({ ...deployed, VITE_API_URL: blank })).toEqual(['VITE_API_URL']);
}
});

it('passes a fully configured deployment', () => {
expect(missingDeployEnv(deployed)).toEqual([]);
});

it('exempts builds that name no deployment', () => {
expect(missingDeployEnv({})).toEqual([]);
expect(missingDeployEnv({ VITE_ENVIRONMENT: 'ci' })).toEqual([]);
});
});

describe('loginEnv', () => {
it('reads the Web3Auth identifiers a session is built from', () => {
expect(loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client', VITE_WEB3AUTH_VERIFIER: 'v' })).toEqual({
clientId: 'client',
verifier: 'v',
});
});

it('trims the identifiers, which are sent to Web3Auth verbatim', () => {
expect(
loginEnv({ VITE_WEB3AUTH_CLIENT_ID: ' client\n', VITE_WEB3AUTH_VERIFIER: 'v ' })
).toEqual({ clientId: 'client', verifier: 'v' });
});

it('refuses a build missing one, naming it', () => {
expect(() => loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client' })).toThrow(
/^VITE_WEB3AUTH_VERIFIER must be configured$/
);
// Whitespace is missing, not configured.
expect(() =>
loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client', VITE_WEB3AUTH_VERIFIER: ' ' })
).toThrow(/^VITE_WEB3AUTH_VERIFIER must be configured$/);
});
});

describe('environment', () => {
Expand Down
81 changes: 75 additions & 6 deletions apps/web/src/engine/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// `vite.config.ts` imports the deploy gate from here while resolving config, so
// this module must keep to type-only imports — a value import of the client
// package would pull the WASM engine into the bundler.
import type { EngineHostConfig } from '@cipherbox/client';

const DEFAULT_API_URL = 'http://localhost:3000';
Expand All @@ -6,14 +9,51 @@ const DEFAULT_ROUTING_ENDPOINTS = 'https://delegated-ipfs.dev';
/** The deployments the build-time environment names. */
export type Environment = 'local' | 'ci' | 'staging' | 'production';

/** The API origin the engine authenticates and publishes against. */
/**
* The API origin the engine authenticates and publishes against. Trimmed, since
* it is concatenated into request URLs; a whitespace-only value trims to blank
* rather than defaulting, so the engine's own edge check refuses it instead of
* a misconfigured deployment quietly talking to the user's own machine.
*/
export function apiBaseUrl(env: Partial<ImportMetaEnv>): string {
// `VITE_API_URL=` reads as `''`, which `new URL` rejects rather than defaults.
return env.VITE_API_URL || DEFAULT_API_URL;
return env.VITE_API_URL === undefined || env.VITE_API_URL === ''
? DEFAULT_API_URL
: env.VITE_API_URL.trim();
}

const ENVIRONMENTS: readonly Environment[] = ['local', 'ci', 'staging', 'production'];

/** Deployments whose bundle is shipped to users, and so must be able to log in. */
const DEPLOYED: readonly Environment[] = ['staging', 'production'];

/** The one list of what a Core Kit session needs, shared with the build gate. */
const LOGIN_ENV = ['VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER'] as const;

/**
* What a deployed bundle cannot work without. `VITE_API_URL` is here because an
* unset one falls back to `localhost`, which the engine would then authenticate
* against — a working build pointed at whatever answers on the user's machine.
*/
const DEPLOY_ENV = [...LOGIN_ENV, 'VITE_API_URL'] as const;

/**
* A variable's configured value, or `undefined` when it carries none. Absent,
* empty and whitespace-only are one state: a repo variable set to a stray space
* or newline is unset, not configured.
*/
function configured(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}

/** Reads a comma-separated variable as a trimmed, blank-free list. */
function list(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}

/**
* Which deployment this build is; absent means a working-copy `vite dev`. A
* typo is rejected rather than defaulted: it would silently pick the wrong
Expand All @@ -36,10 +76,7 @@ export function engineHostConfig(
env: Partial<ImportMetaEnv>,
artifact: Pick<EngineHostConfig, 'wasmModuleUrl' | 'wasmBinaryUrl'>
): EngineHostConfig {
const recordEndpoints = (env.VITE_ROUTING_ENDPOINTS ?? DEFAULT_ROUTING_ENDPOINTS)
.split(',')
.map((endpoint) => endpoint.trim())
.filter((endpoint) => endpoint.length > 0);
const recordEndpoints = list(env.VITE_ROUTING_ENDPOINTS ?? DEFAULT_ROUTING_ENDPOINTS);
// Config-edge mirror of the `FetchRecordTransport` empty-endpoint-set rejection.
if (recordEndpoints.length === 0) {
throw new Error('VITE_ROUTING_ENDPOINTS must list at least one routing endpoint');
Expand All @@ -48,6 +85,38 @@ export function engineHostConfig(
return {
apiBaseUrl: apiBaseUrl(env),
recordEndpoints,
// The content gateway has no default: unset reads nothing rather than
// reaching for an endpoint nobody chose. Dormant is the fail-closed state,
// so a blank value must land there rather than configuring a gateway source
// whose every request fails.
acceleratorBaseUrl: configured(env.VITE_READ_ACCELERATOR_URL),
publicGateways: list(env.VITE_PUBLIC_GATEWAYS),
...artifact,
};
}

/** Of the variables Core Kit login needs, those `env` does not supply. */
export function missingLoginEnv(env: Partial<ImportMetaEnv>): string[] {
return LOGIN_ENV.filter((name) => configured(env[name]) === undefined);
}

/** The Web3Auth identifiers a Core Kit session is built from; refuses a build missing any. */
export function loginEnv(env: Partial<ImportMetaEnv>): { clientId: string; verifier: string } {
const clientId = configured(env.VITE_WEB3AUTH_CLIENT_ID);
const verifier = configured(env.VITE_WEB3AUTH_VERIFIER);
if (!clientId || !verifier) {
throw new Error(`${missingLoginEnv(env).join(' and ')} must be configured`);
}
return { clientId, verifier };
}

/**
* The variables a deployed bundle is missing. The bundler checks this so an
* unset one is a red build rather than a broken deploy nobody notices until a
* user tries to log in; a working-copy or CI build names no deployment and is
* exempt.
*/
export function missingDeployEnv(env: Partial<ImportMetaEnv>): string[] {
if (!DEPLOYED.includes(environment(env))) return [];
return DEPLOY_ENV.filter((name) => configured(env[name]) === undefined);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 4 additions & 0 deletions apps/web/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ interface ImportMetaEnv {
readonly VITE_API_URL?: string;
/** Comma-separated `/routing/v1` origins: someguy plus a public endpoint. */
readonly VITE_ROUTING_ENDPOINTS?: string;
/** Base URL of the token-authed read accelerator; unset leaves reads dormant. */
readonly VITE_READ_ACCELERATOR_URL?: string;
/** Comma-separated public trustless-gateway origins, tried after the accelerator. */
readonly VITE_PUBLIC_GATEWAYS?: string;
/** `local` | `ci` | `staging` | `production` — picks the Web3Auth network. */
readonly VITE_ENVIRONMENT?: string;
readonly VITE_WEB3AUTH_CLIENT_ID?: string;
Expand Down
23 changes: 21 additions & 2 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { build, type Plugin } from 'vite';
import { build, loadEnv, type Plugin } from 'vite';
import { defineConfig } from 'vitest/config';

import { missingDeployEnv } from './src/engine/config';

const OUT_DIR = fileURLToPath(new URL('dist', import.meta.url));
const SW_ENTRY = fileURLToPath(new URL('src/sw.ts', import.meta.url));
const SW_FILE = 'sw.js';
Expand Down Expand Up @@ -79,8 +81,25 @@ function appShell(): Plugin[] {
];
}

/** Fails a deployment build whose login-critical environment is unset. */
function deployEnvGate(): Plugin {
return {
name: 'cipherbox:deploy-env-gate',
apply: 'build',
config(_config, { mode }) {
const env = loadEnv(mode, import.meta.dirname, 'VITE_');
const missing = missingDeployEnv(env);
if (missing.length > 0) {
throw new Error(
`a ${env.VITE_ENVIRONMENT} build cannot log in without ${missing.join(', ')}`
);
}
},
};
}

export default defineConfig({
plugins: [react(), ...appShell()],
plugins: [react(), deployEnvGate(), ...appShell()],
// `@cipherbox/client`'s engine worker dynamically imports the wasm-bindgen ES
// module, which a classic worker cannot do (blueprint/web-client.md).
worker: { format: 'es' },
Expand Down
Loading