Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^15.1.0",
"@sub-rosa/agent": "workspace:*",
"@sub-rosa/config": "workspace:*",
"@sub-rosa/sdk": "workspace:*",
"@sub-rosa/time": "workspace:*",
"@sub-rosa/tlock": "workspace:*",
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/PasskeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
import { getBrowserEnv } from "@sub-rosa/config/browser";
import { CAP_SAFETY_COPY } from "../demo/trace";
import { useTime } from "../lib/time";
import {
Expand Down Expand Up @@ -46,7 +46,8 @@ export function PasskeyPanel() {
} | null>(null);

const walletWasmHash = resolvePasskeyWalletWasmHash();
const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname;
const env = getBrowserEnv();
const rpId = env.VITE_PASSKEY_RP_ID ?? window.location.hostname;
const passkeyAvailable = useMemo(
() =>
typeof window !== "undefined" &&
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/hooks/useDashboardData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useCallback, useEffect, useState } from "react";
import { getBrowserEnv } from "@sub-rosa/config/browser";
import type { DashboardData } from "../dashboard/types";
import { DASHBOARD_FIXTURE } from "../dashboard/fixture";
import { assertDashboardData } from "../dashboard/fixture-health-check";
Expand Down Expand Up @@ -42,7 +42,8 @@ export function isStale(fetchedAt: string | null | undefined, nowMs: number): bo

export function useDashboardData(): UseDashboardDataResult {
const { clock, scheduler } = useTime();
const endpoint = import.meta.env.VITE_DASHBOARD_ENDPOINT as string | undefined;
const env = getBrowserEnv();
const endpoint = env.VITE_DASHBOARD_ENDPOINT as string | undefined;
const useFixture = !endpoint?.trim();

const [state, setState] = useState<UseDashboardDataResult>(() => ({
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/hooks/useLiveRound.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Copyright (c) 2026 Sub Rosa contributors
import { useEffect, useState } from "react";
import type { Round, BidState } from "@sub-rosa/sdk";
import { getBrowserEnv } from "@sub-rosa/config/browser";
import { useTime } from "../lib/time";

const RPC = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
const env = getBrowserEnv();
const RPC = env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
const NETWORK =
import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
const CONTRACT = import.meta.env.VITE_CONTRACT_ID as string | undefined;
const ROUND_ID = import.meta.env.VITE_ROUND_ID
? BigInt(import.meta.env.VITE_ROUND_ID)
env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
const CONTRACT = env.VITE_CONTRACT_ID as string | undefined;
const ROUND_ID = env.VITE_ROUND_ID
? BigInt(env.VITE_ROUND_ID)
: undefined;

export interface LiveSnapshot {
Expand Down
15 changes: 8 additions & 7 deletions apps/web/src/lib/chain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Copyright (c) 2026 Sub Rosa contributors
import { Buffer } from "buffer";
import {
getAddress,
Expand All @@ -7,17 +6,19 @@ import {
} from "@stellar/freighter-api";
import { RoundContract } from "@sub-rosa/sdk";
import { useMemo } from "react";
import { getBrowserEnv } from "@sub-rosa/config/browser";

import { formatEscrowAmount } from "./amount";

const env = getBrowserEnv();
export const LOGO_SRC = "/sub-rosa-logo.png";
export const RPC_URL = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
export const RPC_URL = env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
export const NETWORK =
import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
export const CONTRACT_ID = import.meta.env.VITE_CONTRACT_ID;
export const ESCROW_TOKEN_LABEL = import.meta.env.VITE_ESCROW_TOKEN_LABEL ?? "token";
export const DEFAULT_ROUND_ID = import.meta.env.VITE_ROUND_ID
? BigInt(import.meta.env.VITE_ROUND_ID)
env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
export const CONTRACT_ID = env.VITE_CONTRACT_ID;
export const ESCROW_TOKEN_LABEL = env.VITE_ESCROW_TOKEN_LABEL ?? "token";
export const DEFAULT_ROUND_ID = env.VITE_ROUND_ID
? BigInt(env.VITE_ROUND_ID)
: null;

/** Seconds between commit deadline and Drand round R (the “Wait for Drand R” UI phase). */
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2026 Sub Rosa contributors
import { getBrowserEnv } from "@sub-rosa/config/browser";

export interface ConfigIssue {
key: string;
message: string;
Expand Down Expand Up @@ -29,7 +30,7 @@ const PLACEHOLDER_VALUES: Record<string, string[]> = {
};

export function validatePublicConfig(
env: Record<string, string | undefined> = import.meta.env,
env: Record<string, string | undefined> = getBrowserEnv(),
): ConfigIssue[] {
const issues: ConfigIssue[] = [];

Expand Down Expand Up @@ -82,7 +83,7 @@ export function validatePublicConfig(
}

export function hasConfigIssues(
env: Record<string, string | undefined> = import.meta.env,
env: Record<string, string | undefined> = getBrowserEnv(),
): boolean {
return validatePublicConfig(env).length > 0;
}
12 changes: 7 additions & 5 deletions apps/web/src/passkey-config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
// Copyright (c) 2026 Sub Rosa contributors
import { getBrowserEnv } from "@sub-rosa/config/browser";

const env = getBrowserEnv();

/** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */
export const PASSKEY_TESTNET_WALLET_WASM_HASH =
"ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90";

export const PASSKEY_RPC_URL =
import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";
env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org";

export const PASSKEY_NETWORK_PASSPHRASE =
import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";

export function resolvePasskeyWalletWasmHash(): string | undefined {
const fromEnv = import.meta.env.VITE_PASSKEY_WALLET_WASM_HASH?.trim();
const fromEnv = env.VITE_PASSKEY_WALLET_WASM_HASH?.trim();
if (fromEnv) return fromEnv;
// Default for local jury demo — same hash as passkey-kit-demo on testnet.
return PASSKEY_TESTNET_WALLET_WASM_HASH;
}
2 changes: 2 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default defineConfig({
events: require.resolve("events/"),
"node:crypto": require.resolve("crypto-browserify"),
"node:buffer": require.resolve("buffer/"),
"process/browser": require.resolve("process/browser"),
process: require.resolve("process/browser"),
},
},
define: {
Expand Down
1 change: 1 addition & 0 deletions coverage.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"lineThresholdPercent": 70,
"workspaces": [
"packages/config",
"packages/sdk",
"packages/tlock",
"packages/round-bindings",
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
"receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck",
"time:test": "pnpm --filter @sub-rosa/time test",
"time:guard": "node scripts/check-direct-time-access.mjs",
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs"
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs",
"config:test": "pnpm --filter @sub-rosa/config test",
"config:typecheck": "pnpm --filter @sub-rosa/config typecheck",
"config:guard": "node scripts/check-direct-env-access.mjs",
"config:guard:test": "node --test scripts/check-direct-env-access.test.mjs"
}
}
31 changes: 31 additions & 0 deletions packages/config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@sub-rosa/config",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Typed environment access boundary, declarative schemas, readers, and secret redaction.",
"repository": {
"type": "git",
"url": "https://github.com/Sub-Rosa-Issue/sub-rosa-issue.git",
"directory": "packages/config"
},
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./system": "./src/system.ts",
"./browser": "./src/browser.ts"
},
"scripts": {
"test": "node --import tsx --test src/readers.test.ts src/secret.test.ts src/errors.test.ts src/schema.test.ts src/browser.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@stellar/stellar-sdk": "^15.1.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}
27 changes: 27 additions & 0 deletions packages/config/src/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getBrowserEnv } from "./browser.js";
import { ConfigError } from "./errors.js";
import { readBrowserPublic } from "./readers.js";

describe("readBrowserPublic and getBrowserEnv", () => {
it("enforces VITE_ prefix on browser-facing variables", () => {
const val = readBrowserPublic({ VITE_RPC_URL: "https://rpc.example.com" }, "VITE_RPC_URL");
assert.equal(val, "https://rpc.example.com");

assert.throws(
() => readBrowserPublic({ SECRET_KEY: "secret" }, "SECRET_KEY"),
(err: unknown) => {
assert.ok(err instanceof ConfigError);
assert.match(err.message, /must start with VITE_ prefix/);
return true;
},
);
});

it("reads browser environment with injection support", () => {
const custom = { VITE_API: "https://api.example.com" };
const env = getBrowserEnv(custom);
assert.equal(env.VITE_API, "https://api.example.com");
});
});
18 changes: 18 additions & 0 deletions packages/config/src/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Approved bootstrap adapter reading the Vite browser environment.
*
* @param customEnv Optional environment map override for testing.
* @returns Record of environment variable keys and values.
*/
export function getBrowserEnv(
customEnv?: Record<string, string | undefined>,
): Record<string, string | undefined> {
if (customEnv) {
return customEnv;
}
try {
return (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {};
} catch {
return {};
}
}
49 changes: 49 additions & 0 deletions packages/config/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ConfigError,
EmptyEnvironmentVariableError,
MalformedEnvironmentVariableError,
MissingEnvironmentVariableError,
} from "./errors.js";

describe("ConfigError hierarchy", () => {
it("formats error messages with key prefix and code", () => {
const error = new ConfigError("TEST_VAR", "something failed", "MALFORMED");
assert.equal(error.key, "TEST_VAR");
assert.equal(error.variable, "TEST_VAR");
assert.equal(error.code, "MALFORMED");
assert.equal(error.message, "TEST_VAR: something failed");
assert.ok(error instanceof Error);
assert.ok(error instanceof ConfigError);
});

it("distinguishes missing environment variables", () => {
const error = new MissingEnvironmentVariableError("PORT");
assert.equal(error.key, "PORT");
assert.equal(error.code, "MISSING");
assert.equal(error.message, "PORT: required environment variable is missing");
assert.ok(error instanceof ConfigError);
assert.ok(error instanceof MissingEnvironmentVariableError);
});

it("distinguishes empty environment variables", () => {
const error = new EmptyEnvironmentVariableError("RPC_URL");
assert.equal(error.key, "RPC_URL");
assert.equal(error.code, "EMPTY");
assert.equal(error.message, "RPC_URL: environment variable cannot be empty");
assert.ok(error instanceof ConfigError);
assert.ok(error instanceof EmptyEnvironmentVariableError);
});

it("distinguishes malformed environment variables", () => {
const cause = new Error("inner parser error");
const error = new MalformedEnvironmentVariableError("TIMEOUT", "must be an integer", { cause });
assert.equal(error.key, "TIMEOUT");
assert.equal(error.code, "MALFORMED");
assert.equal(error.message, "TIMEOUT: must be an integer");
assert.equal(error.cause, cause);
assert.ok(error instanceof ConfigError);
assert.ok(error instanceof MalformedEnvironmentVariableError);
});
});
87 changes: 87 additions & 0 deletions packages/config/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Configuration error codes categorizing environment read failures.
*/
export type ConfigErrorCode = "MISSING" | "EMPTY" | "MALFORMED";

/**
* Base error class for all configuration read and validation failures.
*/
export class ConfigError extends Error {
readonly key: string;
readonly variable: string;
readonly code: ConfigErrorCode;

/**
* Constructs a new ConfigError.
*
* @param key Environment variable key name.
* @param message Failure description without secrets.
* @param code Error category code.
* @param options Standard ErrorOptions containing cause.
*/
constructor(
key: string,
message: string,
code: ConfigErrorCode = "MALFORMED",
options?: ErrorOptions,
) {
super(`${key}: ${message}`, options);
this.name = "ConfigError";
this.key = key;
this.variable = key;
this.code = code;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Error thrown when a required environment variable is not defined.
*/
export class MissingEnvironmentVariableError extends ConfigError {
/**
* Constructs a new MissingEnvironmentVariableError.
*
* @param key Environment variable key name.
* @param message Optional detail message.
*/
constructor(key: string, message: string = "required environment variable is missing") {
super(key, message, "MISSING");
this.name = "MissingEnvironmentVariableError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Error thrown when an environment variable is defined but contains only whitespace.
*/
export class EmptyEnvironmentVariableError extends ConfigError {
/**
* Constructs a new EmptyEnvironmentVariableError.
*
* @param key Environment variable key name.
* @param message Optional detail message.
*/
constructor(key: string, message: string = "environment variable cannot be empty") {
super(key, message, "EMPTY");
this.name = "EmptyEnvironmentVariableError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Error thrown when an environment variable fails format or type parsing.
*/
export class MalformedEnvironmentVariableError extends ConfigError {
/**
* Constructs a new MalformedEnvironmentVariableError.
*
* @param key Environment variable key name.
* @param message Parsing failure explanation without sensitive data.
* @param options Standard ErrorOptions containing cause.
*/
constructor(key: string, message: string, options?: ErrorOptions) {
super(key, message, "MALFORMED", options);
this.name = "MalformedEnvironmentVariableError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
Loading
Loading