@vixor/contracts 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,37 +1,37 @@
1
- # @vixor/contracts
2
-
3
- Zero-runtime-dependency TypeScript contracts for VIXOR integration boundaries.
4
-
5
- ## Install
6
-
7
- ```sh
8
- npm install @vixor/contracts
9
- ```
10
-
11
- ## Runtime
12
-
13
- Node.js 20 or newer is supported. The published package contains compiled
14
- ES modules and TypeScript declaration files under `dist/`; consumers do not
15
- need a TypeScript runtime loader.
16
-
17
- ## Example
18
-
19
- ```ts
20
- import { validateEnvelope, validateOpportunityPayload, fixtureEnvelope } from "@vixor/contracts";
21
-
22
- const result = validateEnvelope(fixtureEnvelope(), validateOpportunityPayload);
23
- if (!result.ok) throw new Error(result.errors.join("; "));
24
- ```
25
-
26
- Package SemVer (currently 0.2.0) is independent of the wire `SCHEMA_VERSION`
27
- (currently `0.1`). Consumers must validate the wire schema version independently.
28
-
29
- This package validates VIXOR event envelopes, opportunity payloads, Shield risk
30
- assessments, and publication candidates. It does not authenticate requests,
31
- verify HMAC signatures, authorize channels, or replace persistence/idempotency.
32
-
33
- ## License
34
-
35
- The package is published with `UNLICENSED` metadata. Public npm visibility makes
36
- the artifact downloadable; it does not grant third-party rights to reuse or
37
- redistribute the code. Obtain separate written permission before external reuse.
1
+ # @vixor/contracts
2
+
3
+ Zero-runtime-dependency TypeScript contracts for VIXOR integration boundaries.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @vixor/contracts
9
+ ```
10
+
11
+ ## Runtime
12
+
13
+ Node.js 20 or newer is supported. The published package contains compiled
14
+ ES modules and TypeScript declaration files under `dist/`; consumers do not
15
+ need a TypeScript runtime loader.
16
+
17
+ ## Example
18
+
19
+ ```ts
20
+ import { validateEnvelope, validateOpportunityPayload, fixtureEnvelope } from "@vixor/contracts";
21
+
22
+ const result = validateEnvelope(fixtureEnvelope(), validateOpportunityPayload);
23
+ if (!result.ok) throw new Error(result.errors.join("; "));
24
+ ```
25
+
26
+ Package SemVer (currently 0.2.1) is independent of the wire `SCHEMA_VERSION`
27
+ (currently `0.1`). Consumers must validate the wire schema version independently.
28
+
29
+ This package validates VIXOR event envelopes, opportunity payloads, Shield risk
30
+ assessments, and publication candidates. It does not authenticate requests,
31
+ verify HMAC signatures, authorize channels, or replace persistence/idempotency.
32
+
33
+ ## License
34
+
35
+ The package is published with `UNLICENSED` metadata. Public npm visibility makes
36
+ the artifact downloadable; it does not grant third-party rights to reuse or
37
+ redistribute the code. Obtain separate written permission before external reuse.
@@ -0,0 +1,27 @@
1
+ import { type ValidationResult } from "./validate.ts";
2
+ /** Contract version — consumers must reject unsupported versions. */
3
+ export declare const SCHEMA_VERSION = "0.1";
4
+ export declare const PRODUCED_BY: readonly ["hunt", "shield", "core", "trade", "tg-content-bot"];
5
+ export type ProducedBy = (typeof PRODUCED_BY)[number];
6
+ /** The only event type on the minimal proof path (§2). */
7
+ export declare const EVENT_OPPORTUNITY_DETECTED = "opportunity.detected";
8
+ export interface EventEnvelope<P = unknown> {
9
+ schema_version: string;
10
+ event_id: string;
11
+ event_type: string;
12
+ occurred_at: string;
13
+ produced_by: ProducedBy;
14
+ correlation_id: string;
15
+ idempotency_key: string;
16
+ payload: P;
17
+ }
18
+ export interface EnvelopeOptions {
19
+ /** Restrict `event_type`; defaults to the minimal-path event only. */
20
+ allowedEventTypes?: readonly string[];
21
+ }
22
+ /**
23
+ * Validate a §1 envelope. `validatePayload` validates the event-specific
24
+ * payload against its schema (e.g. §2 opportunity). Envelope identity fields
25
+ * are length-capped for log hygiene.
26
+ */
27
+ export declare function validateEnvelope<P>(raw: unknown, validatePayload: (payload: unknown) => ValidationResult<P>, opts?: EnvelopeOptions): ValidationResult<EventEnvelope<P>>;
@@ -0,0 +1,68 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — §1 Common envelope (VIXOR_INTEGRATION_CONTRACTS_DRAFT §1)
3
+ // ============================================================================
4
+ import { cappedId, cappedTimestamp, field, invalid, isOneOf, isRecord, } from "./validate.js";
5
+ /** Contract version — consumers must reject unsupported versions. */
6
+ export const SCHEMA_VERSION = "0.1";
7
+ export const PRODUCED_BY = [
8
+ "hunt",
9
+ "shield",
10
+ "core",
11
+ "trade",
12
+ "tg-content-bot",
13
+ ];
14
+ /** The only event type on the minimal proof path (§2). */
15
+ export const EVENT_OPPORTUNITY_DETECTED = "opportunity.detected";
16
+ /**
17
+ * Validate a §1 envelope. `validatePayload` validates the event-specific
18
+ * payload against its schema (e.g. §2 opportunity). Envelope identity fields
19
+ * are length-capped for log hygiene.
20
+ */
21
+ export function validateEnvelope(raw, validatePayload, opts = {}) {
22
+ if (!isRecord(raw))
23
+ return invalid("envelope: must be a JSON object");
24
+ const sv = field(raw, "schema_version", (v) => v === SCHEMA_VERSION
25
+ ? { ok: true, value: SCHEMA_VERSION }
26
+ : { ok: false, errors: [`schema_version: unsupported version (expected "${SCHEMA_VERSION}")`] });
27
+ if (!sv.ok)
28
+ return sv;
29
+ const allowed = opts.allowedEventTypes ?? [EVENT_OPPORTUNITY_DETECTED];
30
+ const eventId = field(raw, "event_id", (v) => cappedId(v, "event_id"));
31
+ if (!eventId.ok)
32
+ return eventId;
33
+ const eventType = field(raw, "event_type", (v) => isOneOf(v, allowed)
34
+ ? { ok: true, value: v }
35
+ : { ok: false, errors: [`event_type: unknown event type "${String(v).slice(0, 50)}"`] });
36
+ if (!eventType.ok)
37
+ return eventType;
38
+ const occurredAt = field(raw, "occurred_at", (v) => cappedTimestamp(v, "occurred_at"));
39
+ if (!occurredAt.ok)
40
+ return occurredAt;
41
+ const producedBy = field(raw, "produced_by", (v) => isOneOf(v, PRODUCED_BY)
42
+ ? { ok: true, value: v }
43
+ : { ok: false, errors: ["produced_by: must be one of hunt|shield|core|trade|tg-content-bot"] });
44
+ if (!producedBy.ok)
45
+ return producedBy;
46
+ const correlationId = field(raw, "correlation_id", (v) => cappedId(v, "correlation_id"));
47
+ if (!correlationId.ok)
48
+ return correlationId;
49
+ const idempotencyKey = field(raw, "idempotency_key", (v) => cappedId(v, "idempotency_key"));
50
+ if (!idempotencyKey.ok)
51
+ return idempotencyKey;
52
+ const payload = validatePayload(raw.payload);
53
+ if (!payload.ok)
54
+ return payload;
55
+ return {
56
+ ok: true,
57
+ value: {
58
+ schema_version: sv.value,
59
+ event_id: eventId.value,
60
+ event_type: eventType.value,
61
+ occurred_at: occurredAt.value,
62
+ produced_by: producedBy.value,
63
+ correlation_id: correlationId.value,
64
+ idempotency_key: idempotencyKey.value,
65
+ payload: payload.value,
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,25 @@
1
+ import type { EventEnvelope } from "./envelope.ts";
2
+ import type { OpportunityPayload } from "./opportunity.ts";
3
+ import type { RiskAssessment } from "./risk.ts";
4
+ import type { PublicationCandidate } from "./publication.ts";
5
+ /**
6
+ * All fixture values are constant (no Date.now, no randomness) so that two
7
+ * runs always produce deep-equal output and identical idempotency keys —
8
+ * the property T2 (duplicate → 409) depends on.
9
+ */
10
+ export declare const FIXTURE_EVENT_ID = "evt-e2e-test-0001";
11
+ export declare const FIXTURE_CORRELATION_ID = "corr-e2e-test-0001";
12
+ export declare const FIXTURE_OPPORTUNITY_ID = "vixor-e2e-test-001";
13
+ export declare const FIXTURE_ASSESSMENT_ID = "asmt-e2e-test-0001";
14
+ export declare const FIXTURE_PUBLICATION_ID = "pub-e2e-test-0001";
15
+ /** Fixed instants (UTC) — far future expiry keeps fixtures time-stable. */
16
+ export declare const FIXTURE_OCCURRED_AT = "2026-01-01T00:00:00.000Z";
17
+ export declare const FIXTURE_OBSERVED_AT = "2026-01-01T00:00:00.000Z";
18
+ export declare const FIXTURE_CHECKED_AT = "2026-01-01T00:01:00.000Z";
19
+ export declare const FIXTURE_EXPIRES_AT = "2099-01-01T00:00:00.000Z";
20
+ /** Canonical asset identity: chain:address (Solana test fixture). */
21
+ export declare const FIXTURE_ASSET_ID = "solana:So11111111111111111111111111111111111111112";
22
+ export declare function fixtureOpportunityPayload(): OpportunityPayload;
23
+ export declare function fixtureRiskAssessment(): RiskAssessment;
24
+ export declare function fixtureEnvelope(): EventEnvelope<OpportunityPayload>;
25
+ export declare function fixturePublicationCandidate(): PublicationCandidate;
@@ -0,0 +1,81 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — deterministic fixtures (M1 acceptance: one fixture
3
+ // traverses every schema: envelope → opportunity → risk → publication)
4
+ // ============================================================================
5
+ import { EVENT_OPPORTUNITY_DETECTED } from "./envelope.js";
6
+ /**
7
+ * All fixture values are constant (no Date.now, no randomness) so that two
8
+ * runs always produce deep-equal output and identical idempotency keys —
9
+ * the property T2 (duplicate → 409) depends on.
10
+ */
11
+ export const FIXTURE_EVENT_ID = "evt-e2e-test-0001";
12
+ export const FIXTURE_CORRELATION_ID = "corr-e2e-test-0001";
13
+ export const FIXTURE_OPPORTUNITY_ID = "vixor-e2e-test-001";
14
+ export const FIXTURE_ASSESSMENT_ID = "asmt-e2e-test-0001";
15
+ export const FIXTURE_PUBLICATION_ID = "pub-e2e-test-0001";
16
+ /** Fixed instants (UTC) — far future expiry keeps fixtures time-stable. */
17
+ export const FIXTURE_OCCURRED_AT = "2026-01-01T00:00:00.000Z";
18
+ export const FIXTURE_OBSERVED_AT = "2026-01-01T00:00:00.000Z";
19
+ export const FIXTURE_CHECKED_AT = "2026-01-01T00:01:00.000Z";
20
+ export const FIXTURE_EXPIRES_AT = "2099-01-01T00:00:00.000Z";
21
+ /** Canonical asset identity: chain:address (Solana test fixture). */
22
+ export const FIXTURE_ASSET_ID = "solana:So11111111111111111111111111111111111111112";
23
+ export function fixtureOpportunityPayload() {
24
+ return {
25
+ opportunity_id: FIXTURE_OPPORTUNITY_ID,
26
+ asset_id: FIXTURE_ASSET_ID,
27
+ symbol: "TEST",
28
+ source: { name: "dexscreener", record_ref: "pair:e2e-test-pair-001" },
29
+ observed_at: FIXTURE_OBSERVED_AT,
30
+ market_data: {
31
+ liq_usd: { value: 25000, currency: "USD", at: FIXTURE_OBSERVED_AT },
32
+ accel_score: { value: 78, at: FIXTURE_OBSERVED_AT },
33
+ price_chg_m5: { value: 4.2, unit: "pct", at: FIXTURE_OBSERVED_AT },
34
+ },
35
+ evidence_refs: ["geckoterminal:e2e-test-pool", "hunt:scanner:1"],
36
+ quality_flags: [],
37
+ risk_assessment: fixtureRiskAssessment(),
38
+ };
39
+ }
40
+ export function fixtureRiskAssessment() {
41
+ return {
42
+ assessment_id: FIXTURE_ASSESSMENT_ID,
43
+ opportunity_id: FIXTURE_OPPORTUNITY_ID,
44
+ decision: "allow",
45
+ reason_codes: ["S03", "S07"],
46
+ checked_at: FIXTURE_CHECKED_AT,
47
+ policy_version: "shield-assess-v1",
48
+ data_quality: "valid",
49
+ expires_at: FIXTURE_EXPIRES_AT,
50
+ };
51
+ }
52
+ export function fixtureEnvelope() {
53
+ return {
54
+ schema_version: "0.1",
55
+ event_id: FIXTURE_EVENT_ID,
56
+ event_type: EVENT_OPPORTUNITY_DETECTED,
57
+ occurred_at: FIXTURE_OCCURRED_AT,
58
+ produced_by: "hunt",
59
+ correlation_id: FIXTURE_CORRELATION_ID,
60
+ idempotency_key: `publish:test:${FIXTURE_OPPORTUNITY_ID}`,
61
+ payload: fixtureOpportunityPayload(),
62
+ };
63
+ }
64
+ export function fixturePublicationCandidate() {
65
+ return {
66
+ publication_id: FIXTURE_PUBLICATION_ID,
67
+ opportunity_id: FIXTURE_OPPORTUNITY_ID,
68
+ assessment_id: FIXTURE_ASSESSMENT_ID,
69
+ channel_id: "test-channel-001",
70
+ template_id: "signal_card",
71
+ template_version: "1",
72
+ content: {
73
+ text: "[TEST] TEST (solana) — accel 78, liq 25000 USD. " +
74
+ "assessment asmt-e2e-test-0001 allow/valid until 2099.",
75
+ },
76
+ buttons: [{ type: "url", label: "Chart", url: "https://example.com/chart" }],
77
+ approval_state: "pending_human",
78
+ expires_at: FIXTURE_EXPIRES_AT,
79
+ idempotency_key: `publish:test:${FIXTURE_OPPORTUNITY_ID}`,
80
+ };
81
+ }
@@ -0,0 +1,11 @@
1
+ export { valid, invalid, flatMap, isRecord, isString, isNonEmptyString, isStringArray, isTimestamp, isFiniteNumber, isOneOf, cappedId, cappedText, cappedStringList, cappedTimestamp, field, optionalField, MAX_ID_LEN, MAX_TEXT_LEN, MAX_LIST_LEN, } from "./validate.ts";
2
+ export type { Valid, Invalid, ValidationResult } from "./validate.ts";
3
+ export { SCHEMA_VERSION, PRODUCED_BY, EVENT_OPPORTUNITY_DETECTED, validateEnvelope, } from "./envelope.ts";
4
+ export type { EventEnvelope, ProducedBy, EnvelopeOptions } from "./envelope.ts";
5
+ export { validateOpportunityPayload } from "./opportunity.ts";
6
+ export type { OpportunityPayload, OpportunitySource, MarketValue, } from "./opportunity.ts";
7
+ export { RISK_DECISIONS, DATA_QUALITIES, validateRiskAssessment, isAssessmentUsableForPublication, } from "./risk.ts";
8
+ export type { RiskAssessment, RiskDecision, DataQuality, } from "./risk.ts";
9
+ export { validatePublicationCandidate } from "./publication.ts";
10
+ export type { PublicationCandidate, PublicationButton, PublicationContent, } from "./publication.ts";
11
+ export { FIXTURE_EVENT_ID, FIXTURE_CORRELATION_ID, FIXTURE_OPPORTUNITY_ID, FIXTURE_ASSESSMENT_ID, FIXTURE_PUBLICATION_ID, FIXTURE_ASSET_ID, FIXTURE_OCCURRED_AT, FIXTURE_OBSERVED_AT, FIXTURE_CHECKED_AT, FIXTURE_EXPIRES_AT, fixtureEnvelope, fixtureOpportunityPayload, fixtureRiskAssessment, fixturePublicationCandidate, } from "./fixtures.ts";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts Package — Public Exports
3
+ // ============================================================================
4
+ // Zero-dependency runtime contracts for the VIXOR integration proof path
5
+ // (mirrors integration/VIXOR_INTEGRATION_CONTRACTS_DRAFT.md §1–§4).
6
+ // --- validation primitives -------------------------------------------------
7
+ export { valid, invalid, flatMap, isRecord, isString, isNonEmptyString, isStringArray, isTimestamp, isFiniteNumber, isOneOf, cappedId, cappedText, cappedStringList, cappedTimestamp, field, optionalField, MAX_ID_LEN, MAX_TEXT_LEN, MAX_LIST_LEN, } from "./validate.js";
8
+ // --- §1 common envelope -----------------------------------------------------
9
+ export { SCHEMA_VERSION, PRODUCED_BY, EVENT_OPPORTUNITY_DETECTED, validateEnvelope, } from "./envelope.js";
10
+ // --- §2 opportunity detected ------------------------------------------------
11
+ export { validateOpportunityPayload } from "./opportunity.js";
12
+ // --- §3 risk assessment ------------------------------------------------------
13
+ export { RISK_DECISIONS, DATA_QUALITIES, validateRiskAssessment, isAssessmentUsableForPublication, } from "./risk.js";
14
+ // --- §4 publication candidate ------------------------------------------------
15
+ export { validatePublicationCandidate } from "./publication.js";
16
+ // --- deterministic fixtures ---------------------------------------------------
17
+ export { FIXTURE_EVENT_ID, FIXTURE_CORRELATION_ID, FIXTURE_OPPORTUNITY_ID, FIXTURE_ASSESSMENT_ID, FIXTURE_PUBLICATION_ID, FIXTURE_ASSET_ID, FIXTURE_OCCURRED_AT, FIXTURE_OBSERVED_AT, FIXTURE_CHECKED_AT, FIXTURE_EXPIRES_AT, fixtureEnvelope, fixtureOpportunityPayload, fixtureRiskAssessment, fixturePublicationCandidate, } from "./fixtures.js";
@@ -0,0 +1,35 @@
1
+ import { type ValidationResult } from "./validate.ts";
2
+ import { type RiskAssessment } from "./risk.ts";
3
+ /** One measured value with its units/currency and observation timestamp (§2 market_data). */
4
+ export interface MarketValue {
5
+ value: number;
6
+ unit?: string;
7
+ currency?: string;
8
+ at?: string;
9
+ }
10
+ /** §2: "source: source/provider name and source record reference". */
11
+ export interface OpportunitySource {
12
+ name: string;
13
+ record_ref: string;
14
+ }
15
+ /**
16
+ * §2 minimum payload. Field names mirror the draft verbatim.
17
+ *
18
+ * `risk_assessment` is a VIXOR extension used on the minimal proof path: the
19
+ * producer (Hunt emitter) fetches Shield's §3 assessment and embeds it so the
20
+ * publication ledger can fail closed without an outbound call. Consumers that
21
+ * require stronger guarantees should verify the assessment against Shield
22
+ * directly (documented limitation of the proof phase).
23
+ */
24
+ export interface OpportunityPayload {
25
+ opportunity_id: string;
26
+ asset_id: string;
27
+ symbol: string;
28
+ source: OpportunitySource;
29
+ observed_at: string;
30
+ market_data: Record<string, MarketValue>;
31
+ evidence_refs: string[];
32
+ quality_flags: string[];
33
+ risk_assessment?: RiskAssessment;
34
+ }
35
+ export declare function validateOpportunityPayload(raw: unknown): ValidationResult<OpportunityPayload>;
@@ -0,0 +1,124 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — §2 Hunt → Core: opportunity detected
3
+ // ============================================================================
4
+ import { cappedId, cappedStringList, cappedTimestamp, field, invalid, isFiniteNumber, isRecord, optionalField, } from "./validate.js";
5
+ import { validateRiskAssessment, } from "./risk.js";
6
+ function validateMarketValue(v, name) {
7
+ if (!isRecord(v))
8
+ return invalid(`market_data.${name}: must be an object`);
9
+ const value = field(v, "value", (x) => isFiniteNumber(x)
10
+ ? { ok: true, value: x }
11
+ : { ok: false, errors: [`market_data.${name}.value: must be a finite number`] });
12
+ if (!value.ok)
13
+ return value;
14
+ const unit = optionalField(v, "unit", (x) => typeof x === "string" && x.length <= 50
15
+ ? { ok: true, value: x }
16
+ : { ok: false, errors: [`market_data.${name}.unit: must be a string ≤ 50 chars`] });
17
+ if (!unit.ok)
18
+ return unit;
19
+ const currency = optionalField(v, "currency", (x) => typeof x === "string" && x.length <= 10
20
+ ? { ok: true, value: x }
21
+ : { ok: false, errors: [`market_data.${name}.currency: must be a string ≤ 10 chars`] });
22
+ if (!currency.ok)
23
+ return currency;
24
+ const at = optionalField(v, "at", (x) => cappedTimestamp(x, `market_data.${name}.at`));
25
+ if (!at.ok)
26
+ return at;
27
+ return {
28
+ ok: true,
29
+ value: { value: value.value, unit: unit.value, currency: currency.value, at: at.value },
30
+ };
31
+ }
32
+ function validateMarketData(v) {
33
+ if (!isRecord(v))
34
+ return invalid("market_data: must be an object");
35
+ const keys = Object.keys(v);
36
+ if (keys.length === 0)
37
+ return invalid("market_data: must contain at least one metric");
38
+ if (keys.length > 50)
39
+ return invalid("market_data: exceeds 50 metrics");
40
+ const out = {};
41
+ for (const k of keys) {
42
+ if (!/^[a-z0-9_]{1,50}$/.test(k)) {
43
+ return invalid(`market_data: metric name "${k.slice(0, 50)}" must match [a-z0-9_]{1,50}`);
44
+ }
45
+ const mv = validateMarketValue(v[k], k);
46
+ if (!mv.ok)
47
+ return mv;
48
+ out[k] = mv.value;
49
+ }
50
+ return { ok: true, value: out };
51
+ }
52
+ export function validateOpportunityPayload(raw) {
53
+ if (!isRecord(raw))
54
+ return invalid("payload: must be a JSON object");
55
+ const opportunityId = field(raw, "opportunity_id", (v) => cappedId(v, "opportunity_id"));
56
+ if (!opportunityId.ok)
57
+ return opportunityId;
58
+ // §2: "asset_id: canonical asset identity, preferably chain/network +
59
+ // contract or provider ID" — enforce the canonical "chain:address" shape.
60
+ const assetId = field(raw, "asset_id", (v) => {
61
+ const id = cappedId(v, "asset_id");
62
+ if (!id.ok)
63
+ return id;
64
+ const colon = id.value.indexOf(":");
65
+ if (colon <= 0 || colon === id.value.length - 1) {
66
+ return { ok: false, errors: ["asset_id: must be canonical \"chain:address\""] };
67
+ }
68
+ return id;
69
+ });
70
+ if (!assetId.ok)
71
+ return assetId;
72
+ const symbol = field(raw, "symbol", (v) => {
73
+ // §2: display only; never sole identity — cap length, allow empty marginally? No: must exist.
74
+ if (typeof v !== "string" || v.length === 0 || v.length > 50) {
75
+ return { ok: false, errors: ["symbol: must be a string of 1..50 chars (display only)"] };
76
+ }
77
+ return { ok: true, value: v };
78
+ });
79
+ if (!symbol.ok)
80
+ return symbol;
81
+ const sourceRaw = raw.source;
82
+ if (!isRecord(sourceRaw))
83
+ return invalid("source: must be an object");
84
+ const sourceName = field(sourceRaw, "name", (v) => cappedId(v, "source.name"));
85
+ if (!sourceName.ok)
86
+ return sourceName;
87
+ const sourceRef = field(sourceRaw, "record_ref", (v) => cappedId(v, "source.record_ref"));
88
+ if (!sourceRef.ok)
89
+ return sourceRef;
90
+ const observedAt = field(raw, "observed_at", (v) => cappedTimestamp(v, "observed_at"));
91
+ if (!observedAt.ok)
92
+ return observedAt;
93
+ const marketData = field(raw, "market_data", validateMarketData);
94
+ if (!marketData.ok)
95
+ return marketData;
96
+ const evidenceRefs = field(raw, "evidence_refs", (v) => cappedStringList(v, "evidence_refs"));
97
+ if (!evidenceRefs.ok)
98
+ return evidenceRefs;
99
+ const qualityFlags = field(raw, "quality_flags", (v) => cappedStringList(v, "quality_flags"));
100
+ if (!qualityFlags.ok)
101
+ return qualityFlags;
102
+ // Notes must never smuggle credentials — generic cap, content-agnostic.
103
+ const riskAssessment = optionalField(raw, "risk_assessment", validateRiskAssessment);
104
+ if (!riskAssessment.ok)
105
+ return riskAssessment;
106
+ if (riskAssessment.value &&
107
+ riskAssessment.value.opportunity_id !== opportunityId.value) {
108
+ return invalid("risk_assessment.opportunity_id: must match payload.opportunity_id");
109
+ }
110
+ return {
111
+ ok: true,
112
+ value: {
113
+ opportunity_id: opportunityId.value,
114
+ asset_id: assetId.value,
115
+ symbol: symbol.value,
116
+ source: { name: sourceName.value, record_ref: sourceRef.value },
117
+ observed_at: observedAt.value,
118
+ market_data: marketData.value,
119
+ evidence_refs: evidenceRefs.value,
120
+ quality_flags: qualityFlags.value,
121
+ risk_assessment: riskAssessment.value,
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,30 @@
1
+ import { type ValidationResult } from "./validate.ts";
2
+ /** §4: "buttons: allow-listed action types and validated destinations". */
3
+ export interface PublicationButton {
4
+ type: string;
5
+ label: string;
6
+ url: string;
7
+ }
8
+ /** §4: "content: rendered text/media references". */
9
+ export interface PublicationContent {
10
+ text: string;
11
+ media_refs?: string[];
12
+ }
13
+ /**
14
+ * §4 minimum payload, mirrored field-for-field. `approval_state` starts at
15
+ * `pending_human` — a candidate can never enter the ledger pre-approved.
16
+ */
17
+ export interface PublicationCandidate {
18
+ publication_id: string;
19
+ opportunity_id: string;
20
+ assessment_id: string;
21
+ channel_id: string;
22
+ template_id: string;
23
+ template_version: string;
24
+ content: PublicationContent;
25
+ buttons?: PublicationButton[];
26
+ approval_state: "pending_human";
27
+ expires_at: string;
28
+ idempotency_key: string;
29
+ }
30
+ export declare function validatePublicationCandidate(raw: unknown): ValidationResult<PublicationCandidate>;
@@ -0,0 +1,124 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — §4 Core → tg-content-bot: publication candidate
3
+ // ============================================================================
4
+ import { cappedId, cappedText, cappedTimestamp, field, invalid, isRecord, optionalField, } from "./validate.js";
5
+ import { validateRiskAssessment } from "./risk.js";
6
+ const BUTTON_TYPES = ["url"];
7
+ function validateButton(v, idx) {
8
+ if (!isRecord(v))
9
+ return invalid(`buttons[${idx}]: must be an object`);
10
+ const type = field(v, "type", (x) => typeof x === "string" && BUTTON_TYPES.includes(x)
11
+ ? { ok: true, value: x }
12
+ : { ok: false, errors: [`buttons[${idx}].type: not an allow-listed action type`] });
13
+ if (!type.ok)
14
+ return type;
15
+ const label = field(v, "label", (x) => cappedText(x, `buttons[${idx}].label`));
16
+ if (!label.ok)
17
+ return label;
18
+ const url = field(v, "url", (x) => {
19
+ if (typeof x !== "string" || x.length > 500) {
20
+ return { ok: false, errors: [`buttons[${idx}].url: must be a string of at most 500 chars`] };
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(x);
25
+ }
26
+ catch {
27
+ return { ok: false, errors: [`buttons[${idx}].url: must be a valid absolute https URL`] };
28
+ }
29
+ if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password) {
30
+ return { ok: false, errors: [`buttons[${idx}].url: must be https and cannot contain credentials`] };
31
+ }
32
+ return { ok: true, value: parsed.toString() };
33
+ });
34
+ if (!url.ok)
35
+ return url;
36
+ return { ok: true, value: { type: type.value, label: label.value, url: url.value } };
37
+ }
38
+ export function validatePublicationCandidate(raw) {
39
+ if (!isRecord(raw))
40
+ return invalid("publication_candidate: must be a JSON object");
41
+ const publicationId = field(raw, "publication_id", (v) => cappedId(v, "publication_id"));
42
+ if (!publicationId.ok)
43
+ return publicationId;
44
+ const opportunityId = field(raw, "opportunity_id", (v) => cappedId(v, "opportunity_id"));
45
+ if (!opportunityId.ok)
46
+ return opportunityId;
47
+ const assessmentId = field(raw, "assessment_id", (v) => cappedId(v, "assessment_id"));
48
+ if (!assessmentId.ok)
49
+ return assessmentId;
50
+ // §4: "channel_id: approved internal identifier, not an arbitrary
51
+ // destination supplied by an event" — shape-level check only; the ledger
52
+ // must additionally verify it exists internally before use.
53
+ const channelId = field(raw, "channel_id", (v) => cappedId(v, "channel_id"));
54
+ if (!channelId.ok)
55
+ return channelId;
56
+ const templateId = field(raw, "template_id", (v) => cappedId(v, "template_id"));
57
+ if (!templateId.ok)
58
+ return templateId;
59
+ const templateVersion = field(raw, "template_version", (v) => cappedId(v, "template_version"));
60
+ if (!templateVersion.ok)
61
+ return templateVersion;
62
+ const contentRaw = raw.content;
63
+ if (!isRecord(contentRaw))
64
+ return invalid("content: must be an object");
65
+ const text = field(contentRaw, "text", (v) => cappedText(v, "content.text"));
66
+ if (!text.ok)
67
+ return text;
68
+ const mediaRefs = optionalField(contentRaw, "media_refs", (v) => {
69
+ if (!Array.isArray(v) || v.length > 10 || !v.every((x) => typeof x === "string")) {
70
+ return { ok: false, errors: ["content.media_refs: must be ≤ 10 strings"] };
71
+ }
72
+ return { ok: true, value: v };
73
+ });
74
+ if (!mediaRefs.ok)
75
+ return mediaRefs;
76
+ const buttons = optionalField(raw, "buttons", (v) => {
77
+ if (!Array.isArray(v))
78
+ return { ok: false, errors: ["buttons: must be an array"] };
79
+ if (v.length > 8)
80
+ return { ok: false, errors: ["buttons: exceeds 8 buttons"] };
81
+ const out = [];
82
+ for (let i = 0; i < v.length; i++) {
83
+ const b = validateButton(v[i], i);
84
+ if (!b.ok)
85
+ return b;
86
+ out.push(b.value);
87
+ }
88
+ return { ok: true, value: out };
89
+ });
90
+ if (!buttons.ok)
91
+ return buttons;
92
+ // §4: "approval_state: initially pending_human" — literally enforced.
93
+ const approvalState = field(raw, "approval_state", (v) => v === "pending_human"
94
+ ? { ok: true, value: "pending_human" }
95
+ : { ok: false, errors: ["approval_state: must be exactly \"pending_human\""] });
96
+ if (!approvalState.ok)
97
+ return approvalState;
98
+ const expiresAt = field(raw, "expires_at", (v) => cappedTimestamp(v, "expires_at"));
99
+ if (!expiresAt.ok)
100
+ return expiresAt;
101
+ const idempotencyKey = field(raw, "idempotency_key", (v) => cappedId(v, "idempotency_key"));
102
+ if (!idempotencyKey.ok)
103
+ return idempotencyKey;
104
+ // risk_assessment presence is not part of §4; validate if supplied (helper reuse).
105
+ const extraAssessment = optionalField(raw, "risk_assessment", validateRiskAssessment);
106
+ if (!extraAssessment.ok)
107
+ return extraAssessment;
108
+ return {
109
+ ok: true,
110
+ value: {
111
+ publication_id: publicationId.value,
112
+ opportunity_id: opportunityId.value,
113
+ assessment_id: assessmentId.value,
114
+ channel_id: channelId.value,
115
+ template_id: templateId.value,
116
+ template_version: templateVersion.value,
117
+ content: { text: text.value, media_refs: mediaRefs.value },
118
+ buttons: buttons.value,
119
+ approval_state: "pending_human",
120
+ expires_at: expiresAt.value,
121
+ idempotency_key: idempotencyKey.value,
122
+ },
123
+ };
124
+ }
package/dist/risk.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { type ValidationResult } from "./validate.ts";
2
+ export declare const RISK_DECISIONS: readonly ["allow", "reject", "review"];
3
+ export type RiskDecision = (typeof RISK_DECISIONS)[number];
4
+ export declare const DATA_QUALITIES: readonly ["valid", "stale", "incomplete", "conflicting"];
5
+ export type DataQuality = (typeof DATA_QUALITIES)[number];
6
+ /**
7
+ * Shield's /assess response (§3): "Shield response must include" — mirrored
8
+ * field-for-field. Any decision other than a verbatim match is rejected.
9
+ */
10
+ export interface RiskAssessment {
11
+ assessment_id: string;
12
+ opportunity_id: string;
13
+ decision: RiskDecision;
14
+ reason_codes: string[];
15
+ checked_at: string;
16
+ policy_version: string;
17
+ data_quality: DataQuality;
18
+ expires_at: string;
19
+ }
20
+ export declare function validateRiskAssessment(raw: unknown): ValidationResult<RiskAssessment>;
21
+ /**
22
+ * §3 gate: "A missing response, unknown decision, expired assessment, or
23
+ * non-valid data quality must block publication."
24
+ *
25
+ * Publication is allowed only when the decision is `allow`, the data quality
26
+ * is exactly `valid`, and the assessment has not expired. Anything else
27
+ * blocks — including `review` (human triage may override later by explicit
28
+ * owner action, never by default).
29
+ */
30
+ export declare function isAssessmentUsableForPublication(a: RiskAssessment, nowMs: number): boolean;
package/dist/risk.js ADDED
@@ -0,0 +1,73 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — §3 Shield risk assessment (DRAFT §3 response shape)
3
+ // ============================================================================
4
+ import { cappedId, cappedStringList, cappedTimestamp, field, invalid, isOneOf, isRecord, } from "./validate.js";
5
+ export const RISK_DECISIONS = ["allow", "reject", "review"];
6
+ export const DATA_QUALITIES = [
7
+ "valid",
8
+ "stale",
9
+ "incomplete",
10
+ "conflicting",
11
+ ];
12
+ export function validateRiskAssessment(raw) {
13
+ if (!isRecord(raw))
14
+ return invalid("risk_assessment: must be a JSON object");
15
+ const assessmentId = field(raw, "assessment_id", (v) => cappedId(v, "assessment_id"));
16
+ if (!assessmentId.ok)
17
+ return assessmentId;
18
+ const opportunityId = field(raw, "opportunity_id", (v) => cappedId(v, "opportunity_id"));
19
+ if (!opportunityId.ok)
20
+ return opportunityId;
21
+ const decision = field(raw, "decision", (v) => isOneOf(v, RISK_DECISIONS)
22
+ ? { ok: true, value: v }
23
+ : { ok: false, errors: ["decision: must be one of allow|reject|review"] });
24
+ if (!decision.ok)
25
+ return decision;
26
+ const reasonCodes = field(raw, "reason_codes", (v) => cappedStringList(v, "reason_codes"));
27
+ if (!reasonCodes.ok)
28
+ return reasonCodes;
29
+ const checkedAt = field(raw, "checked_at", (v) => cappedTimestamp(v, "checked_at"));
30
+ if (!checkedAt.ok)
31
+ return checkedAt;
32
+ const policyVersion = field(raw, "policy_version", (v) => cappedId(v, "policy_version"));
33
+ if (!policyVersion.ok)
34
+ return policyVersion;
35
+ const dataQuality = field(raw, "data_quality", (v) => isOneOf(v, DATA_QUALITIES)
36
+ ? { ok: true, value: v }
37
+ : { ok: false, errors: ["data_quality: must be one of valid|stale|incomplete|conflicting"] });
38
+ if (!dataQuality.ok)
39
+ return dataQuality;
40
+ const expiresAt = field(raw, "expires_at", (v) => cappedTimestamp(v, "expires_at"));
41
+ if (!expiresAt.ok)
42
+ return expiresAt;
43
+ return {
44
+ ok: true,
45
+ value: {
46
+ assessment_id: assessmentId.value,
47
+ opportunity_id: opportunityId.value,
48
+ decision: decision.value,
49
+ reason_codes: reasonCodes.value,
50
+ checked_at: checkedAt.value,
51
+ policy_version: policyVersion.value,
52
+ data_quality: dataQuality.value,
53
+ expires_at: expiresAt.value,
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * §3 gate: "A missing response, unknown decision, expired assessment, or
59
+ * non-valid data quality must block publication."
60
+ *
61
+ * Publication is allowed only when the decision is `allow`, the data quality
62
+ * is exactly `valid`, and the assessment has not expired. Anything else
63
+ * blocks — including `review` (human triage may override later by explicit
64
+ * owner action, never by default).
65
+ */
66
+ export function isAssessmentUsableForPublication(a, nowMs) {
67
+ if (a.decision !== "allow")
68
+ return false;
69
+ if (a.data_quality !== "valid")
70
+ return false;
71
+ const exp = Date.parse(a.expires_at);
72
+ return Number.isFinite(exp) && exp > nowMs;
73
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Hand-rolled validation helpers (house style: the sibling engines validate
3
+ * by hand, no zod at runtime). Everything here is pure and deterministic.
4
+ *
5
+ * Result shape: `{ ok: true, value }` or `{ ok: false, errors: string[] }`.
6
+ * Errors are safe, redacted messages — never echo payload secrets.
7
+ */
8
+ export type Valid<T> = {
9
+ ok: true;
10
+ value: T;
11
+ };
12
+ export type Invalid = {
13
+ ok: false;
14
+ errors: string[];
15
+ };
16
+ export type ValidationResult<T> = Valid<T> | Invalid;
17
+ export declare function valid<T>(value: T): Valid<T>;
18
+ export declare function invalid(...errors: string[]): Invalid;
19
+ /** Merge two results; short-circuits on the first invalid one. */
20
+ export declare function flatMap<T, U>(r: ValidationResult<T>, f: (value: T) => ValidationResult<U>): ValidationResult<U>;
21
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
22
+ export declare function isString(v: unknown): v is string;
23
+ export declare function isNonEmptyString(v: unknown): v is string;
24
+ export declare function isStringArray(v: unknown): v is string[];
25
+ /** Any string that `Date.parse` accepts (ISO-8601 UTC expected in practice). */
26
+ export declare function isTimestamp(v: unknown): v is string;
27
+ export declare function isFiniteNumber(v: unknown): v is number;
28
+ export declare function isOneOf<T extends string>(v: unknown, allowed: readonly T[]): v is T;
29
+ /** Hard length caps — hygiene against oversized/abusive fields. */
30
+ export declare const MAX_ID_LEN = 200;
31
+ export declare const MAX_TEXT_LEN = 8000;
32
+ export declare const MAX_LIST_LEN = 50;
33
+ export declare function cappedId(v: unknown, field: string): ValidationResult<string>;
34
+ export declare function cappedText(v: unknown, field: string): ValidationResult<string>;
35
+ export declare function cappedStringList(v: unknown, field: string): ValidationResult<string[]>;
36
+ export declare function cappedTimestamp(v: unknown, field: string): ValidationResult<string>;
37
+ /** Read a required field from a record and validate it. */
38
+ export declare function field<T>(rec: Record<string, unknown>, name: string, validate: (v: unknown) => ValidationResult<T>): ValidationResult<T>;
39
+ /** Read an optional field; absent → undefined, present-but-invalid → error. */
40
+ export declare function optionalField<T>(rec: Record<string, unknown>, name: string, validate: (v: unknown) => ValidationResult<T>): ValidationResult<T | undefined>;
@@ -0,0 +1,80 @@
1
+ // ============================================================================
2
+ // VIXOR Contracts — minimal zero-dependency runtime validators
3
+ // ============================================================================
4
+ export function valid(value) {
5
+ return { ok: true, value };
6
+ }
7
+ export function invalid(...errors) {
8
+ return { ok: false, errors };
9
+ }
10
+ /** Merge two results; short-circuits on the first invalid one. */
11
+ export function flatMap(r, f) {
12
+ return r.ok ? f(r.value) : r;
13
+ }
14
+ export function isRecord(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ export function isString(v) {
18
+ return typeof v === "string";
19
+ }
20
+ export function isNonEmptyString(v) {
21
+ return typeof v === "string" && v.length > 0;
22
+ }
23
+ export function isStringArray(v) {
24
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
25
+ }
26
+ /** Any string that `Date.parse` accepts (ISO-8601 UTC expected in practice). */
27
+ export function isTimestamp(v) {
28
+ if (typeof v !== "string")
29
+ return false;
30
+ const t = Date.parse(v);
31
+ return Number.isFinite(t);
32
+ }
33
+ export function isFiniteNumber(v) {
34
+ return typeof v === "number" && Number.isFinite(v);
35
+ }
36
+ export function isOneOf(v, allowed) {
37
+ return typeof v === "string" && allowed.includes(v);
38
+ }
39
+ /** Hard length caps — hygiene against oversized/abusive fields. */
40
+ export const MAX_ID_LEN = 200;
41
+ export const MAX_TEXT_LEN = 8000;
42
+ export const MAX_LIST_LEN = 50;
43
+ export function cappedId(v, field) {
44
+ if (!isNonEmptyString(v))
45
+ return invalid(`${field}: must be a non-empty string`);
46
+ if (v.length > MAX_ID_LEN)
47
+ return invalid(`${field}: exceeds ${MAX_ID_LEN} chars`);
48
+ return valid(v);
49
+ }
50
+ export function cappedText(v, field) {
51
+ if (!isString(v))
52
+ return invalid(`${field}: must be a string`);
53
+ if (v.length > MAX_TEXT_LEN)
54
+ return invalid(`${field}: exceeds ${MAX_TEXT_LEN} chars`);
55
+ return valid(v);
56
+ }
57
+ export function cappedStringList(v, field) {
58
+ if (!isStringArray(v))
59
+ return invalid(`${field}: must be an array of strings`);
60
+ if (v.length > MAX_LIST_LEN)
61
+ return invalid(`${field}: exceeds ${MAX_LIST_LEN} items`);
62
+ return valid(v);
63
+ }
64
+ export function cappedTimestamp(v, field) {
65
+ if (!isTimestamp(v))
66
+ return invalid(`${field}: must be a parseable timestamp`);
67
+ return valid(v);
68
+ }
69
+ /** Read a required field from a record and validate it. */
70
+ export function field(rec, name, validate) {
71
+ if (!(name in rec))
72
+ return invalid(`${name}: missing required field`);
73
+ return validate(rec[name]);
74
+ }
75
+ /** Read an optional field; absent → undefined, present-but-invalid → error. */
76
+ export function optionalField(rec, name, validate) {
77
+ if (!(name in rec) || rec[name] === undefined)
78
+ return valid(undefined);
79
+ return validate(rec[name]);
80
+ }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "@vixor/contracts",
3
- "version": "0.2.0",
4
- "description": "Zero-dependency runtime contracts and validators for VIXOR crypto-intelligence integrations.",
5
- "license": "UNLICENSED",
6
- "type": "module",
7
- "main": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js",
13
- "default": "./dist/index.js"
14
- }
15
- },
16
- "files": [
17
- "dist",
18
- "README.md"
19
- ],
20
- "sideEffects": false,
21
- "engines": {
22
- "node": ">=20"
23
- },
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
- "scripts": {
28
- "build": "tsc -p tsconfig.build.json",
29
- "typecheck": "tsc --noEmit",
30
- "test": "node --experimental-strip-types --test test/contracts.test.ts",
31
- "pack:check": "node scripts/verify-pack.mjs"
32
- },
33
- "devDependencies": {
34
- "@types/node": "^22.16.5",
35
- "typescript": "^5.8.3"
36
- },
37
- "repository": {
38
- "type": "git",
39
- "url": "https://github.com/kam65624-cmd/vixor-packages.git",
40
- "directory": "packages/vixor-contracts"
41
- }
42
- }
1
+ {
2
+ "name": "@vixor/contracts",
3
+ "version": "0.2.1",
4
+ "description": "Zero-dependency runtime contracts and validators for VIXOR crypto-intelligence integrations.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "node --experimental-strip-types --test test/contracts.test.ts",
31
+ "pack:check": "node scripts/verify-pack.mjs"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.16.5",
35
+ "typescript": "^5.8.3"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/kam65624-cmd/vixor-packages.git",
40
+ "directory": "packages/vixor-contracts"
41
+ }
42
+ }