From 9511fa2d42dbab4880fa96ef2dd6aa5e5b52e518 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 00:34:51 +0100 Subject: [PATCH 1/4] Refactor: Implement validation governance pattern and reorganise schemas --- docs/VALIDATION_GOVERNANCE.md | 34 +++ packages/validators/src/index.ts | 321 +-------------------- packages/validators/src/schemas/anchor.ts | 110 +++++++ packages/validators/src/schemas/escrow.ts | 36 +++ packages/validators/src/schemas/stellar.ts | 190 ++++++++++++ 5 files changed, 376 insertions(+), 315 deletions(-) create mode 100644 docs/VALIDATION_GOVERNANCE.md create mode 100644 packages/validators/src/schemas/anchor.ts create mode 100644 packages/validators/src/schemas/escrow.ts create mode 100644 packages/validators/src/schemas/stellar.ts diff --git a/docs/VALIDATION_GOVERNANCE.md b/docs/VALIDATION_GOVERNANCE.md new file mode 100644 index 0000000..2ce837e --- /dev/null +++ b/docs/VALIDATION_GOVERNANCE.md @@ -0,0 +1,34 @@ +# Validation Governance Pattern + +This document outlines the governance pattern for validation schemas shared across AnchorKit's packages and web forms. + +## Principles + +To prevent duplication and maintain a clear structure for reusable validation schemas, inferred types, error mapping, and package ownership, AnchorKit enforces the following rules: + +### 1. Schema Ownership (`@anchorkit/validators`) +The `@anchorkit/validators` package is the single source of truth for runtime validation schemas. +- All Zod schemas (`z.object()`, `z.string().refine()`, etc.) must be defined here. +- Schemas are organized by domain (e.g., `stellar.ts`, `anchor.ts`, `escrow.ts`). +- Consumers (such as `@anchorkit/stellar-kit`, `@anchorkit/anchor-utils`, or `apps/web`) must import these shared schemas rather than re-defining them inline. + +### 2. Inferred Types +To prevent drift between static TypeScript types and runtime validation, schemas that produce complex objects should have their inferred types exported safely. +- Naming convention: `Parsed[SchemaName]`, e.g., `export type ParsedAnchorAssetConfig = z.infer;` +- `@anchorkit/types` remains the owner of branded primitives (e.g., `StellarPublicKey`) and literal unions (e.g., `AnchorTransactionStatus`), which are imported by validators to ensure schema parity. + +### 3. Error Mapping +Directly exposing raw Zod errors to end-users or API consumers can leak implementation details. +- Use the shared Validation Engine (`validationEngine.ts`) located in `@anchorkit/validators`. +- The engine standardizes raw validation output into a safe, uniform `ValidationResult` with user-friendly error codes and messages. +- Web forms and API endpoints should rely on the validation engine wrappers rather than `Schema.safeParse` directly when presenting errors. + +## Adding a New Validator + +When contributing a new shared feature or web form that requires validation: +1. Determine the domain (e.g., `anchor`, `escrow`, `stellar`). +2. Add the schema to the corresponding file in `packages/validators/src/schemas/`. +3. Export any complex inferred types. +4. Export the schema from `packages/validators/src/index.ts`. +5. Add unit tests for your schema in `packages/validators/test/`. +6. (Optional) If it requires complex conditional validation, create a wrapper function in `validationEngine.ts` to map the errors. diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index c63595d..9cc7950 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1,319 +1,10 @@ -import { z } from "zod"; -import { - ANCHOR_TRANSACTION_STATUSES, - MILESTONE_STATUSES, - STELLAR_NETWORKS, -} from "@anchorkit/types"; -import type { - AnchorTransactionStatus, - AssetCode, - MilestoneStatus, - StellarNetwork, - StellarPublicKey, - StellarSecretKey, - StellarTransactionHash, -} from "@anchorkit/types"; -import { DEFAULT_ENV_CONFIG } from "@anchorkit/config"; +export { z } from "zod"; -const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -const PUBLIC_KEY_LENGTH = 56; -const SECRET_KEY_LENGTH = 56; -const TX_HASH_LENGTH = 64; - -function isValidBase32(input: string): boolean { - for (let i = 0; i < input.length; i++) { - if (!BASE32_ALPHABET.includes(input[i]!)) { - return false; - } - } - return true; -} - -export const StellarPublicKeySchema = z - .string() - .refine((val) => val.length === PUBLIC_KEY_LENGTH, { - message: `Stellar public key must be exactly ${PUBLIC_KEY_LENGTH} characters`, - }) - .refine((val) => val.startsWith("G"), { - message: "Stellar public key must start with 'G'", - }) - .refine((val) => isValidBase32(val), { - message: "Stellar public key must contain only valid base32 characters (A-Z, 2-7)", - }) - .transform((val) => val as StellarPublicKey); - -export const StellarSecretKeySchema = z - .string() - .refine((val) => val.length === SECRET_KEY_LENGTH, { - message: `Stellar secret key must be exactly ${SECRET_KEY_LENGTH} characters`, - }) - .refine((val) => val.startsWith("S"), { - message: "Stellar secret key must start with 'S'", - }) - .refine((val) => isValidBase32(val), { - message: "Stellar secret key must contain only valid base32 characters (A-Z, 2-7)", - }) - .transform((val) => val as StellarSecretKey); - -export const StellarTransactionHashSchema = z - .string() - .refine((val) => val.length === TX_HASH_LENGTH, { - message: `Stellar transaction hash must be exactly ${TX_HASH_LENGTH} hex characters`, - }) - .refine((val) => /^[0-9a-fA-F]+$/.test(val), { - message: "Stellar transaction hash must be a valid hex string", - }) - .transform((val) => val as StellarTransactionHash); - -export const StellarNetworkSchema = z.enum( - [STELLAR_NETWORKS.TESTNET, STELLAR_NETWORKS.MAINNET, STELLAR_NETWORKS.FUTURENET] as [ - StellarNetwork, - ...StellarNetwork[] - ] -); - -export const MemoTypeSchema = z.enum(["none", "text", "id", "hash", "return"]); - -export const MemoInputSchema = z - .object({ - type: MemoTypeSchema, - value: z.string().max(28, "Memo value exceeds 28 byte limit for text memo"), - }) - .superRefine((data, ctx) => { - if (data.type === "text") { - const encoder = new TextEncoder(); - const bytes = encoder.encode(data.value); - if (bytes.length > DEFAULT_ENV_CONFIG.maximumMemoTextBytes) { - ctx.addIssue({ - code: z.ZodIssueCode.too_big, - maximum: DEFAULT_ENV_CONFIG.maximumMemoTextBytes, - type: "string", - inclusive: true, - message: `Memo text exceeds ${DEFAULT_ENV_CONFIG.maximumMemoTextBytes} byte limit`, - path: ["value"], - }); - } - } - if (data.type === "id") { - if (!/^\d+$/.test(data.value)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Memo ID must be a non-negative 64-bit integer string", - path: ["value"], - }); - } - } - if (data.type === "hash" || data.type === "return") { - if (!/^[0-9a-fA-F]{64}$/.test(data.value)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Memo hash/return must be a 64-character hex string", - path: ["value"], - }); - } - } - }); - -export const AssetCodeSchema = z - .string() - .min(1, "Asset code must not be empty") - .max(12, "Asset code must be at most 12 characters") - .regex(/^[a-zA-Z0-9]+$/, "Asset code must contain only alphanumeric characters") - .transform((val) => val as AssetCode); - -export const NativeAssetSchema = z.object({ - type: z.literal("native"), - code: z.literal("XLM"), - issuer: z.null(), -}); - -export const IssuedAssetSchema = z.object({ - type: z.literal("issued"), - code: AssetCodeSchema, - issuer: StellarPublicKeySchema, -}); - -export const StellarAssetSchema = z.discriminatedUnion("type", [ - NativeAssetSchema, - IssuedAssetSchema, -]); - -export const PaymentAmountSchema = z - .string() - .regex(/^\d+(\.\d{1,7})?$/, "Amount must be a valid Stellar amount (max 7 decimal places)") - .superRefine((val, ctx) => { - const num = Number(val); - if (Number.isNaN(num)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Amount is not a valid number", - }); - return; - } - if (num <= 0) { - ctx.addIssue({ - code: z.ZodIssueCode.too_small, - minimum: 0, - type: "number", - inclusive: false, - message: "Amount must be greater than zero", - }); - } - const min = Number(DEFAULT_ENV_CONFIG.minimumPaymentAmount); - const max = Number(DEFAULT_ENV_CONFIG.maximumPaymentAmount); - if (num < min) { - ctx.addIssue({ - code: z.ZodIssueCode.too_small, - minimum: min, - type: "number", - inclusive: true, - message: `Amount must be at least ${DEFAULT_ENV_CONFIG.minimumPaymentAmount}`, - }); - } - if (num > max) { - ctx.addIssue({ - code: z.ZodIssueCode.too_big, - maximum: max, - type: "number", - inclusive: true, - message: `Amount must not exceed ${DEFAULT_ENV_CONFIG.maximumPaymentAmount}`, - }); - } - }); - -export const PaymentIntentSchema = z.object({ - sourcePublicKey: StellarPublicKeySchema, - destinationPublicKey: StellarPublicKeySchema, - asset: StellarAssetSchema, - amount: PaymentAmountSchema, - memo: MemoInputSchema.optional(), -}); - -export const AnchorTransactionStatusSchema = z.enum( - ANCHOR_TRANSACTION_STATUSES as [AnchorTransactionStatus, ...AnchorTransactionStatus[]] -); - -export const AnchorTransactionKindSchema = z.enum(["deposit", "withdrawal"]); - -export const AnchorAssetConfigSchema = z.object({ - code: z.string().min(1).max(12), - issuer: StellarPublicKeySchema, - schema: z.enum(["stellar", "iso4217"]), - enabled: z.boolean(), - depositEnabled: z.boolean(), - withdrawalEnabled: z.boolean(), - depositMinAmount: z.string().optional(), - depositMaxAmount: z.string().optional(), - withdrawalMinAmount: z.string().optional(), - withdrawalMaxAmount: z.string().optional(), - feeFixed: z.string().optional(), - feePercent: z.string().optional(), -}); - -export const PaymentRailConfigSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - kind: z.enum(["bank_transfer", "card", "cash", "crypto", "other"]), - currencies: z.array(z.string()).min(1), - countries: z.array(z.string()).min(1), - enabled: z.boolean(), - estimatedProcessingMinutesMin: z.number().int().min(0), - estimatedProcessingMinutesMax: z.number().int().min(0), -}); - -export const DepositRequestMetadataSchema = z.object({ - assetCode: z.string().min(1).max(12), - amount: PaymentAmountSchema, - account: StellarPublicKeySchema, - memo: z.string().optional(), - memoType: MemoTypeSchema.optional(), - railId: z.string().optional(), - clientDomain: z.string().optional(), - emailAddress: z.string().email().optional(), - type: z.string(), -}); - -export const WithdrawalRequestMetadataSchema = z.object({ - assetCode: z.string().min(1).max(12), - amount: PaymentAmountSchema, - account: StellarPublicKeySchema, - memo: z.string().optional(), - memoType: MemoTypeSchema.optional(), - railId: z.string().optional(), - clientDomain: z.string().optional(), - dest: z.string().min(1), - destExtra: z.string().optional(), - type: z.string(), -}); - -export const AnchorTransactionRecordSchema = z.object({ - id: z.string().min(1), - kind: AnchorTransactionKindSchema, - status: AnchorTransactionStatusSchema, - assetCode: z.string().min(1).max(12), - amountIn: PaymentAmountSchema, - amountOut: PaymentAmountSchema.optional(), - feeAmount: PaymentAmountSchema.optional(), - stellarAccount: StellarPublicKeySchema, - stellarTransactionId: StellarTransactionHashSchema.optional(), - externalTransactionId: z.string().optional(), - startedAt: z.string().datetime(), - updatedAt: z.string().datetime(), - completedAt: z.string().datetime().optional(), - userActionRequired: z.boolean().optional(), - userActionUrl: z.string().url().optional(), - message: z.string().optional(), - refunded: z.boolean().optional(), - metadata: z.record(z.unknown()).default({}), -}); - -export const MilestoneStatusSchema = z.enum( - MILESTONE_STATUSES as [MilestoneStatus, ...MilestoneStatus[]] -); - -export const MilestoneSchema = z.object({ - id: z.string().min(1), - title: z.string().min(1), - description: z.string().optional(), - amount: PaymentAmountSchema, - status: MilestoneStatusSchema, - evidenceHash: z.string().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - approvedAt: z.string().datetime().optional(), - releasedAt: z.string().datetime().optional(), - disputedAt: z.string().datetime().optional(), - disputeReason: z.string().optional(), -}); - -export const EscrowSummarySchema = z.object({ - totalMilestones: z.number().int().min(0), - totalAmount: z.string(), - releasedAmount: z.string(), - pendingAmount: z.string(), - disputedCount: z.number().int().min(0), - completedCount: z.number().int().min(0), - admin: z.string().min(1), -}); - -export const CallbackUrlSchema = z.string().url().superRefine((val, ctx) => { - try { - const url = new URL(val); - if (url.protocol !== "https:" && url.hostname !== "localhost") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Callback URL must use HTTPS in production (localhost is allowed for testing)", - }); - } - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Invalid URL format", - }); - } -}); - -export { z }; +// Re-export domain schemas +export * from "./schemas/stellar"; +export * from "./schemas/anchor"; +export * from "./schemas/escrow"; // ─── Validation engine (issue #6) ─────────────────────────────────────────── export * from "./validationEngine"; + diff --git a/packages/validators/src/schemas/anchor.ts b/packages/validators/src/schemas/anchor.ts new file mode 100644 index 0000000..f4fe1b2 --- /dev/null +++ b/packages/validators/src/schemas/anchor.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; +import { ANCHOR_TRANSACTION_STATUSES } from "@anchorkit/types"; +import type { AnchorTransactionStatus } from "@anchorkit/types"; +import { + StellarPublicKeySchema, + StellarTransactionHashSchema, + MemoTypeSchema, + PaymentAmountSchema, +} from "./stellar"; + +export const AnchorTransactionStatusSchema = z.enum( + ANCHOR_TRANSACTION_STATUSES as [AnchorTransactionStatus, ...AnchorTransactionStatus[]] +); + +export const AnchorTransactionKindSchema = z.enum(["deposit", "withdrawal"]); + +export const AnchorAssetConfigSchema = z.object({ + code: z.string().min(1).max(12), + issuer: StellarPublicKeySchema, + schema: z.enum(["stellar", "iso4217"]), + enabled: z.boolean(), + depositEnabled: z.boolean(), + withdrawalEnabled: z.boolean(), + depositMinAmount: z.string().optional(), + depositMaxAmount: z.string().optional(), + withdrawalMinAmount: z.string().optional(), + withdrawalMaxAmount: z.string().optional(), + feeFixed: z.string().optional(), + feePercent: z.string().optional(), +}); + +export const PaymentRailConfigSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + kind: z.enum(["bank_transfer", "card", "cash", "crypto", "other"]), + currencies: z.array(z.string()).min(1), + countries: z.array(z.string()).min(1), + enabled: z.boolean(), + estimatedProcessingMinutesMin: z.number().int().min(0), + estimatedProcessingMinutesMax: z.number().int().min(0), +}); + +export const DepositRequestMetadataSchema = z.object({ + assetCode: z.string().min(1).max(12), + amount: PaymentAmountSchema, + account: StellarPublicKeySchema, + memo: z.string().optional(), + memoType: MemoTypeSchema.optional(), + railId: z.string().optional(), + clientDomain: z.string().optional(), + emailAddress: z.string().email().optional(), + type: z.string(), +}); + +export const WithdrawalRequestMetadataSchema = z.object({ + assetCode: z.string().min(1).max(12), + amount: PaymentAmountSchema, + account: StellarPublicKeySchema, + memo: z.string().optional(), + memoType: MemoTypeSchema.optional(), + railId: z.string().optional(), + clientDomain: z.string().optional(), + dest: z.string().min(1), + destExtra: z.string().optional(), + type: z.string(), +}); + +export const AnchorTransactionRecordSchema = z.object({ + id: z.string().min(1), + kind: AnchorTransactionKindSchema, + status: AnchorTransactionStatusSchema, + assetCode: z.string().min(1).max(12), + amountIn: PaymentAmountSchema, + amountOut: PaymentAmountSchema.optional(), + feeAmount: PaymentAmountSchema.optional(), + stellarAccount: StellarPublicKeySchema, + stellarTransactionId: StellarTransactionHashSchema.optional(), + externalTransactionId: z.string().optional(), + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().optional(), + userActionRequired: z.boolean().optional(), + userActionUrl: z.string().url().optional(), + message: z.string().optional(), + refunded: z.boolean().optional(), + metadata: z.record(z.unknown()).default({}), +}); + +export const CallbackUrlSchema = z.string().url().superRefine((val, ctx) => { + try { + const url = new URL(val); + if (url.protocol !== "https:" && url.hostname !== "localhost") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Callback URL must use HTTPS in production (localhost is allowed for testing)", + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid URL format", + }); + } +}); + +export type ParsedAnchorAssetConfig = z.infer; +export type ParsedPaymentRailConfig = z.infer; +export type ParsedDepositRequestMetadata = z.infer; +export type ParsedWithdrawalRequestMetadata = z.infer; +export type ParsedAnchorTransactionRecord = z.infer; diff --git a/packages/validators/src/schemas/escrow.ts b/packages/validators/src/schemas/escrow.ts new file mode 100644 index 0000000..5333c0b --- /dev/null +++ b/packages/validators/src/schemas/escrow.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { MILESTONE_STATUSES } from "@anchorkit/types"; +import type { MilestoneStatus } from "@anchorkit/types"; +import { PaymentAmountSchema } from "./stellar"; + +export const MilestoneStatusSchema = z.enum( + MILESTONE_STATUSES as [MilestoneStatus, ...MilestoneStatus[]] +); + +export const MilestoneSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().optional(), + amount: PaymentAmountSchema, + status: MilestoneStatusSchema, + evidenceHash: z.string().optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + approvedAt: z.string().datetime().optional(), + releasedAt: z.string().datetime().optional(), + disputedAt: z.string().datetime().optional(), + disputeReason: z.string().optional(), +}); + +export const EscrowSummarySchema = z.object({ + totalMilestones: z.number().int().min(0), + totalAmount: z.string(), + releasedAmount: z.string(), + pendingAmount: z.string(), + disputedCount: z.number().int().min(0), + completedCount: z.number().int().min(0), + admin: z.string().min(1), +}); + +export type ParsedMilestone = z.infer; +export type ParsedEscrowSummary = z.infer; diff --git a/packages/validators/src/schemas/stellar.ts b/packages/validators/src/schemas/stellar.ts new file mode 100644 index 0000000..01f870a --- /dev/null +++ b/packages/validators/src/schemas/stellar.ts @@ -0,0 +1,190 @@ +import { z } from "zod"; +import { STELLAR_NETWORKS } from "@anchorkit/types"; +import type { + AssetCode, + StellarNetwork, + StellarPublicKey, + StellarSecretKey, + StellarTransactionHash, +} from "@anchorkit/types"; +import { DEFAULT_ENV_CONFIG } from "@anchorkit/config"; + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +const PUBLIC_KEY_LENGTH = 56; +const SECRET_KEY_LENGTH = 56; +const TX_HASH_LENGTH = 64; + +function isValidBase32(input: string): boolean { + for (let i = 0; i < input.length; i++) { + if (!BASE32_ALPHABET.includes(input[i]!)) { + return false; + } + } + return true; +} + +export const StellarPublicKeySchema = z + .string() + .refine((val) => val.length === PUBLIC_KEY_LENGTH, { + message: `Stellar public key must be exactly ${PUBLIC_KEY_LENGTH} characters`, + }) + .refine((val) => val.startsWith("G"), { + message: "Stellar public key must start with 'G'", + }) + .refine((val) => isValidBase32(val), { + message: "Stellar public key must contain only valid base32 characters (A-Z, 2-7)", + }) + .transform((val) => val as StellarPublicKey); + +export const StellarSecretKeySchema = z + .string() + .refine((val) => val.length === SECRET_KEY_LENGTH, { + message: `Stellar secret key must be exactly ${SECRET_KEY_LENGTH} characters`, + }) + .refine((val) => val.startsWith("S"), { + message: "Stellar secret key must start with 'S'", + }) + .refine((val) => isValidBase32(val), { + message: "Stellar secret key must contain only valid base32 characters (A-Z, 2-7)", + }) + .transform((val) => val as StellarSecretKey); + +export const StellarTransactionHashSchema = z + .string() + .refine((val) => val.length === TX_HASH_LENGTH, { + message: `Stellar transaction hash must be exactly ${TX_HASH_LENGTH} hex characters`, + }) + .refine((val) => /^[0-9a-fA-F]+$/.test(val), { + message: "Stellar transaction hash must be a valid hex string", + }) + .transform((val) => val as StellarTransactionHash); + +export const StellarNetworkSchema = z.enum( + [STELLAR_NETWORKS.TESTNET, STELLAR_NETWORKS.MAINNET, STELLAR_NETWORKS.FUTURENET] as [ + StellarNetwork, + ...StellarNetwork[] + ] +); + +export const MemoTypeSchema = z.enum(["none", "text", "id", "hash", "return"]); + +export const MemoInputSchema = z + .object({ + type: MemoTypeSchema, + value: z.string().max(28, "Memo value exceeds 28 byte limit for text memo"), + }) + .superRefine((data, ctx) => { + if (data.type === "text") { + const encoder = new TextEncoder(); + const bytes = encoder.encode(data.value); + if (bytes.length > DEFAULT_ENV_CONFIG.maximumMemoTextBytes) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: DEFAULT_ENV_CONFIG.maximumMemoTextBytes, + type: "string", + inclusive: true, + message: `Memo text exceeds ${DEFAULT_ENV_CONFIG.maximumMemoTextBytes} byte limit`, + path: ["value"], + }); + } + } + if (data.type === "id") { + if (!/^\d+$/.test(data.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Memo ID must be a non-negative 64-bit integer string", + path: ["value"], + }); + } + } + if (data.type === "hash" || data.type === "return") { + if (!/^[0-9a-fA-F]{64}$/.test(data.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Memo hash/return must be a 64-character hex string", + path: ["value"], + }); + } + } + }); + +export const AssetCodeSchema = z + .string() + .min(1, "Asset code must not be empty") + .max(12, "Asset code must be at most 12 characters") + .regex(/^[a-zA-Z0-9]+$/, "Asset code must contain only alphanumeric characters") + .transform((val) => val as AssetCode); + +export const NativeAssetSchema = z.object({ + type: z.literal("native"), + code: z.literal("XLM"), + issuer: z.null(), +}); + +export const IssuedAssetSchema = z.object({ + type: z.literal("issued"), + code: AssetCodeSchema, + issuer: StellarPublicKeySchema, +}); + +export const StellarAssetSchema = z.discriminatedUnion("type", [ + NativeAssetSchema, + IssuedAssetSchema, +]); + +export const PaymentAmountSchema = z + .string() + .regex(/^\d+(\.\d{1,7})?$/, "Amount must be a valid Stellar amount (max 7 decimal places)") + .superRefine((val, ctx) => { + const num = Number(val); + if (Number.isNaN(num)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Amount is not a valid number", + }); + return; + } + if (num <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.too_small, + minimum: 0, + type: "number", + inclusive: false, + message: "Amount must be greater than zero", + }); + } + const min = Number(DEFAULT_ENV_CONFIG.minimumPaymentAmount); + const max = Number(DEFAULT_ENV_CONFIG.maximumPaymentAmount); + if (num < min) { + ctx.addIssue({ + code: z.ZodIssueCode.too_small, + minimum: min, + type: "number", + inclusive: true, + message: `Amount must be at least ${DEFAULT_ENV_CONFIG.minimumPaymentAmount}`, + }); + } + if (num > max) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: max, + type: "number", + inclusive: true, + message: `Amount must not exceed ${DEFAULT_ENV_CONFIG.maximumPaymentAmount}`, + }); + } + }); + +export const PaymentIntentSchema = z.object({ + sourcePublicKey: StellarPublicKeySchema, + destinationPublicKey: StellarPublicKeySchema, + asset: StellarAssetSchema, + amount: PaymentAmountSchema, + memo: MemoInputSchema.optional(), +}); + +export type ParsedMemoInput = z.infer; +export type ParsedNativeAsset = z.infer; +export type ParsedIssuedAsset = z.infer; +export type ParsedStellarAsset = z.infer; +export type ParsedPaymentIntent = z.infer; From fc409231daf41c4669206c06d9d94b89eac82ef6 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 08:55:04 +0100 Subject: [PATCH 2/4] feat(validation): add GrantFox-style advanced issue validator, tests, and documentation --- docs/advanced-issues.md | 51 ++++++++++++++ issues/sample-issue.json | 12 ++++ package.json | 3 +- scripts/validate-issues.mts | 124 ++++++++++++++++++++++++++++++++++ tests/validate-issues.test.ts | 74 ++++++++++++++++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 docs/advanced-issues.md create mode 100644 issues/sample-issue.json create mode 100644 scripts/validate-issues.mts create mode 100644 tests/validate-issues.test.ts 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/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 5aacc97..93a4004 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,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/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)'); + }); +}); From d520b14c70432c8c19e1044ee8b25d8fbdbf0e11 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 09:23:32 +0100 Subject: [PATCH 3/4] feat(config): implement feature flag framework and safe config source metadata --- docs/feature-flags.md | 81 ++++++++++++ packages/config/src/index.ts | 123 +++++++++++++++++- packages/config/test/featureFlags.test.ts | 84 ++++++++++++ packages/stellar-kit/src/diagnostics.ts | 46 ++++++- packages/stellar-kit/src/errors.ts | 13 ++ packages/stellar-kit/src/index.ts | 2 + packages/stellar-kit/src/soroban.ts | 39 ++++++ packages/stellar-kit/src/vault.ts | 39 ++++++ .../stellar-kit/test/featureFlags.test.ts | 117 +++++++++++++++++ packages/types/src/index.ts | 29 +++++ 10 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 docs/feature-flags.md create mode 100644 packages/config/test/featureFlags.test.ts create mode 100644 packages/stellar-kit/src/soroban.ts create mode 100644 packages/stellar-kit/src/vault.ts create mode 100644 packages/stellar-kit/test/featureFlags.test.ts 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/packages/config/src/index.ts b/packages/config/src/index.ts index 0068b9c..e9bf1b7 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,4 +1,10 @@ -import type { NetworkConfig, StellarNetwork } from "@anchorkit/types"; +import type { + ConfigSourceMetadata, + FeatureFlagDefinition, + FeatureFlagId, + NetworkConfig, + StellarNetwork, +} from "@anchorkit/types"; import { STELLAR_NETWORKS } from "@anchorkit/types"; const TESTNET_CONFIG: NetworkConfig = { @@ -39,6 +45,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 +86,7 @@ export interface AnchorKitEnvConfig { maximumPaymentAmount: string; secretKeyPrefix: string; publicKeyPrefix: string; + featureFlags?: Partial>; } export const DEFAULT_ENV_CONFIG: AnchorKitEnvConfig = { @@ -61,6 +99,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 { @@ -85,3 +129,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 1544817..1e7a33b 100644 --- a/packages/stellar-kit/src/diagnostics.ts +++ b/packages/stellar-kit/src/diagnostics.ts @@ -8,8 +8,9 @@ * Stellar Expert link — without ever exposing secrets. */ -import type { AccountInfo, AccountStatus, NetworkConfig, StellarPublicKey } from "@anchorkit/types"; -import { getNetworkConfig } from "@anchorkit/config"; +import type { AccountInfo, AccountStatus, ConfigSourceMetadata, NetworkConfig, StellarPublicKey } from "@anchorkit/types"; +import type { AnchorKitEnvConfig } from "@anchorkit/config"; +import { DEFAULT_ENV_CONFIG, getFeatureFlagDefinitions, getNetworkConfig, isFeatureEnabled, resolveConfigSourceMetadata } from "@anchorkit/config"; import { isPublicKeyValid } from "./keys"; import { buildAccountLink } from "./explorer"; import { loadAccount } from "./accounts"; @@ -69,6 +70,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": @@ -115,6 +132,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 } = {} @@ -152,3 +193,4 @@ export async function diagnoseAccount( } } + diff --git a/packages/stellar-kit/src/errors.ts b/packages/stellar-kit/src/errors.ts index 90a90ad..72c37e9 100644 --- a/packages/stellar-kit/src/errors.ts +++ b/packages/stellar-kit/src/errors.ts @@ -15,6 +15,19 @@ export function createStellarError( return error; } +export function createFeatureDisabledError( + flagId: string, + featureName?: string, + stability?: string +): StellarKitError { + const nameStr = featureName ? `'${featureName}' (${flagId})` : `'${flagId}'`; + const stabilityStr = stability ? ` Feature stability: ${stability}.` : ""; + return createStellarError( + "FEATURE_DISABLED", + `Feature ${nameStr} is disabled by default.${stabilityStr} Enable it in config by setting featureFlags.${flagId}: true.` + ); +} + function sanitizeCause(cause: unknown): unknown { if (cause instanceof Error) { const sanitizedMessage = redactSecrets(cause.message); diff --git a/packages/stellar-kit/src/index.ts b/packages/stellar-kit/src/index.ts index 6d86f89..f36b6f2 100644 --- a/packages/stellar-kit/src/index.ts +++ b/packages/stellar-kit/src/index.ts @@ -9,4 +9,6 @@ export * from "./escrowEvents"; export * from "./logging"; export * from "./explorer"; export * from "./diagnostics"; +export * from "./soroban"; +export * from "./vault"; export type { StellarKeypair } from "@anchorkit/types"; 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/types/src/index.ts b/packages/types/src/index.ts index df50442..8a58c72 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -252,6 +252,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" @@ -263,6 +290,8 @@ export type StellarErrorCode = | "TRANSACTION_HASH_INVALID" | "NETWORK_ERROR" | "MAINNET_DISABLED" + | "FEATURE_DISABLED" + | "UNSUPPORTED_FEATURE" | "UNAUTHORIZED" | "UNKNOWN"; From da62070a0588e8fc53a3d4df2f9ee04598aa3780 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 11:11:36 +0100 Subject: [PATCH 4/4] feat(security): implement central secret redaction framework, diagnostics integration, and tests --- docs/secret-redaction.md | 11 +- packages/stellar-kit/src/diagnostics.ts | 12 +- packages/stellar-kit/src/errors.ts | 35 ++---- packages/stellar-kit/src/index.ts | 2 + packages/stellar-kit/src/keys.ts | 21 ++-- packages/stellar-kit/src/redaction.ts | 121 ++++++++++++++++++++ packages/stellar-kit/test/keys.test.ts | 3 +- packages/stellar-kit/test/payments.test.ts | 4 +- packages/stellar-kit/test/redaction.test.ts | 104 +++++++++++++++++ packages/validators/src/schemas/stellar.ts | 3 +- 10 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 packages/stellar-kit/src/redaction.ts create mode 100644 packages/stellar-kit/test/redaction.test.ts 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/packages/stellar-kit/src/diagnostics.ts b/packages/stellar-kit/src/diagnostics.ts index 1e7a33b..d935578 100644 --- a/packages/stellar-kit/src/diagnostics.ts +++ b/packages/stellar-kit/src/diagnostics.ts @@ -14,6 +14,7 @@ import { DEFAULT_ENV_CONFIG, getFeatureFlagDefinitions, getNetworkConfig, isFeat import { isPublicKeyValid } from "./keys"; import { buildAccountLink } from "./explorer"; import { loadAccount } from "./accounts"; +import { redactSecrets } from "./redaction"; /** Diagnostic states — superset of the raw `AccountStatus`. */ export type AccountDiagnosticState = @@ -116,13 +117,13 @@ 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, account: info, - error: info.error ?? null, + error: info.error ? redactSecrets(info.error) : null, }; } @@ -164,7 +165,7 @@ export async function diagnoseAccount( if (!isPublicKeyValid(publicKey)) { return { - input: publicKey, + input: redactSecrets(publicKey), state: "invalid", isValidPublicKey: false, expertUrl: null, @@ -182,15 +183,16 @@ 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), reserve: null, 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 72c37e9..8876e67 100644 --- a/packages/stellar-kit/src/errors.ts +++ b/packages/stellar-kit/src/errors.ts @@ -1,11 +1,15 @@ import type { StellarErrorCode, StellarKitError } from "@anchorkit/types"; +import { redactSecrets } from "./redaction"; + +export { redactSecrets } from "./redaction"; export function createStellarError( code: StellarErrorCode, message: string, cause?: unknown ): StellarKitError { - const error = new Error(message) as StellarKitError; + const redactedMessage = redactSecrets(message); + const error = new Error(redactedMessage) as StellarKitError; error.code = code; error.name = "StellarKitError"; error.redacted = true; @@ -47,27 +51,6 @@ function sanitizeCause(cause: unknown): unknown { return cause; } -const SECRET_PATTERNS = [ - /S[A-Z2-7]{55}/g, - /SA[A-Z2-7]{54}/g, - /secret[\s_-]?key/i, - /private[\s_-]?key/i, - /seed[\s_-]?phrase/i, -]; - -export function redactSecrets(input: string): string { - let result = input; - for (const pattern of SECRET_PATTERNS) { - result = result.replace(pattern, (match) => { - if (match.startsWith("S") && match.length === 56) { - return match.slice(0, 4) + "[REDACTED]" + match.slice(-4); - } - return "[REDACTED]"; - }); - } - return result; -} - export function mapHorizonError( error: unknown ): { code: StellarErrorCode; message: string } { @@ -110,7 +93,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 f36b6f2..fc31b2c 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"; @@ -12,3 +13,4 @@ export * from "./diagnostics"; export * from "./soroban"; export * from "./vault"; 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/test/keys.test.ts b/packages/stellar-kit/test/keys.test.ts index 8c15c83..332846d 100644 --- a/packages/stellar-kit/test/keys.test.ts +++ b/packages/stellar-kit/test/keys.test.ts @@ -15,7 +15,8 @@ import { const WELL_KNOWN_FRIENDBOT = "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR"; const SECRET_KEY_SAMPLE = "SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4"; -const SECRET_KEY_SAMPLE_PUBLIC = "GA2C5RFPE6GCKMY3K7AIGZ5ZBBX26Z5B3E6G7V4MMSZ5L2R5YHMBFQJJ"; +const SECRET_KEY_SAMPLE_PUBLIC = "GC2BKLYOOYPDEFJKLKY6FNNRQMGFLVHJKQRGNSSRRGSMPGF32LHCQVGF"; + describe("Stellar public key validation", () => { it("accepts a valid 56-char G-prefixed base32 public key", () => { 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/validators/src/schemas/stellar.ts b/packages/validators/src/schemas/stellar.ts index 01f870a..6fc51fb 100644 --- a/packages/validators/src/schemas/stellar.ts +++ b/packages/validators/src/schemas/stellar.ts @@ -71,7 +71,7 @@ export const MemoTypeSchema = z.enum(["none", "text", "id", "hash", "return"]); export const MemoInputSchema = z .object({ type: MemoTypeSchema, - value: z.string().max(28, "Memo value exceeds 28 byte limit for text memo"), + value: z.string(), }) .superRefine((data, ctx) => { if (data.type === "text") { @@ -108,6 +108,7 @@ export const MemoInputSchema = z } }); + export const AssetCodeSchema = z .string() .min(1, "Asset code must not be empty")