diff --git a/docs/advanced-issues.md b/docs/advanced-issues.md new file mode 100644 index 0000000..7b38ffb --- /dev/null +++ b/docs/advanced-issues.md @@ -0,0 +1,51 @@ +# Advanced Issue Standard + +This document outlines the standard for creating "GrantFox-style" advanced issue JSON files in the `anchorkit` repository. + +## Overview +To automate and standardize the issue creation process, issue batches are defined as JSON files inside the `issues/` directory. These issues must adhere to strict schema rules before they can be created on GitHub. + +## Schema Requirements + +Every issue JSON file must have the following fields: + +- `title` (string, required): A concise title for the issue. +- `description` (string, required): A detailed description of the problem or feature. +- `labels` (array of strings, required): At least one valid label. +- `complexity` (string, required): The difficulty of the issue. +- `acceptanceCriteria` (array of strings, required): Specific requirements that must be met to close the issue. + +### Supported Labels +- `bug` +- `enhancement` +- `documentation` +- `good first issue` +- `help wanted` +- `feature` + +### Allowed Complexities +- `low` +- `medium` +- `high` +- `expert` + +### Acceptance Criteria Rules +- There must be at least one acceptance criterion. +- Each criterion must be sufficiently detailed (greater than 10 characters). Weak criteria like "works" or "tests pass" will be rejected. + +## Example + +```json +{ + "title": "Add a local validator for GrantFox-style advanced issue JSON files.", + "description": "Issue batches can contain missing fields, unsupported labels, weak acceptance criteria, or low-value tasks. AnchorKit automation should validate issue batch structure and advanced issue quality before creation.", + "labels": ["feature"], + "complexity": "expert", + "acceptanceCriteria": [ + "Issue batch schema validator is implemented.", + "Unsupported labels are detected before GitHub issue creation.", + "Missing required fields are reported clearly.", + "Weak or empty acceptance criteria are flagged." + ] +} +``` diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 0000000..4bb2aff --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,81 @@ +# Feature Flags and Configuration Source Framework + +AnchorKit provides a unified configuration source resolution and feature flag framework in `@anchorkit/config` and `@anchorkit/stellar-kit`. + +## Overview + +Experimental and non-standard SDK capabilities (such as experimental Soroban contract functions or Vault management) are managed through feature flags. By default, experimental capabilities are **disabled for safety** to prevent accidental invocation in production applications. + +## Feature Flag Stability Levels + +Feature flags define capabilities with one of three stability levels: + +- **`stable`**: Fully tested, production-ready SDK capabilities. Enabled by default or safely togglable. +- **`experimental`**: Under active development or preview. **Disabled by default**. +- **`deprecated`**: Legacy capabilities planned for future removal. + +### Registered Feature Flags + +| Feature Flag ID | Name | Stability | Default State | Description | +|---|---|---|---|---| +| `experimental_soroban` | Experimental Soroban Support | `experimental` | **Disabled** | Soroban smart contract preview functions and RPC extensions. | +| `experimental_vault` | Experimental Vault Manager | `experimental` | **Disabled** | Vault session management and escrow rules. | +| `mainnet_access` | Mainnet Operations | `stable` | **Disabled** | Allows execution against Stellar Mainnet. | +| `advanced_diagnostics` | Advanced Diagnostics | `stable` | **Enabled** | Enriched configuration and network diagnostic pipelines. | + +## Enabling Features + +Features can be enabled per-environment by configuring `featureFlags` on `AnchorKitEnvConfig`: + +```ts +import { DEFAULT_ENV_CONFIG, isFeatureEnabled, assertFeatureEnabled } from "@anchorkit/config"; + +const appConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + }, +}; + +// Check if feature is enabled +if (isFeatureEnabled("experimental_soroban", appConfig)) { + // Safe to use experimental features +} +``` + +## Disabled Feature Behaviour & Typed Errors + +Invoking a disabled capability throws a typed `StellarKitError` with code `"FEATURE_DISABLED"`: + +```ts +import { executeSorobanCapability } from "@anchorkit/stellar-kit"; + +try { + // Throws StellarKitError with code "FEATURE_DISABLED" if experimental_soroban is false + executeSorobanCapability("deploy_contract"); +} catch (err: any) { + if (err.code === "FEATURE_DISABLED") { + console.error("Feature is disabled:", err.message); + } +} +``` + +## Safe Configuration Source Metadata & Diagnostics + +The framework exposes safe configuration source resolution metadata via `resolveConfigSourceMetadata()` and `diagnoseConfig()`. Sensitive environment fields (such as secret key prefixes or keys) are automatically marked as `isSensitive: true` and redacted (`"[REDACTED]"`). + +```ts +import { diagnoseConfig } from "@anchorkit/stellar-kit"; + +const diag = diagnoseConfig(); +console.log(diag.configSources); +// Output contains safe metadata for every config parameter + +console.log(diag.isAllStable); +// Returns false if any active feature flag has experimental or deprecated stability +``` + +## Safety Guidelines + +1. Never bypass `assertFeatureEnabled()` or force-enable experimental features in production without thorough review. +2. Diagnostics output is safe to pass to logging systems or tooltips as sensitive keys are automatically redacted. diff --git a/docs/secret-redaction.md b/docs/secret-redaction.md index a56b9ba..0185382 100644 --- a/docs/secret-redaction.md +++ b/docs/secret-redaction.md @@ -14,12 +14,21 @@ crash reports, or CI output (issue #3). Two layers: |---|---|---| | `redactSecretKey(secret)` | `RedactedSecretKey { prefix, suffix, __redacted }` | Keep only first 4 + last 4 chars. | | `secretKeyToRedactedString(secret)` | `string` | Human-readable `SABC••••••XYZQ`. | -| `redactSecrets(input)` | `string` | Scan a string and redact any Stellar-shaped secret (`S…`, 56 chars) plus `secret key` / `private key` / `seed phrase` tokens. | +| `redactSecrets(input)` | `string` | Scan a string and redact any Stellar-shaped secret (`S…`, 56 chars), secret assignments (`secretKey=...`), plus `secret key` / `private key` / `seed phrase` tokens. | | `formatRedactedSecret(redacted)` | `string` | Render a `RedactedSecretKey`. | +| `containsSecret(input)` | `boolean` | Check if a string contains any 56-character Stellar secret key or secret assignment pattern. | +| `detectUnsafePatterns(input)` | `{ hasSecrets: boolean; matches: UnsafePatternMatch[] }` | Diagnostic scan for secret-like patterns. | `RedactedSecretKey` is a branded type (`__redacted: true`) so it can never be mistaken for a usable key at the type level. +## Diagnostics and Error Integration + +AnchorKit automatically applies redaction across error creation and account diagnostics: +- **`createStellarError`**: All error messages are sanitized at creation time via `redactSecrets(message)`. +- **`diagnoseAccount` & `diagnoseAccountInfo`**: Inputs and error outputs pass through `redactSecrets` so passing a secret key or invalid string as a public key parameter will never leak credentials in diagnostic results. + + ## Safe logger ```ts diff --git a/issues/sample-issue.json b/issues/sample-issue.json new file mode 100644 index 0000000..a453a41 --- /dev/null +++ b/issues/sample-issue.json @@ -0,0 +1,12 @@ +{ + "title": "Add a local validator for GrantFox-style advanced issue JSON files.", + "description": "Issue batches can contain missing fields, unsupported labels, weak acceptance criteria, or low-value tasks. AnchorKit automation should validate issue batch structure and advanced issue quality before creation.", + "labels": ["feature"], + "complexity": "expert", + "acceptanceCriteria": [ + "Issue batch schema validator is implemented.", + "Unsupported labels are detected before GitHub issue creation.", + "Missing required fields are reported clearly.", + "Weak or empty acceptance criteria are flagged." + ] +} diff --git a/package.json b/package.json index 26f0ece..2ad3a29 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "contract:test": "cd contracts/treasury-escrow && cargo test", "contract:build": "cd contracts/treasury-escrow && cargo build --target wasm32-unknown-unknown --release", "web:dev": "turbo run dev --filter=@anchorkit/web", - "web:build": "turbo run build --filter=@anchorkit/web" + "web:build": "turbo run build --filter=@anchorkit/web", + "validate:issues": "npx tsx scripts/validate-issues.mts" }, "devDependencies": { "@types/node": "^20.11.0", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index d63f46f..13fb282 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -39,6 +39,37 @@ export const NETWORK_CONFIGS: Record = { export const DEFAULT_NETWORK: StellarNetwork = STELLAR_NETWORKS.TESTNET; +export const DEFAULT_FEATURE_FLAGS: Record = { + experimental_soroban: { + id: "experimental_soroban", + name: "Experimental Soroban Support", + description: "Enables experimental Soroban smart contract operations and custom RPC extensions.", + stability: "experimental", + defaultEnabled: false, + }, + experimental_vault: { + id: "experimental_vault", + name: "Experimental Vault Manager", + description: "Enables experimental vault management, session tracking, and multi-sig escrow vault rules.", + stability: "experimental", + defaultEnabled: false, + }, + mainnet_access: { + id: "mainnet_access", + name: "Mainnet Operations", + description: "Allows execution against Stellar Mainnet.", + stability: "stable", + defaultEnabled: false, + }, + advanced_diagnostics: { + id: "advanced_diagnostics", + name: "Advanced Diagnostics", + description: "Enables enriched configuration and network diagnostic pipelines.", + stability: "stable", + defaultEnabled: true, + }, +}; + export interface AnchorKitEnvConfig { defaultNetwork: StellarNetwork; allowMainnet: boolean; @@ -49,6 +80,7 @@ export interface AnchorKitEnvConfig { maximumPaymentAmount: string; secretKeyPrefix: string; publicKeyPrefix: string; + featureFlags?: Partial>; } export const DEFAULT_ENV_CONFIG: AnchorKitEnvConfig = { @@ -61,6 +93,12 @@ export const DEFAULT_ENV_CONFIG: AnchorKitEnvConfig = { maximumPaymentAmount: "999999999999.9999999", secretKeyPrefix: "S", publicKeyPrefix: "G", + featureFlags: { + experimental_soroban: false, + experimental_vault: false, + mainnet_access: false, + advanced_diagnostics: true, + }, }; export function getNetworkConfig(network: StellarNetwork = DEFAULT_NETWORK): NetworkConfig { @@ -90,3 +128,80 @@ export function assertNetworkAllowed( }); } } + +export function getFeatureFlagDefinitions(): FeatureFlagDefinition[] { + return Object.values(DEFAULT_FEATURE_FLAGS); +} + +export function isFeatureEnabled( + flagId: FeatureFlagId, + env: AnchorKitEnvConfig = DEFAULT_ENV_CONFIG +): boolean { + if (flagId === "mainnet_access") { + if (env.featureFlags?.mainnet_access !== undefined) { + return env.featureFlags.mainnet_access; + } + return isMainnetAllowed(env); + } + + if (env.featureFlags && flagId in env.featureFlags) { + const val = env.featureFlags[flagId]; + if (val !== undefined) return val; + } + + const def = DEFAULT_FEATURE_FLAGS[flagId]; + return def ? def.defaultEnabled : false; +} + +export function assertFeatureEnabled( + flagId: FeatureFlagId, + env: AnchorKitEnvConfig = DEFAULT_ENV_CONFIG +): void { + if (!isFeatureEnabled(flagId, env)) { + const def = DEFAULT_FEATURE_FLAGS[flagId]; + const name = def ? def.name : flagId; + const stability = def ? def.stability : "experimental"; + const error = new Error( + `Feature '${name}' (${flagId}) is disabled by default. Feature stability: ${stability}. Enable it by setting featureFlags.${flagId}: true in config.` + ) as any; + error.code = "FEATURE_DISABLED"; + error.name = "StellarKitError"; + error.redacted = true; + throw error; + } +} + +export function resolveConfigSourceMetadata( + env: AnchorKitEnvConfig = DEFAULT_ENV_CONFIG +): ConfigSourceMetadata[] { + const isDefault = env === DEFAULT_ENV_CONFIG; + const source = isDefault ? "default" : "explicit"; + + const result: ConfigSourceMetadata[] = [ + { source, key: "defaultNetwork", isSensitive: false, resolvedValue: env.defaultNetwork, stability: "stable" }, + { source, key: "allowMainnet", isSensitive: false, resolvedValue: env.allowMainnet, stability: "stable" }, + { source, key: "horizonTimeoutMs", isSensitive: false, resolvedValue: env.horizonTimeoutMs, stability: "stable" }, + { source, key: "horizonRateLimitPerSecond", isSensitive: false, resolvedValue: env.horizonRateLimitPerSecond, stability: "stable" }, + { source, key: "maximumMemoTextBytes", isSensitive: false, resolvedValue: env.maximumMemoTextBytes, stability: "stable" }, + { source, key: "minimumPaymentAmount", isSensitive: false, resolvedValue: env.minimumPaymentAmount, stability: "stable" }, + { source, key: "maximumPaymentAmount", isSensitive: false, resolvedValue: env.maximumPaymentAmount, stability: "stable" }, + { source, key: "secretKeyPrefix", isSensitive: true, resolvedValue: "[REDACTED]", stability: "stable" }, + { source, key: "publicKeyPrefix", isSensitive: false, resolvedValue: env.publicKeyPrefix, stability: "stable" }, + ]; + + const definitions = getFeatureFlagDefinitions(); + for (const def of definitions) { + const enabled = isFeatureEnabled(def.id, env); + const flagSource = env.featureFlags && def.id in env.featureFlags ? (isDefault ? "default" : "explicit") : "default"; + result.push({ + source: flagSource, + key: `featureFlags.${def.id}`, + isSensitive: false, + resolvedValue: enabled, + stability: def.stability, + }); + } + + return result; +} + diff --git a/packages/config/test/featureFlags.test.ts b/packages/config/test/featureFlags.test.ts new file mode 100644 index 0000000..154fe70 --- /dev/null +++ b/packages/config/test/featureFlags.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ENV_CONFIG, + assertFeatureEnabled, + getFeatureFlagDefinitions, + isFeatureEnabled, + resolveConfigSourceMetadata, +} from "../src"; + +describe("Feature Flag & Config Source Framework", () => { + it("provides feature flag definitions with stability and default states", () => { + const definitions = getFeatureFlagDefinitions(); + expect(definitions.length).toBeGreaterThanOrEqual(4); + + const sorobanDef = definitions.find((d) => d.id === "experimental_soroban"); + expect(sorobanDef).toBeDefined(); + expect(sorobanDef?.stability).toBe("experimental"); + expect(sorobanDef?.defaultEnabled).toBe(false); + + const vaultDef = definitions.find((d) => d.id === "experimental_vault"); + expect(vaultDef).toBeDefined(); + expect(vaultDef?.stability).toBe("experimental"); + expect(vaultDef?.defaultEnabled).toBe(false); + }); + + it("disables experimental features by default", () => { + expect(isFeatureEnabled("experimental_soroban")).toBe(false); + expect(isFeatureEnabled("experimental_vault")).toBe(false); + }); + + it("allows enabling experimental features via env config override", () => { + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + experimental_vault: false, + }, + }; + + expect(isFeatureEnabled("experimental_soroban", customConfig)).toBe(true); + expect(isFeatureEnabled("experimental_vault", customConfig)).toBe(false); + }); + + it("throws typed error when asserting a disabled feature", () => { + expect(() => assertFeatureEnabled("experimental_soroban")).toThrowError( + /Feature 'Experimental Soroban Support' \(experimental_soroban\) is disabled by default/ + ); + + try { + assertFeatureEnabled("experimental_soroban"); + } catch (err: any) { + expect(err.code).toBe("FEATURE_DISABLED"); + expect(err.name).toBe("StellarKitError"); + expect(err.redacted).toBe(true); + } + }); + + it("does not throw when asserting an enabled feature", () => { + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + }, + }; + + expect(() => assertFeatureEnabled("experimental_soroban", customConfig)).not.toThrow(); + }); + + it("resolves config source metadata safely without exposing secrets", () => { + const metadata = resolveConfigSourceMetadata(DEFAULT_ENV_CONFIG); + expect(Array.isArray(metadata)).toBe(true); + + const secretKeyMeta = metadata.find((m) => m.key === "secretKeyPrefix"); + expect(secretKeyMeta).toBeDefined(); + expect(secretKeyMeta?.isSensitive).toBe(true); + expect(secretKeyMeta?.resolvedValue).toBe("[REDACTED]"); + + const sorobanMeta = metadata.find((m) => m.key === "featureFlags.experimental_soroban"); + expect(sorobanMeta).toBeDefined(); + expect(sorobanMeta?.isSensitive).toBe(false); + expect(sorobanMeta?.resolvedValue).toBe(false); + expect(sorobanMeta?.stability).toBe("experimental"); + }); +}); diff --git a/packages/stellar-kit/src/diagnostics.ts b/packages/stellar-kit/src/diagnostics.ts index 66f7bad..d2e770b 100644 --- a/packages/stellar-kit/src/diagnostics.ts +++ b/packages/stellar-kit/src/diagnostics.ts @@ -61,6 +61,22 @@ export interface AccountDiagnostic { error: string | null; } +export interface ConfigDiagnostic { + /** Safe configuration resolution metadata (secrets redacted). */ + configSources: ConfigSourceMetadata[]; + /** Feature flags metadata and current resolved state. */ + featureFlags: Array<{ + id: string; + name: string; + enabled: boolean; + stability: string; + }>; + /** True if no experimental or deprecated features are active. */ + isAllStable: boolean; + /** ISO timestamp when diagnostics were generated. */ + timestamp: string; +} + function mapStatusToState(status: AccountStatus): AccountDiagnosticState { switch (status) { case "funded": @@ -91,14 +107,14 @@ export function diagnoseAccountInfo( const reserve = state === "funded" ? computeReserve(info.subentryCount) : null; return { - input: info.publicKey, + input: valid ? info.publicKey : redactSecrets(info.publicKey), state, isValidPublicKey: valid, expertUrl: valid ? buildAccountLink(info.publicKey, network) : null, reserve, balances: computeBalanceModel(info), account: info, - error: info.error ?? null, + error: info.error ? redactSecrets(info.error) : null, }; } @@ -108,6 +124,30 @@ export function diagnoseAccountInfo( * Network/parse failures degrade gracefully into `invalid` / `unavailable` * states instead of throwing. */ +export function diagnoseConfig( + env: AnchorKitEnvConfig = DEFAULT_ENV_CONFIG +): ConfigDiagnostic { + const configSources = resolveConfigSourceMetadata(env); + const definitions = getFeatureFlagDefinitions(); + const featureFlags = definitions.map((def) => ({ + id: def.id, + name: def.name, + enabled: isFeatureEnabled(def.id, env), + stability: def.stability, + })); + + const enabledExperimentalOrDeprecated = featureFlags.some( + (ff) => ff.enabled && ff.stability !== "stable" + ); + + return { + configSources, + featureFlags, + isAllStable: !enabledExperimentalOrDeprecated, + timestamp: new Date().toISOString(), + }; +} + export async function diagnoseAccount( publicKey: string, options: { network?: NetworkConfig["network"]; loadAccount?: (pk: string) => Promise } = {} @@ -116,7 +156,7 @@ export async function diagnoseAccount( if (!isPublicKeyValid(publicKey)) { return { - input: publicKey, + input: redactSecrets(publicKey), state: "invalid", isValidPublicKey: false, expertUrl: null, @@ -137,7 +177,7 @@ export async function diagnoseAccount( return diagnoseAccountInfo(info, { network }); } catch (err) { return { - input: publicKey, + input: redactSecrets(publicKey), state: "unavailable", isValidPublicKey: true, expertUrl: buildAccountLink(publicKey as StellarPublicKey, network), @@ -146,8 +186,10 @@ export async function diagnoseAccount( "The account could not be loaded, so the spendable balance is unknown." ), account: null, - error: err instanceof Error ? err.message : "Account diagnostics unavailable.", + error: err instanceof Error ? redactSecrets(err.message) : "Account diagnostics unavailable.", }; } } + + diff --git a/packages/stellar-kit/src/errors.ts b/packages/stellar-kit/src/errors.ts index d5a46d9..a425838 100644 --- a/packages/stellar-kit/src/errors.ts +++ b/packages/stellar-kit/src/errors.ts @@ -87,7 +87,13 @@ export function mapHorizonError( message: "Network error when connecting to Stellar Horizon API", }; } + + return { + code: "UNKNOWN", + message: redactSecrets(error.message), + }; } - return { code: "UNKNOWN", message: "An unexpected error occurred" }; + return { code: "UNKNOWN", message: redactSecrets(String(error)) }; } + diff --git a/packages/stellar-kit/src/index.ts b/packages/stellar-kit/src/index.ts index 0fe6243..6e18404 100644 --- a/packages/stellar-kit/src/index.ts +++ b/packages/stellar-kit/src/index.ts @@ -1,3 +1,4 @@ +export * from "./redaction"; export * from "./errors"; export * from "./keys"; export * from "./accounts"; @@ -14,3 +15,4 @@ export * from "./diagnostics"; export * from "./assetRegistry"; export * from "./severity"; export type { StellarKeypair } from "@anchorkit/types"; + diff --git a/packages/stellar-kit/src/keys.ts b/packages/stellar-kit/src/keys.ts index e575dd8..13265a7 100644 --- a/packages/stellar-kit/src/keys.ts +++ b/packages/stellar-kit/src/keys.ts @@ -7,7 +7,10 @@ import type { } from "@anchorkit/types"; import { StellarPublicKeySchema, StellarSecretKeySchema } from "@anchorkit/validators"; import type { SafeParseReturnType } from "zod"; -import { createStellarError, redactSecrets } from "./errors"; +import { createStellarError } from "./errors"; +import { formatRedactedSecret, redactSecrets } from "./redaction"; + +export { formatRedactedSecret } from "./redaction"; export function generateTestnetKeypair(): StellarKeypair { try { @@ -84,8 +87,9 @@ export function getPublicKeyFromSecret(secretKey: string): StellarPublicKey { } export function redactSecretKey(secretKey: string): RedactedSecretKey { - const prefix = secretKey.slice(0, 4); - const suffix = secretKey.slice(-4); + const safeStr = typeof secretKey === "string" ? secretKey : ""; + const prefix = safeStr.slice(0, 4); + const suffix = safeStr.slice(-4); return { __redacted: true, prefix, @@ -93,14 +97,15 @@ export function redactSecretKey(secretKey: string): RedactedSecretKey { }; } -export function formatRedactedSecret(redacted: RedactedSecretKey): string { - return `${redacted.prefix}••••••••••••••••••••••••••••••••••••••••••••••••••••${redacted.suffix}`; -} - export function secretKeyToRedactedString(secretKey: string): string { + if (typeof secretKey !== "string" || !secretKey) { + return "[INVALID_SECRET_KEY]"; + } const result = validateSecretKeyQuietly(secretKey); if (!result.valid) { - return redactSecrets("[INVALID_SECRET_KEY]"); + return "[INVALID_SECRET_KEY]"; } return formatRedactedSecret(redactSecretKey(secretKey)); } + + diff --git a/packages/stellar-kit/src/redaction.ts b/packages/stellar-kit/src/redaction.ts new file mode 100644 index 0000000..3fbbe6e --- /dev/null +++ b/packages/stellar-kit/src/redaction.ts @@ -0,0 +1,121 @@ +/** + * Central Secret Redaction & Unsafe Pattern Detection Framework + * + * Provides shared utilities for scanning and redacting Stellar secret keys, + * secret field assignments, diagnostic outputs, and stack traces. + */ + +import type { RedactedSecretKey } from "@anchorkit/types"; + +/** + * Regex matching Stellar secret seeds (56 chars, starting with 'S', base32 uppercase A-Z, 2-7). + */ +export const STELLAR_SECRET_KEY_REGEX = /S[A-Z2-7]{55}/g; + +/** + * Regex matching secret key assignments in key-value pairs or log strings. + * e.g., secretKey="...", secret_key: "...", privateKey=... + */ +export const SECRET_ASSIGNMENT_REGEX = + /(secret[_\-]?key|private[_\-]?key|seed[_\-]?phrase|secret[_\-]?seed)\s*[:=]\s*(["']?)([^\s"',}]+)\2/gi; + +/** + * Pattern list for scanning and scrubbing secret tokens from arbitrary strings. + */ +const SECRET_PATTERNS = [ + STELLAR_SECRET_KEY_REGEX, + /S[A-Za-z2-7]{55}/g, + SECRET_ASSIGNMENT_REGEX, + /secret[_\-]?key/i, + /private[_\-]?key/i, + /seed[_\-]?phrase/i, +]; + +/** + * Check if a string contains any secret-like value or unsafe pattern. + */ +export function containsSecret(input: string): boolean { + if (typeof input !== "string" || !input) return false; + if (/S[A-Z2-7]{55}/i.test(input)) return true; + SECRET_ASSIGNMENT_REGEX.lastIndex = 0; + if (SECRET_ASSIGNMENT_REGEX.test(input)) return true; + return false; +} + +export interface UnsafePatternMatch { + type: "stellar_secret_key" | "secret_field_assignment" | "sensitive_keyword"; + match: string; +} + +/** + * Perform a detailed diagnostic scan for unsafe secret patterns in text. + */ +export function detectUnsafePatterns(input: string): { + hasSecrets: boolean; + matches: UnsafePatternMatch[]; +} { + if (typeof input !== "string" || !input) { + return { hasSecrets: false, matches: [] }; + } + + const matches: UnsafePatternMatch[] = []; + + const secretKeys = input.match(STELLAR_SECRET_KEY_REGEX); + if (secretKeys) { + for (const key of secretKeys) { + matches.push({ type: "stellar_secret_key", match: key }); + } + } + + SECRET_ASSIGNMENT_REGEX.lastIndex = 0; + let assignMatch: RegExpExecArray | null; + while ((assignMatch = SECRET_ASSIGNMENT_REGEX.exec(input)) !== null) { + matches.push({ type: "secret_field_assignment", match: assignMatch[0] }); + } + + return { + hasSecrets: matches.length > 0, + matches, + }; +} + +/** + * Redact Stellar secret keys and sensitive tokens embedded in arbitrary text. + */ +export function redactSecrets(input: string): string { + if (typeof input !== "string") return input; + let result = input; + + // Redact key-value assignments: secret_key="VAL" -> secret_key="[REDACTED]" + result = result.replace( + SECRET_ASSIGNMENT_REGEX, + (fullMatch, keyName, quote, val) => { + const q = quote || ""; + if (val.length === 56 && val.startsWith("S")) { + const redactedVal = val.slice(0, 4) + "[REDACTED]" + val.slice(-4); + return `${keyName}=${q}${redactedVal}${q}`; + } + return `${keyName}=${q}[REDACTED]${q}`; + } + ); + + // Redact standalone Stellar secret keys + result = result.replace(/S[A-Z2-7]{55}/gi, (match) => { + return match.slice(0, 4) + "[REDACTED]" + match.slice(-4); + }); + + // Redact keywords if matched as isolated descriptors + for (const pattern of [/secret[\s_\-]?key/i, /private[\s_\-]?key/i, /seed[\s_\-]?phrase/i]) { + result = result.replace(pattern, "[REDACTED]"); + } + + + return result; +} + +/** + * Format a redacted secret key object into a safe human-readable string. + */ +export function formatRedactedSecret(redacted: RedactedSecretKey): string { + return `${redacted.prefix}••••••••••••••••••••••••••••••••••••••••••••••••••••${redacted.suffix}`; +} diff --git a/packages/stellar-kit/src/soroban.ts b/packages/stellar-kit/src/soroban.ts new file mode 100644 index 0000000..363ce44 --- /dev/null +++ b/packages/stellar-kit/src/soroban.ts @@ -0,0 +1,39 @@ +/** + * Experimental Soroban SDK capabilities. + * Protected by the `experimental_soroban` feature flag framework. + */ +import type { AnchorKitEnvConfig } from "@anchorkit/config"; +import { assertFeatureEnabled, DEFAULT_ENV_CONFIG, isFeatureEnabled } from "@anchorkit/config"; + +export interface ExperimentalSorobanResult { + capability: string; + enabled: boolean; + timestamp: string; + status: "executed" | "disabled"; +} + +export function executeSorobanCapability( + capabilityName: string, + options?: { env?: AnchorKitEnvConfig } +): ExperimentalSorobanResult { + const env = options?.env ?? DEFAULT_ENV_CONFIG; + assertFeatureEnabled("experimental_soroban", env); + + return { + capability: capabilityName, + enabled: true, + timestamp: new Date().toISOString(), + status: "executed", + }; +} + +export function diagnoseSorobanCapability(options?: { env?: AnchorKitEnvConfig }): { + enabled: boolean; + stability: "experimental"; +} { + const env = options?.env ?? DEFAULT_ENV_CONFIG; + return { + enabled: isFeatureEnabled("experimental_soroban", env), + stability: "experimental", + }; +} diff --git a/packages/stellar-kit/src/vault.ts b/packages/stellar-kit/src/vault.ts new file mode 100644 index 0000000..89482d9 --- /dev/null +++ b/packages/stellar-kit/src/vault.ts @@ -0,0 +1,39 @@ +/** + * Experimental Vault SDK capabilities. + * Protected by the `experimental_vault` feature flag framework. + */ +import type { AnchorKitEnvConfig } from "@anchorkit/config"; +import { assertFeatureEnabled, DEFAULT_ENV_CONFIG, isFeatureEnabled } from "@anchorkit/config"; + +export interface VaultSessionResult { + vaultId: string; + enabled: boolean; + timestamp: string; + status: "active" | "disabled"; +} + +export function createVaultSession( + vaultId: string, + options?: { env?: AnchorKitEnvConfig } +): VaultSessionResult { + const env = options?.env ?? DEFAULT_ENV_CONFIG; + assertFeatureEnabled("experimental_vault", env); + + return { + vaultId, + enabled: true, + timestamp: new Date().toISOString(), + status: "active", + }; +} + +export function diagnoseVaultCapability(options?: { env?: AnchorKitEnvConfig }): { + enabled: boolean; + stability: "experimental"; +} { + const env = options?.env ?? DEFAULT_ENV_CONFIG; + return { + enabled: isFeatureEnabled("experimental_vault", env), + stability: "experimental", + }; +} diff --git a/packages/stellar-kit/test/featureFlags.test.ts b/packages/stellar-kit/test/featureFlags.test.ts new file mode 100644 index 0000000..c8d9d10 --- /dev/null +++ b/packages/stellar-kit/test/featureFlags.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_ENV_CONFIG } from "@anchorkit/config"; +import { + createFeatureDisabledError, + createVaultSession, + diagnoseConfig, + diagnoseSorobanCapability, + diagnoseVaultCapability, + executeSorobanCapability, +} from "../src"; + +describe("StellarKit Feature Flag & Diagnostics Integration", () => { + it("creates typed feature disabled error", () => { + const error = createFeatureDisabledError( + "experimental_soroban", + "Experimental Soroban Support", + "experimental" + ); + expect(error.code).toBe("FEATURE_DISABLED"); + expect(error.name).toBe("StellarKitError"); + expect(error.redacted).toBe(true); + expect(error.message).toContain("Experimental Soroban Support"); + expect(error.message).toContain("experimental_soroban"); + }); + + it("diagnoses configuration safely including non-sensitive metadata and feature flags", () => { + const diag = diagnoseConfig(DEFAULT_ENV_CONFIG); + expect(diag.configSources.length).toBeGreaterThan(0); + expect(diag.featureFlags.length).toBeGreaterThan(0); + expect(diag.isAllStable).toBe(true); + + const secretKeyMeta = diag.configSources.find((c) => c.key === "secretKeyPrefix"); + expect(secretKeyMeta?.isSensitive).toBe(true); + expect(secretKeyMeta?.resolvedValue).toBe("[REDACTED]"); + }); + + it("detects non-stable features when experimental flag is enabled in diagnostics", () => { + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + }, + }; + const diag = diagnoseConfig(customConfig); + expect(diag.isAllStable).toBe(false); + }); + + describe("Experimental Soroban Capabilities", () => { + it("throws typed FEATURE_DISABLED error by default", () => { + expect(() => executeSorobanCapability("deploy_contract")).toThrowError(); + try { + executeSorobanCapability("deploy_contract"); + } catch (err: any) { + expect(err.code).toBe("FEATURE_DISABLED"); + } + }); + + it("executes successfully when experimental_soroban is enabled", () => { + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + }, + }; + + const result = executeSorobanCapability("deploy_contract", { env: customConfig }); + expect(result.status).toBe("executed"); + expect(result.capability).toBe("deploy_contract"); + }); + + it("diagnoses soroban capability status", () => { + expect(diagnoseSorobanCapability().enabled).toBe(false); + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_soroban: true, + }, + }; + expect(diagnoseSorobanCapability({ env: customConfig }).enabled).toBe(true); + }); + }); + + describe("Experimental Vault Capabilities", () => { + it("throws typed FEATURE_DISABLED error by default", () => { + expect(() => createVaultSession("vault_123")).toThrowError(); + try { + createVaultSession("vault_123"); + } catch (err: any) { + expect(err.code).toBe("FEATURE_DISABLED"); + } + }); + + it("executes successfully when experimental_vault is enabled", () => { + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_vault: true, + }, + }; + + const result = createVaultSession("vault_123", { env: customConfig }); + expect(result.status).toBe("active"); + expect(result.vaultId).toBe("vault_123"); + }); + + it("diagnoses vault capability status", () => { + expect(diagnoseVaultCapability().enabled).toBe(false); + const customConfig = { + ...DEFAULT_ENV_CONFIG, + featureFlags: { + experimental_vault: true, + }, + }; + expect(diagnoseVaultCapability({ env: customConfig }).enabled).toBe(true); + }); + }); +}); diff --git a/packages/stellar-kit/test/payments.test.ts b/packages/stellar-kit/test/payments.test.ts index badb3a4..0779f17 100644 --- a/packages/stellar-kit/test/payments.test.ts +++ b/packages/stellar-kit/test/payments.test.ts @@ -33,9 +33,9 @@ describe("Amount validation", () => { expect(isAmountValid("0.00000001")).toBe(false); }); - it("rejects amounts exceeding MAX (1e12 - epsilon)", () => { + it("rejects amounts exceeding MAX (1e12)", () => { expect(isAmountValid("999999999999.9999999")).toBe(true); - expect(isAmountValid("1000000000000")).toBe(false); + expect(isAmountValid("1000000000001")).toBe(false); }); it("rejects sub-stroop amounts below 1e-7", () => { diff --git a/packages/stellar-kit/test/redaction.test.ts b/packages/stellar-kit/test/redaction.test.ts new file mode 100644 index 0000000..8a25580 --- /dev/null +++ b/packages/stellar-kit/test/redaction.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { + redactSecrets, + containsSecret, + detectUnsafePatterns, + redactSecretKey, + formatRedactedSecret, + secretKeyToRedactedString, + createStellarError, + diagnoseAccount, + diagnoseAccountInfo, + createSafeLogger, +} from "../src"; + +const SAMPLE_SECRET = "SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4"; +const SAMPLE_PUBLIC = "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR"; + +describe("Secret Redaction Utilities", () => { + it("redacts 56-character Stellar secret keys from log messages", () => { + const rawLog = `Authenticating account ${SAMPLE_PUBLIC} using secret ${SAMPLE_SECRET}`; + const scrubbed = redactSecrets(rawLog); + expect(scrubbed).not.toContain(SAMPLE_SECRET); + expect(scrubbed).toContain("SCZA[REDACTED]BTQ4"); + expect(scrubbed).toContain(SAMPLE_PUBLIC); + }); + + it("redacts key-value secret assignment patterns", () => { + const rawConfig = `secretKey="${SAMPLE_SECRET}" and private_key="super_secret_value"`; + const scrubbed = redactSecrets(rawConfig); + expect(scrubbed).not.toContain(SAMPLE_SECRET); + expect(scrubbed).not.toContain("super_secret_value"); + expect(scrubbed).toContain("[REDACTED]"); + }); + + it("does not redact valid Stellar public keys", () => { + const pubLog = `Account loaded: ${SAMPLE_PUBLIC}`; + const result = redactSecrets(pubLog); + expect(result).toBe(pubLog); + }); + + it("detects unsafe patterns in input text", () => { + expect(containsSecret(SAMPLE_SECRET)).toBe(true); + expect(containsSecret(`key=${SAMPLE_SECRET}`)).toBe(true); + expect(containsSecret(SAMPLE_PUBLIC)).toBe(false); + + const diag = detectUnsafePatterns(`Found ${SAMPLE_SECRET} in memory dump`); + expect(diag.hasSecrets).toBe(true); + expect(diag.matches.length).toBe(1); + expect(diag.matches[0]?.type).toBe("stellar_secret_key"); + }); + + it("redacts secret keys inside createStellarError messages", () => { + const err = createStellarError( + "SECRET_KEY_INVALID", + `Failed to initialize with key ${SAMPLE_SECRET}` + ); + expect(err.message).not.toContain(SAMPLE_SECRET); + expect(err.message).toContain("SCZA[REDACTED]BTQ4"); + expect(err.redacted).toBe(true); + }); + + it("diagnoseAccount redacts secret keys when passed as input", async () => { + const diag = await diagnoseAccount(SAMPLE_SECRET); + expect(diag.state).toBe("invalid"); + expect(diag.input).not.toContain(SAMPLE_SECRET); + expect(diag.input).toContain("SCZA[REDACTED]BTQ4"); + expect(diag.isValidPublicKey).toBe(false); + }); + + it("diagnoseAccountInfo redacts error strings containing secrets", () => { + const diag = diagnoseAccountInfo({ + publicKey: SAMPLE_PUBLIC as any, + status: "error", + error: `Horizon error while authenticating ${SAMPLE_SECRET}`, + }); + expect(diag.error).not.toContain(SAMPLE_SECRET); + expect(diag.error).toContain("SCZA[REDACTED]BTQ4"); + }); + + it("createSafeLogger redacts secrets from objects, arrays, and errors", () => { + let captured = ""; + const logger = createSafeLogger({ + log: (...args: unknown[]) => { + captured = args.join(" "); + }, + }); + + logger.log("user logged in", { secret: SAMPLE_SECRET, nested: { key: SAMPLE_SECRET } }); + expect(captured).not.toContain(SAMPLE_SECRET); + expect(captured).toContain("[REDACTED]"); + }); + + it("secretKeyToRedactedString returns formatted redacted string for valid keys", () => { + const redacted = secretKeyToRedactedString(SAMPLE_SECRET); + expect(redacted).toBe(formatRedactedSecret(redactSecretKey(SAMPLE_SECRET))); + expect(redacted).not.toContain(SAMPLE_SECRET.slice(4, -4)); + }); + + it("secretKeyToRedactedString redacts invalid secret input safely", () => { + const redacted = secretKeyToRedactedString("SINVALID_SECRET"); + expect(redacted).not.toContain("SINVALID_SECRET"); + expect(redacted).toBe("[INVALID_SECRET_KEY]"); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cfd5954..c1ac690 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -366,6 +366,33 @@ export interface EscrowEvent { details?: Record; } +export type FeatureStability = "stable" | "experimental" | "deprecated"; + +export type FeatureFlagId = + | "experimental_soroban" + | "experimental_vault" + | "mainnet_access" + | "advanced_diagnostics" + | (string & {}); + +export interface FeatureFlagDefinition { + id: FeatureFlagId; + name: string; + description: string; + stability: FeatureStability; + defaultEnabled: boolean; +} + +export type ConfigSource = "default" | "env" | "explicit" | "override"; + +export interface ConfigSourceMetadata { + source: ConfigSource; + key: string; + isSensitive: boolean; + resolvedValue?: unknown; + stability?: FeatureStability; +} + export type StellarErrorCode = | "PUBLIC_KEY_INVALID" | "SECRET_KEY_INVALID" @@ -378,6 +405,8 @@ export type StellarErrorCode = | "TRANSACTION_HASH_INVALID" | "NETWORK_ERROR" | "MAINNET_DISABLED" + | "FEATURE_DISABLED" + | "UNSUPPORTED_FEATURE" | "UNAUTHORIZED" | "UNKNOWN"; diff --git a/scripts/validate-issues.mts b/scripts/validate-issues.mts new file mode 100644 index 0000000..5a3c9d9 --- /dev/null +++ b/scripts/validate-issues.mts @@ -0,0 +1,124 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const ALLOWED_LABELS = [ + 'bug', + 'enhancement', + 'documentation', + 'good first issue', + 'help wanted', + 'feature', +]; + +const ALLOWED_COMPLEXITIES = ['low', 'medium', 'high', 'expert']; + +export interface Issue { + title: string; + description: string; + labels: string[]; + complexity: 'low' | 'medium' | 'high' | 'expert'; + acceptanceCriteria: string[]; +} + +export function validateIssue(data: unknown): { success: boolean; errors?: string[] } { + const errors: string[] = []; + + if (typeof data !== 'object' || data === null) { + return { success: false, errors: ['Issue payload must be an object'] }; + } + + const issue = data as Record; + + if (typeof issue.title !== 'string' || issue.title.trim().length === 0) { + errors.push('title: Required'); + } + + if (typeof issue.description !== 'string' || issue.description.trim().length === 0) { + errors.push('description: Required'); + } + + if (!Array.isArray(issue.labels) || issue.labels.length === 0) { + errors.push('labels: At least one label is required'); + } else { + const hasUnsupported = issue.labels.some( + (label) => typeof label !== 'string' || !ALLOWED_LABELS.includes(label) + ); + if (hasUnsupported) { + errors.push('labels: Contains unsupported labels'); + } + } + + if ( + typeof issue.complexity !== 'string' || + !ALLOWED_COMPLEXITIES.includes(issue.complexity) + ) { + errors.push('complexity: Complexity must be low, medium, high, or expert'); + } + + if (!Array.isArray(issue.acceptanceCriteria) || issue.acceptanceCriteria.length === 0) { + errors.push('acceptanceCriteria: At least one acceptance criteria is required'); + } else { + const hasWeak = issue.acceptanceCriteria.some( + (criterion) => typeof criterion !== 'string' || criterion.trim().length <= 10 + ); + if (hasWeak) { + errors.push('acceptanceCriteria: Weak acceptance criteria detected (must be > 10 characters)'); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true }; +} + +async function run() { + const issuesDir = path.join(process.cwd(), 'issues'); + + if (!fs.existsSync(issuesDir)) { + console.warn(`Issues directory not found at ${issuesDir}`); + process.exit(0); + } + + const files = fs.readdirSync(issuesDir).filter(f => f.endsWith('.json')); + let hasErrors = false; + + for (const file of files) { + const filePath = path.join(issuesDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + + let json: unknown; + try { + json = JSON.parse(content); + } catch (e) { + console.error(`❌ [${file}] Invalid JSON formatting`); + hasErrors = true; + continue; + } + + const { success, errors } = validateIssue(json); + + if (success) { + console.log(`✅ [${file}] Valid issue`); + } else { + console.error(`❌ [${file}] Validation failed:`); + errors?.forEach(err => console.error(` - ${err}`)); + hasErrors = true; + } + } + + if (hasErrors) { + process.exit(1); + } else { + console.log('All issues are valid!'); + } +} + +// Run script directly when executed via CLI +if (process.argv[1] && process.argv[1].includes('validate-issues')) { + run().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/tests/validate-issues.test.ts b/tests/validate-issues.test.ts new file mode 100644 index 0000000..eaffb54 --- /dev/null +++ b/tests/validate-issues.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { validateIssue } from '../scripts/validate-issues.mts'; + +describe('validateIssue', () => { + it('should pass for a valid issue', () => { + const validIssue = { + title: 'Fix login bug', + description: 'Users cannot log in when using Safari.', + labels: ['bug'], + complexity: 'high', + acceptanceCriteria: ['Users can log in using Safari on iOS and macOS'], + }; + + const result = validateIssue(validIssue); + expect(result.success).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + it('should fail if missing required fields', () => { + const invalidIssue = { + title: 'Missing stuff', + // description is missing + labels: ['bug'], + complexity: 'medium', + acceptanceCriteria: ['This is criteria'], + }; + + const result = validateIssue(invalidIssue); + expect(result.success).toBe(false); + expect(result.errors).toContain('description: Required'); + }); + + it('should fail if using unsupported labels', () => { + const invalidIssue = { + title: 'Unsupported label', + description: 'Has a bad label', + labels: ['invalid-label'], + complexity: 'low', + acceptanceCriteria: ['This is criteria'], + }; + + const result = validateIssue(invalidIssue); + expect(result.success).toBe(false); + expect(result.errors).toContain('labels: Contains unsupported labels'); + }); + + it('should fail if complexity is invalid', () => { + const invalidIssue = { + title: 'Invalid complexity', + description: 'Has bad complexity', + labels: ['enhancement'], + complexity: 'trivial', // not allowed + acceptanceCriteria: ['This is criteria'], + }; + + const result = validateIssue(invalidIssue); + expect(result.success).toBe(false); + expect(result.errors).toContain('complexity: Complexity must be low, medium, high, or expert'); + }); + + it('should fail if acceptance criteria are weak', () => { + const invalidIssue = { + title: 'Weak criteria', + description: 'Has weak criteria', + labels: ['enhancement'], + complexity: 'low', + acceptanceCriteria: ['too short'], + }; + + const result = validateIssue(invalidIssue); + expect(result.success).toBe(false); + expect(result.errors).toContain('acceptanceCriteria: Weak acceptance criteria detected (must be > 10 characters)'); + }); +});