From 3543a3df62f5155e5a58384bd355bee1937289b9 Mon Sep 17 00:00:00 2001 From: thatcodebabe Date: Sat, 27 Jun 2026 16:22:40 +0100 Subject: [PATCH] feat(profiles): Soroban analysis profile marketplace (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams repeatedly recreate similar Soroban scan configurations. This adds a marketplace for sharing and importing analysis profiles, built on the existing ConfigurationProfile model so profiles stay compatible with the config system. src/profiles/marketplace/stellar/: - types.ts: AnalysisProfile (shareable profile) + SharedProfileEnvelope (versioned, self-describing artifact with provenance + integrity checksum) - profile-exporter.ts: export a profile into an envelope with an order-stable SHA-256 checksum; serialize to JSON for sharing - profile-importer.ts: parse + validate a shared artifact before trusting it — kind discriminator, schema-major compatibility, checksum/tamper detection, and structural validation; returns structured errors/warnings (never throws) - marketplace.ts: StellarProfileMarketplace registry — publish/import, list, get, search (text/tags/category), export, dedup on id - index.ts: public barrel - __tests__/marketplace.spec.ts: 19 tests covering export/import round-trip, tamper + schema-incompat + malformed rejection, dedup, and search Verified: jest 19/19 passing; tsc reports no errors in the new module. --- .../stellar/__tests__/marketplace.spec.ts | 210 ++++++++++++++++++ src/profiles/marketplace/stellar/index.ts | 37 +++ .../marketplace/stellar/marketplace.ts | 125 +++++++++++ .../marketplace/stellar/profile-exporter.ts | 102 +++++++++ .../marketplace/stellar/profile-importer.ts | 159 +++++++++++++ src/profiles/marketplace/stellar/types.ts | 108 +++++++++ 6 files changed, 741 insertions(+) create mode 100644 src/profiles/marketplace/stellar/__tests__/marketplace.spec.ts create mode 100644 src/profiles/marketplace/stellar/index.ts create mode 100644 src/profiles/marketplace/stellar/marketplace.ts create mode 100644 src/profiles/marketplace/stellar/profile-exporter.ts create mode 100644 src/profiles/marketplace/stellar/profile-importer.ts create mode 100644 src/profiles/marketplace/stellar/types.ts diff --git a/src/profiles/marketplace/stellar/__tests__/marketplace.spec.ts b/src/profiles/marketplace/stellar/__tests__/marketplace.spec.ts new file mode 100644 index 0000000..8b2c2f0 --- /dev/null +++ b/src/profiles/marketplace/stellar/__tests__/marketplace.spec.ts @@ -0,0 +1,210 @@ +/** + * Soroban Analysis Profile Marketplace — Tests (Issue #484) + */ + +import { + AnalysisProfile, + PROFILE_ENVELOPE_KIND, + StellarProfileMarketplace, + computeChecksum, + exportProfile, + exportProfileToJson, + importProfile, + isSchemaCompatible, +} from '../index'; + +function makeProfile(overrides: Partial = {}): AnalysisProfile { + return { + id: 'soroban-strict', + name: 'Soroban Strict', + description: 'Strict security scan configuration for Soroban contracts', + target: 'stellar-soroban', + tags: ['security', 'strict'], + rules: [ + { id: 'SOR-001', enabled: true, severity: 'high', category: 'reentrancy' }, + { id: 'SOR-002', enabled: true, severity: 'critical', category: 'auth' }, + ], + ...overrides, + }; +} + +describe('schema compatibility', () => { + it('accepts the same major version', () => { + expect(isSchemaCompatible('1.0.0')).toBe(true); + expect(isSchemaCompatible('1.4.2')).toBe(true); + }); + + it('rejects a different major version or garbage', () => { + expect(isSchemaCompatible('2.0.0')).toBe(false); + expect(isSchemaCompatible('not-a-version')).toBe(false); + }); +}); + +describe('export', () => { + it('wraps a profile in a self-describing envelope with a checksum', () => { + const profile = makeProfile(); + const env = exportProfile(profile, { author: { name: 'team-sec' } }); + + expect(env.kind).toBe(PROFILE_ENVELOPE_KIND); + expect(env.schemaVersion).toBe('1.0.0'); + expect(env.profile).toEqual(profile); + expect(env.metadata.author).toEqual({ name: 'team-sec' }); + expect(env.metadata.checksum).toBe(computeChecksum(profile)); + expect(new Date(env.metadata.exportedAt).toString()).not.toBe('Invalid Date'); + }); + + it('produces an order-independent, stable checksum', () => { + const a = makeProfile({ tags: ['security', 'strict'] }); + // Same data, different key insertion order. + const b: AnalysisProfile = { + rules: a.rules, + tags: ['security', 'strict'], + target: 'stellar-soroban', + description: a.description, + name: a.name, + id: a.id, + }; + expect(computeChecksum(a)).toBe(computeChecksum(b)); + }); +}); + +describe('import (round-trip)', () => { + it('imports a freshly exported profile', () => { + const profile = makeProfile(); + const json = exportProfileToJson(profile); + + const result = importProfile(json); + expect(result.ok).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.profile).toEqual(profile); + }); + + it('accepts an already-parsed envelope object', () => { + const env = exportProfile(makeProfile()); + const result = importProfile(env); + expect(result.ok).toBe(true); + }); +}); + +describe('import (rejection)', () => { + it('rejects malformed JSON', () => { + const result = importProfile('{ not valid json'); + expect(result.ok).toBe(false); + expect(result.errors[0].code).toBe('INVALID_JSON'); + }); + + it('rejects an artifact with the wrong kind', () => { + const result = importProfile({ kind: 'something-else', schemaVersion: '1.0.0' }); + expect(result.ok).toBe(false); + expect(result.errors[0].code).toBe('KIND_INVALID'); + }); + + it('rejects an incompatible schema version', () => { + const env = exportProfile(makeProfile()); + const result = importProfile({ ...env, schemaVersion: '2.0.0' }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INCOMPATIBLE')).toBe(true); + }); + + it('rejects a tampered profile (checksum mismatch)', () => { + const env = exportProfile(makeProfile()); + // Mutate the profile after the checksum was computed. + const tampered = { + ...env, + profile: { ...env.profile, name: 'Backdoored Profile' }, + }; + const result = importProfile(tampered); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'CHECKSUM_MISMATCH')).toBe(true); + }); + + it('reports structural validation errors', () => { + const env = exportProfile(makeProfile()); + const broken = { ...env, profile: { ...env.profile, id: '', target: 'evm' } }; + const result = importProfile(broken); + expect(result.ok).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain('ID_REQUIRED'); + expect(codes).toContain('TARGET_INVALID'); + }); + + it('warns (but succeeds) when no checksum is present', () => { + const env = exportProfile(makeProfile()); + const noChecksum = { + ...env, + metadata: { ...env.metadata, checksum: '' }, + }; + const result = importProfile(noChecksum); + expect(result.ok).toBe(true); + expect(result.warnings.some((w) => w.code === 'CHECKSUM_MISSING')).toBe(true); + }); +}); + +describe('marketplace registry', () => { + let market: StellarProfileMarketplace; + + beforeEach(() => { + market = new StellarProfileMarketplace(); + }); + + it('publishes, lists, and gets profiles', () => { + market.publish(makeProfile()); + expect(market.size).toBe(1); + expect(market.has('soroban-strict')).toBe(true); + expect(market.get('soroban-strict')?.name).toBe('Soroban Strict'); + expect(market.list()).toHaveLength(1); + }); + + it('dedups on id (publishing the same id upserts)', () => { + market.publish(makeProfile()); + market.publish(makeProfile({ name: 'Soroban Strict v2' })); + expect(market.size).toBe(1); + expect(market.get('soroban-strict')?.name).toBe('Soroban Strict v2'); + }); + + it('imports a shared artifact and registers it', () => { + const json = exportProfileToJson(makeProfile()); + const result = market.importFrom(json); + expect(result.ok).toBe(true); + expect(market.size).toBe(1); + }); + + it('does not register an invalid artifact', () => { + const result = market.importFrom('{ bad'); + expect(result.ok).toBe(false); + expect(market.size).toBe(0); + }); + + it('round-trips through export() from the registry', () => { + market.publish(makeProfile()); + const env = market.export('soroban-strict', { author: { name: 'team-sec' } }); + const other = new StellarProfileMarketplace(); + expect(other.importFrom(env).ok).toBe(true); + expect(other.get('soroban-strict')).toEqual(market.get('soroban-strict')); + }); + + it('throws when exporting an unknown profile', () => { + expect(() => market.export('missing')).toThrow(); + }); + + it('searches by text, tag, and category', () => { + market.publish(makeProfile()); + market.publish( + makeProfile({ + id: 'soroban-gas', + name: 'Soroban Gas', + description: 'Gas-focused checks', + tags: ['performance'], + rules: [{ id: 'SOR-100', enabled: true, severity: 'low', category: 'gas' }], + }), + ); + + expect(market.search()).toHaveLength(2); + expect(market.search({ text: 'gas-focused' }).map((p) => p.id)).toEqual(['soroban-gas']); + expect(market.search({ tags: ['security'] }).map((p) => p.id)).toEqual(['soroban-strict']); + expect(market.search({ category: 'gas' }).map((p) => p.id)).toEqual(['soroban-gas']); + expect(market.search({ text: 'soroban', tags: ['performance'] }).map((p) => p.id)).toEqual([ + 'soroban-gas', + ]); + }); +}); diff --git a/src/profiles/marketplace/stellar/index.ts b/src/profiles/marketplace/stellar/index.ts new file mode 100644 index 0000000..a5b2574 --- /dev/null +++ b/src/profiles/marketplace/stellar/index.ts @@ -0,0 +1,37 @@ +/** + * Soroban Analysis Profile Marketplace (Issue #484) + * + * Public entry point for sharing and importing Soroban analysis profiles. + * + * @example + * ```ts + * import { + * StellarProfileMarketplace, + * exportProfileToJson, + * type AnalysisProfile, + * } from 'src/profiles/marketplace/stellar'; + * + * const market = new StellarProfileMarketplace(); + * market.publish(myProfile); + * + * // Share it + * const json = exportProfileToJson(myProfile, { author: { name: 'team-sec' } }); + * + * // Someone else imports it + * const other = new StellarProfileMarketplace(); + * const result = other.importFrom(json); + * if (result.ok) console.log('imported', result.profile?.name); + * ``` + */ + +export * from './types'; +export { + MARKETPLACE_VERSION, + canonicalize, + computeChecksum, + exportProfile, + exportProfileToJson, + serializeEnvelope, +} from './profile-exporter'; +export { importProfile, isSchemaCompatible, validateProfile } from './profile-importer'; +export { StellarProfileMarketplace } from './marketplace'; diff --git a/src/profiles/marketplace/stellar/marketplace.ts b/src/profiles/marketplace/stellar/marketplace.ts new file mode 100644 index 0000000..d0ec6b4 --- /dev/null +++ b/src/profiles/marketplace/stellar/marketplace.ts @@ -0,0 +1,125 @@ +/** + * Soroban Analysis Profile Marketplace — Registry (Issue #484) + * + * An in-memory marketplace that teams use to share and reuse Soroban analysis + * profiles: publish/import profiles, list and search them, and export any + * registered profile back to a portable artifact for sharing elsewhere. + * + * Storage is intentionally pluggable-friendly (a simple Map keyed by profile + * id); a persistent backend can be layered on without changing this API. + */ + +import { exportProfile } from './profile-exporter'; +import { importProfile } from './profile-importer'; +import { + AnalysisProfile, + ExportOptions, + MarketplaceSearchQuery, + ProfileImportResult, + SharedProfileEnvelope, +} from './types'; + +export class StellarProfileMarketplace { + private readonly profiles = new Map(); + + /** Number of profiles currently registered. */ + get size(): number { + return this.profiles.size; + } + + /** Whether a profile with the given id is registered. */ + has(id: string): boolean { + return this.profiles.has(id); + } + + /** Get a registered profile by id, or `undefined`. */ + get(id: string): AnalysisProfile | undefined { + return this.profiles.get(id); + } + + /** List all registered profiles (sorted by name for stable output). */ + list(): AnalysisProfile[] { + return [...this.profiles.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Publish a profile directly into the marketplace. + * + * Registration is idempotent on `id`: publishing a profile whose id already + * exists replaces the previous entry (an upsert), so re-sharing an updated + * profile does not create duplicates. + */ + publish(profile: AnalysisProfile): void { + this.profiles.set(profile.id, profile); + } + + /** Remove a profile by id. Returns `true` if one was removed. */ + remove(id: string): boolean { + return this.profiles.delete(id); + } + + /** + * Import a shared artifact (JSON string or envelope object) and, when valid, + * register it. The import result is returned so callers can surface any + * validation errors/warnings; nothing is registered on failure. + */ + importFrom(raw: string | object): ProfileImportResult { + const result = importProfile(raw); + if (result.ok && result.profile) { + this.publish(result.profile); + } + return result; + } + + /** + * Export a registered profile to a shareable envelope. + * + * @throws if no profile with `id` is registered. + */ + export(id: string, options: ExportOptions = {}): SharedProfileEnvelope { + const profile = this.profiles.get(id); + if (!profile) { + throw new Error(`Profile '${id}' is not registered in the marketplace`); + } + return exportProfile(profile, options); + } + + /** + * Search registered profiles. With no criteria, returns all profiles. + * Multiple criteria are combined with AND; within `tags`, match is any-of. + */ + search(query: MarketplaceSearchQuery = {}): AnalysisProfile[] { + const text = query.text?.toLowerCase().trim(); + const tags = query.tags?.filter((t) => t.length > 0); + const category = query.category?.toLowerCase().trim(); + + return this.list().filter((profile) => { + if (text) { + const haystack = `${profile.name} ${profile.description}`.toLowerCase(); + if (!haystack.includes(text)) { + return false; + } + } + if (tags && tags.length > 0) { + const profileTags = profile.tags ?? []; + if (!tags.some((t) => profileTags.includes(t))) { + return false; + } + } + if (category) { + const hasCategory = (profile.rules ?? []).some( + (rule) => typeof rule.category === 'string' && rule.category.toLowerCase() === category, + ); + if (!hasCategory) { + return false; + } + } + return true; + }); + } + + /** Remove all registered profiles. */ + clear(): void { + this.profiles.clear(); + } +} diff --git a/src/profiles/marketplace/stellar/profile-exporter.ts b/src/profiles/marketplace/stellar/profile-exporter.ts new file mode 100644 index 0000000..36a8696 --- /dev/null +++ b/src/profiles/marketplace/stellar/profile-exporter.ts @@ -0,0 +1,102 @@ +/** + * Soroban Analysis Profile Marketplace — Exporter (Issue #484) + * + * Wraps an `AnalysisProfile` in a portable, self-describing envelope with + * provenance and an integrity checksum, and serialises it to JSON for sharing. + */ + +import { createHash } from 'crypto'; +import { + AnalysisProfile, + ExportOptions, + PROFILE_ENVELOPE_KIND, + PROFILE_SCHEMA_VERSION, + ProfileEnvelopeMetadata, + SharedProfileEnvelope, +} from './types'; + +/** Module version recorded in exported artifacts. */ +export const MARKETPLACE_VERSION = '1.0.0'; + +/** + * Deterministically stringify a value with object keys sorted recursively. + * + * The checksum must be stable regardless of property insertion order, so two + * semantically identical profiles always hash to the same value. + */ +export function canonicalize(value: unknown): string { + const seen = new WeakSet(); + + const normalize = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') { + return v; + } + if (seen.has(v as object)) { + throw new Error('Cannot canonicalize a value with circular references'); + } + seen.add(v as object); + + if (Array.isArray(v)) { + return v.map(normalize); + } + const out: Record = {}; + for (const key of Object.keys(v as Record).sort()) { + const inner = (v as Record)[key]; + if (inner !== undefined) { + out[key] = normalize(inner); + } + } + return out; + }; + + return JSON.stringify(normalize(value)); +} + +/** Compute the SHA-256 checksum of a profile's canonical representation. */ +export function computeChecksum(profile: AnalysisProfile): string { + return createHash('sha256').update(canonicalize(profile)).digest('hex'); +} + +/** + * Export a profile into a shareable envelope. + * + * The returned envelope embeds a schema version, provenance metadata, and a + * checksum over the profile so importers can detect tampering or corruption. + */ +export function exportProfile( + profile: AnalysisProfile, + options: ExportOptions = {}, +): SharedProfileEnvelope { + const metadata: ProfileEnvelopeMetadata = { + exportedAt: new Date().toISOString(), + gasguardVersion: options.gasguardVersion ?? MARKETPLACE_VERSION, + checksum: computeChecksum(profile), + ...(options.author ? { author: options.author } : {}), + }; + + return { + kind: PROFILE_ENVELOPE_KIND, + schemaVersion: PROFILE_SCHEMA_VERSION, + metadata, + profile, + }; +} + +/** Serialise an envelope to a JSON string suitable for sharing/storage. */ +export function serializeEnvelope( + envelope: SharedProfileEnvelope, + pretty = true, +): string { + return pretty + ? JSON.stringify(envelope, null, 2) + : JSON.stringify(envelope); +} + +/** Convenience: export a profile straight to a JSON string. */ +export function exportProfileToJson( + profile: AnalysisProfile, + options: ExportOptions = {}, + pretty = true, +): string { + return serializeEnvelope(exportProfile(profile, options), pretty); +} diff --git a/src/profiles/marketplace/stellar/profile-importer.ts b/src/profiles/marketplace/stellar/profile-importer.ts new file mode 100644 index 0000000..ab5df06 --- /dev/null +++ b/src/profiles/marketplace/stellar/profile-importer.ts @@ -0,0 +1,159 @@ +/** + * Soroban Analysis Profile Marketplace — Importer (Issue #484) + * + * Parses and validates a shared profile artifact before it is trusted: + * - parses JSON / accepts an already-parsed object, + * - checks the envelope discriminator and schema compatibility, + * - verifies the integrity checksum (tamper detection), + * - validates the profile's structure. + * + * Returns a structured result rather than throwing, so callers can surface all + * problems at once. + */ + +import { computeChecksum } from './profile-exporter'; +import { + AnalysisProfile, + PROFILE_ENVELOPE_KIND, + PROFILE_SCHEMA_VERSION, + ProfileImportResult, + ProfileValidationIssue, + SharedProfileEnvelope, +} from './types'; + +/** Major version of the supported schema; only this gates compatibility. */ +function majorOf(version: string): number { + const major = Number.parseInt(version.split('.')[0] ?? '', 10); + return Number.isNaN(major) ? -1 : major; +} + +/** + * A shared artifact is importable if its schema shares the current major + * version. Same-major minor/patch differences are forwards/backwards tolerant; + * a different major may have an incompatible shape and is rejected. + */ +export function isSchemaCompatible(schemaVersion: string): boolean { + const supported = majorOf(PROFILE_SCHEMA_VERSION); + return supported >= 0 && majorOf(schemaVersion) === supported; +} + +/** Validate the shape of a profile payload, collecting all issues. */ +export function validateProfile(profile: unknown): ProfileValidationIssue[] { + const issues: ProfileValidationIssue[] = []; + const push = (path: string, message: string, code: string) => + issues.push({ path, message, code }); + + if (profile === null || typeof profile !== 'object') { + push('profile', 'Profile must be an object', 'PROFILE_NOT_OBJECT'); + return issues; + } + const p = profile as Record; + + if (typeof p.id !== 'string' || p.id.trim() === '') { + push('profile.id', 'Profile id is required and must be a non-empty string', 'ID_REQUIRED'); + } + if (typeof p.name !== 'string' || p.name.trim() === '') { + push('profile.name', 'Profile name is required and must be a non-empty string', 'NAME_REQUIRED'); + } + if (typeof p.description !== 'string') { + push('profile.description', 'Profile description is required', 'DESCRIPTION_REQUIRED'); + } + if (p.target !== 'stellar-soroban') { + push('profile.target', "Profile target must be 'stellar-soroban'", 'TARGET_INVALID'); + } + if (!Array.isArray(p.rules)) { + push('profile.rules', 'Profile rules must be an array', 'RULES_INVALID'); + } + if (p.tags !== undefined && !Array.isArray(p.tags)) { + push('profile.tags', 'Profile tags, if present, must be an array of strings', 'TAGS_INVALID'); + } + + return issues; +} + +/** Parse raw input (JSON string or object) into an unknown value. */ +function parseRaw(raw: string | object): { value?: unknown; error?: ProfileValidationIssue } { + if (typeof raw !== 'string') { + return { value: raw }; + } + try { + return { value: JSON.parse(raw) }; + } catch { + return { + error: { path: 'envelope', message: 'Input is not valid JSON', code: 'INVALID_JSON' }, + }; + } +} + +/** + * Import a shared profile artifact. + * + * @param raw A JSON string or an already-parsed envelope object. + */ +export function importProfile(raw: string | object): ProfileImportResult { + const errors: ProfileValidationIssue[] = []; + const warnings: ProfileValidationIssue[] = []; + + const parsed = parseRaw(raw); + if (parsed.error) { + return { ok: false, errors: [parsed.error], warnings }; + } + + const env = parsed.value; + if (env === null || typeof env !== 'object') { + return { + ok: false, + errors: [{ path: 'envelope', message: 'Envelope must be an object', code: 'ENVELOPE_NOT_OBJECT' }], + warnings, + }; + } + const envelope = env as Partial; + + if (envelope.kind !== PROFILE_ENVELOPE_KIND) { + errors.push({ + path: 'envelope.kind', + message: `Unrecognised artifact kind; expected '${PROFILE_ENVELOPE_KIND}'`, + code: 'KIND_INVALID', + }); + // Without the discriminator we cannot trust anything else. + return { ok: false, errors, warnings }; + } + + if (typeof envelope.schemaVersion !== 'string' || !isSchemaCompatible(envelope.schemaVersion)) { + errors.push({ + path: 'envelope.schemaVersion', + message: `Incompatible schema version '${String( + envelope.schemaVersion, + )}'; this build supports ${PROFILE_SCHEMA_VERSION}`, + code: 'SCHEMA_INCOMPATIBLE', + }); + return { ok: false, errors, warnings }; + } + + const structureIssues = validateProfile(envelope.profile); + errors.push(...structureIssues); + if (structureIssues.length > 0) { + return { ok: false, errors, warnings }; + } + + const profile = envelope.profile as AnalysisProfile; + + // Integrity check: recompute the checksum over the profile payload. + const expected = envelope.metadata?.checksum; + if (typeof expected !== 'string' || expected.length === 0) { + warnings.push({ + path: 'envelope.metadata.checksum', + message: 'No checksum present; integrity could not be verified', + code: 'CHECKSUM_MISSING', + }); + } else if (computeChecksum(profile) !== expected) { + errors.push({ + path: 'envelope.metadata.checksum', + message: 'Checksum mismatch; the profile may have been modified or corrupted', + code: 'CHECKSUM_MISMATCH', + }); + return { ok: false, errors, warnings }; + } + + return { ok: true, profile, errors, warnings }; +} diff --git a/src/profiles/marketplace/stellar/types.ts b/src/profiles/marketplace/stellar/types.ts new file mode 100644 index 0000000..9253419 --- /dev/null +++ b/src/profiles/marketplace/stellar/types.ts @@ -0,0 +1,108 @@ +/** + * Soroban Analysis Profile Marketplace — Types (Issue #484) + * + * A Soroban *analysis profile* is a named, reusable scan configuration (a set + * of rule selections/overrides plus optional system overrides). Teams keep + * recreating similar configurations; the marketplace lets them **export** a + * profile to a portable, self-describing artifact and **import** profiles + * shared by others, with integrity and schema-compatibility checks. + * + * This builds on the existing `ConfigurationProfile` model in + * `src/config/config.types.ts` so profiles stay compatible with the config + * system rather than introducing a parallel shape. + */ + +import { ConfigurationProfile } from '../../../config/config.types'; + +/** + * Schema version of the shareable envelope format. + * + * Follows semver; only the **major** component gates import compatibility + * (see `isSchemaCompatible`). Bump the major when the envelope/profile shape + * changes in a backwards-incompatible way. + */ +export const PROFILE_SCHEMA_VERSION = '1.0.0'; + +/** Discriminator embedded in every exported artifact for safe parsing. */ +export const PROFILE_ENVELOPE_KIND = 'gasguard.analysis-profile' as const; + +/** Analysis target a profile applies to. Scoped to Stellar/Soroban here. */ +export type AnalysisTarget = 'stellar-soroban'; + +/** Optional authorship metadata attached at export time. */ +export interface ProfileAuthor { + name: string; + contact?: string; + organization?: string; +} + +/** + * A shareable Soroban analysis profile. + * + * Extends `ConfigurationProfile` (name, description, rules, systemOverrides) + * with a stable `id` for dedup/lookup, the analysis `target`, and free-form + * `tags` for marketplace discovery. + */ +export interface AnalysisProfile extends ConfigurationProfile { + /** Stable identifier (slug) used for dedup and lookup. */ + id: string; + /** Analysis target this profile applies to. */ + target: AnalysisTarget; + /** Free-form tags for discovery/search in the marketplace. */ + tags?: string[]; +} + +/** Provenance + integrity metadata carried alongside an exported profile. */ +export interface ProfileEnvelopeMetadata { + /** ISO-8601 timestamp the artifact was exported. */ + exportedAt: string; + /** GasGuard version that produced the artifact. */ + gasguardVersion: string; + /** SHA-256 of the canonicalised profile payload, for tamper detection. */ + checksum: string; + /** Who produced/shared the profile (optional). */ + author?: ProfileAuthor; +} + +/** + * Portable, self-describing artifact produced by export and consumed by + * import. This is the unit shared between teams (typically as JSON). + */ +export interface SharedProfileEnvelope { + kind: typeof PROFILE_ENVELOPE_KIND; + schemaVersion: string; + metadata: ProfileEnvelopeMetadata; + profile: AnalysisProfile; +} + +/** A single validation problem found while importing a profile. */ +export interface ProfileValidationIssue { + path: string; + message: string; + code: string; +} + +/** Outcome of an import attempt. `profile` is present only when `ok` is true. */ +export interface ProfileImportResult { + ok: boolean; + profile?: AnalysisProfile; + errors: ProfileValidationIssue[]; + warnings: ProfileValidationIssue[]; +} + +/** Options for exporting a profile. */ +export interface ExportOptions { + author?: ProfileAuthor; + /** Override the recorded GasGuard version (defaults to the module version). */ + gasguardVersion?: string; +} + +/** Query for locating profiles in the marketplace. All fields are optional. */ +export interface MarketplaceSearchQuery { + /** Case-insensitive substring match against name and description. */ + text?: string; + /** Match profiles carrying any of these tags. */ + tags?: string[]; + /** Match profiles that include at least one rule in this category. */ + category?: string; +}