diff --git a/package.json b/package.json index 26906aaa..d500be4b 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,11 @@ "test:coverage": "jest --coverage --passWithNoTests", "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config", "test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", + "test:exact-source": "tsc -p tsconfig.exact-source.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", "test:watch": "jest --watch", - "typecheck": "tsc -p tsconfig.tests.json --noEmit" + "typecheck": "tsc -p tsconfig.tests.json --noEmit && npm run -s test:exact-source" }, "config": { "localnet_quickstart_ref": "5c90cf4d0eb934712fd8c269b56e6272b605ccad" diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 3454b2a6..edde6bc0 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -386,9 +386,8 @@ export class CapTableBatch { try { const templateId = 'ExerciseCommand' in command ? command.ExerciseCommand.templateId : undefined; const mergedContext = mergeCommandContext(this.params.defaultContext, this.params.context); - const context = mergeCommandContext(mergedContext, { - commandId: this.params.commandId ?? mergedContext?.commandId ?? createUpdateCapTableCommandId(), - }); + const commandId = this.params.commandId ?? mergedContext?.commandId ?? createUpdateCapTableCommandId(); + const context = { ...mergedContext, commandId }; response = await submitObservedTransactionTree( this.client, { @@ -401,11 +400,11 @@ export class CapTableBatch { ...(this.params.logger === undefined ? {} : { logger: this.params.logger }), ...(this.params.metrics === undefined ? {} : { metrics: this.params.metrics }), ...(this.params.defaultContext === undefined ? {} : { defaultContext: this.params.defaultContext }), - ...(context === undefined ? {} : { context }), + context, }, { operation: 'capTable.update', - templateId, + ...(templateId !== undefined ? { templateId } : {}), choice: 'UpdateCapTable', } ); diff --git a/src/functions/OpenCapTable/capTable/archiveCapTable.ts b/src/functions/OpenCapTable/capTable/archiveCapTable.ts index b345e773..68615ba7 100644 --- a/src/functions/OpenCapTable/capTable/archiveCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveCapTable.ts @@ -77,7 +77,9 @@ function snapshotArchiveCapTableParams( function buildArchiveCapTableCommandFromSnapshot(params: ArchiveCapTableParams): CommandWithDisclosedContracts { return buildCapTableCommand({ capTableContractId: params.capTableContractId, - capTableContractDetails: params.capTableContractDetails, + ...(params.capTableContractDetails === undefined + ? {} + : { capTableContractDetails: params.capTableContractDetails }), choice: 'ArchiveCapTable', choiceArgument: {}, }); @@ -137,7 +139,11 @@ export async function archiveCapTable( disclosedContracts, }, observability, - { operation: 'archiveCapTable', templateId, choice: 'ArchiveCapTable' } + { + operation: 'archiveCapTable', + ...(templateId === undefined ? {} : { templateId }), + choice: 'ArchiveCapTable', + } ); return { updateId: result.transactionTree.updateId }; diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 46a30e01..81791a78 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -281,7 +281,7 @@ async function buildCapTableStateFromCreatedEvent( try { const eventsResponse = await client.getEventsByContractId({ contractId: issuerContractId, - ...ledgerReadScope({ readAs: issuerPartyId ? [issuerPartyId] : undefined }), + ...ledgerReadScope(issuerPartyId ? { readAs: [issuerPartyId] } : {}), }); const issuerId = requireIssuerCanonicalObjectId(eventsResponse, issuerContractId); entities.set('issuer', new Set([issuerId])); diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index feb836d4..7b4f518e 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -19,14 +19,16 @@ import { canonicalizeNumeric10 } from '../../../utils/numeric10'; import { damlAddressToNative, damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): OcfIssuerInput['email'] { +function damlEmailToNative( + damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail +): NonNullable { return { email_type: damlEmailTypeToNative(damlEmail.email_type), email_address: damlEmail.email_address, }; } -function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): OcfIssuerInput['phone'] { +function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): NonNullable { return { phone_type: damlPhoneTypeToNative(phone.phone_type), phone_number: phone.phone_number, diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 67246e19..237d2103 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -202,32 +202,31 @@ export async function readSingleContract( const templateIdentity = options.expectedTemplateId ? assertTemplateIdentity( { - templateId, - packageName, + ...(templateId !== undefined ? { templateId } : {}), + ...(packageName !== undefined ? { packageName } : {}), }, options.expectedTemplateId, { contractId: params.contractId, operation: options.operation, - message: - templateId === undefined && packageName === undefined - ? 'Contract template identity is missing; cannot validate expected template' - : undefined, + ...(templateId === undefined && packageName === undefined + ? { message: 'Contract template identity is missing; cannot validate expected template' } + : {}), } ) : undefined; const createArgument = requireCreateArgumentRecord(createdEvent.createArgument, params.contractId, { operation: options.operation, - templateId, + ...(templateId !== undefined ? { templateId } : {}), }); return { contractId: params.contractId, createArgument, createdEvent, - templateId, - packageName, - templateIdentity, + ...(templateId !== undefined ? { templateId } : {}), + ...(packageName !== undefined ? { packageName } : {}), + ...(templateIdentity !== undefined ? { templateIdentity } : {}), }; } diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index ef99f3b3..2df3b5ba 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -84,18 +84,21 @@ function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumbe }; } -function damlStockIssuanceTypeToNative(t: string | null): StockIssuanceType | undefined { - if (t === null) return undefined; +function damlStockIssuanceTypeToNative(t: unknown): StockIssuanceType | undefined { + if (t === null || t === undefined) return undefined; switch (t) { case 'OcfStockIssuanceRSA': return 'RSA'; case 'OcfStockIssuanceFounders': return 'FOUNDERS_STOCK'; - default: - throw new OcpParseError(`Unknown DAML stock issuance type: ${t}`, { + default: { + const detail = typeof t === 'string' ? `: ${t}` : ''; + throw new OcpParseError(`Unknown DAML stock issuance type${detail}`, { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: t }, }); + } } } @@ -171,6 +174,7 @@ export function damlStockIssuanceDataToNative( }; }) : []; + const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( @@ -208,11 +212,7 @@ export function damlStockIssuanceDataToNative( stock_legend_ids: Array.isArray((d as unknown as { stock_legend_ids?: unknown }).stock_legend_ids) ? (d as unknown as { stock_legend_ids: string[] }).stock_legend_ids : [], - ...((anyD as { issuance_type?: unknown }).issuance_type != null && { - issuance_type: damlStockIssuanceTypeToNative( - (anyD as { issuance_type?: unknown }).issuance_type as string | null - ), - }), + ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), comments: (anyD as { comments?: unknown }).comments !== undefined && Array.isArray((anyD as { comments?: unknown }).comments) diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 6a00f023..7889236b 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -18,8 +18,8 @@ import { readSingleContract } from '../shared/singleContractRead'; type StockPlanOcfData = Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData; -function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { - if (b === null) return undefined; +function damlCancellationBehaviorToNative(b: string | null | undefined): StockPlanCancellationBehavior | undefined { + if (b === null || b === undefined) return undefined; switch (b) { case 'OcfPlanCancelRetire': return 'RETIRE'; @@ -120,6 +120,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: stockClassIds, }); } + const defaultCancellationBehavior = damlCancellationBehaviorToNative(decoded.default_cancellation_behavior); const numeric = canonicalizeNumeric10(initialSharesReserved, { allowExponent: true }); if (!numeric.ok) { @@ -143,9 +144,9 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), initial_shares_reserved: numeric.value, - ...(decoded.default_cancellation_behavior && { - default_cancellation_behavior: damlCancellationBehaviorToNative(decoded.default_cancellation_behavior), - }), + ...(defaultCancellationBehavior !== undefined + ? { default_cancellation_behavior: defaultCancellationBehavior } + : {}), stock_class_ids: [firstStockClassId, ...remainingStockClassIds], comments: decoded.comments, }; diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 5c89721b..a7159393 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -201,7 +201,11 @@ function parseVestingPeriodCommonFields( ? damlVestingPeriodIntegerToNative(v.cliff_installment, `${fieldPath}.cliff_installment`, 0) : undefined; - return { length, occurrences, cliffInstallment }; + return { + length, + occurrences, + ...(cliffInstallment !== undefined ? { cliffInstallment } : {}), + }; } function requireVestingPeriodValue(value: unknown, fieldPath: string): Record { diff --git a/src/observability.ts b/src/observability.ts index b385a0c8..0d42410e 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -1,8 +1,15 @@ -import type { LedgerJsonApiClient, TraceContext } from '@fairmint/canton-node-sdk'; +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, OcpValidationError } from './errors'; import { toSafeDiagnosticText, toSafeDiagnosticValue } from './errors/OcpError'; import type { CommandContext, CommandObservabilityOptions, CommandTelemetry } from './observabilityTypes'; -import { mergeCommandContextSnapshots } from './utils/commandContext'; +import { mergeCommandContextSnapshots, snapshotCommandContext } from './utils/commandContext'; +import { + inspectExactObject, + toExactDataValidationError, + type ExactDataFailure, + type ExactObjectSnapshot, +} from './utils/exactObject'; import { snapshotCommandObservabilityOptions } from './utils/observabilityConfig'; export type { @@ -17,7 +24,52 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; -type TraceableSubmitTransactionTreeParams = SubmitTransactionTreeParams & { traceContext?: TraceContext }; +type SubmitTraceContext = NonNullable; +type RequiredKeys = { + [K in keyof T]-?: object extends Pick ? never : K; +}[keyof T]; +type RequiredSubmitTransactionTreeParams = Pick>; +type OptionalSubmitTransactionTreeParams = Omit>; + +function exhaustiveKeys() { + return >( + keys: Keys & ([Exclude] extends [never] ? unknown : never) + ): Keys => keys; +} + +const REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS = exhaustiveKeys()(['commands']); +const OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS = exhaustiveKeys()([ + 'commandId', + 'actAs', + 'userId', + 'readAs', + 'workflowId', + 'deduplicationPeriod', + 'minLedgerTimeAbs', + 'minLedgerTimeRel', + 'submissionId', + 'traceContext', + 'disclosedContracts', + 'synchronizerId', + 'packageIdSelectionPreference', + 'prefetchContractKeys', +]); +const SUBMIT_TRACE_CONTEXT_KEYS = exhaustiveKeys()([ + 'traceId', + 'spanId', + 'parentSpanId', + 'metadata', +]); +const SUBMIT_TRACE_CONTEXT_KEY_SET: ReadonlySet = new Set(SUBMIT_TRACE_CONTEXT_KEYS); +const SUBMIT_TRANSACTION_TREE_PARAM_KEYS = [ + ...REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, + ...OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, +] as const satisfies ReadonlyArray; +const REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET: ReadonlySet = new Set( + REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS +); +/** Plain ledger submit parameters with omission-only, immutable top-level and command-context fields. */ +export type AppliedCommandContext = Readonly> & CommandContext; export function mergeCommandContext( ...contexts: Array | undefined> @@ -25,19 +77,145 @@ export function mergeCommandContext( return mergeCommandContextSnapshots(contexts); } -function applyMergedCommandContext( - params: T, - context: CommandContext | undefined -): T & TraceableSubmitTransactionTreeParams { - if (!context) return params; - - return { - ...params, - ...(context.workflowId !== undefined ? { workflowId: context.workflowId } : {}), - ...(context.commandId !== undefined ? { commandId: context.commandId } : {}), - ...(context.submissionId !== undefined ? { submissionId: context.submissionId } : {}), - ...(context.traceContext !== undefined ? { traceContext: context.traceContext } : {}), +function throwSubmitObjectFailure(root: string, subject: string, failure: ExactDataFailure): never { + throw toExactDataValidationError(root, failure, { + message: `${subject} must be a plain object containing own data properties only; rejected ${failure.reason}.`, + expectedType: `plain ${subject} object with own data properties only`, + }); +} + +function requiredSubmitParameter( + snapshot: ExactObjectSnapshot, + key: keyof RequiredSubmitTransactionTreeParams, + root: string +): unknown { + if (!snapshot.has(key)) { + throw new OcpValidationError(`${root}.${key}`, `${key} is required.`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'defined own data property', + }); + } + const value = snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError(`${root}.${key}`, `${key} must not be undefined.`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined own data property', + receivedValue: value, + }); + } + return value; +} + +function snapshotSubmitTransactionTreeParams(params: SubmitTransactionTreeParams): SubmitTransactionTreeParams { + const inspection = inspectExactObject(params); + if (!inspection.ok) throwSubmitObjectFailure('submitParams', 'ledger submit parameters', inspection); + + // Keep required fields materialized. The typed assertion is localized after a + // descriptor-safe read; the Canton client remains responsible for command decoding. + const requiredSubmitParams: RequiredSubmitTransactionTreeParams = { + commands: requiredSubmitParameter( + inspection.snapshot, + 'commands', + 'submitParams' + ) as SubmitTransactionTreeParams['commands'], }; + const snapshot: SubmitTransactionTreeParams = { ...requiredSubmitParams }; + + // Project every optional canonical field from its captured data descriptor and + // intentionally exclude unknown caller-specific data properties. + for (const key of SUBMIT_TRANSACTION_TREE_PARAM_KEYS) { + if (REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET.has(key)) continue; + if (!inspection.snapshot.has(key)) continue; + const value = inspection.snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError( + `submitParams.${String(key)}`, + `${String(key)} must be omitted rather than undefined.`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined value or omitted property', + receivedValue: value, + } + ); + } + Object.defineProperty(snapshot, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); + } + + return Object.freeze(snapshot); +} + +function snapshotSubmitTraceContext(traceContext: SubmitTraceContext): NonNullable { + const inspection = inspectExactObject(traceContext, { allowedKeys: SUBMIT_TRACE_CONTEXT_KEY_SET }); + if (!inspection.ok) throwSubmitObjectFailure('submitParams.traceContext', 'trace context', inspection); + + const projected: Partial = {}; + for (const key of SUBMIT_TRACE_CONTEXT_KEYS) { + if (!inspection.snapshot.has(key)) continue; + const value = inspection.snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError( + `submitParams.traceContext.${String(key)}`, + `${String(key)} must be omitted rather than undefined.`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined value or omitted property', + receivedValue: value, + } + ); + } + Object.defineProperty(projected, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); + } + + const context = snapshotCommandContext( + { + traceContext: projected, + }, + 'submitParams' + ); + if (context?.traceContext === undefined) { + throw new OcpValidationError('submitParams.traceContext', 'traceContext snapshot could not be created.', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'valid trace context', + receivedValue: traceContext, + }); + } + return context.traceContext; +} + +function applyMergedCommandContext( + params: SubmitTransactionTreeParams, + context: CommandContext | undefined +): AppliedCommandContext { + const snapshot = snapshotSubmitTransactionTreeParams(params); + const { workflowId, commandId, submissionId, traceContext, ...submitParams } = snapshot; + const normalizedTraceContext = traceContext === undefined ? undefined : snapshotSubmitTraceContext(traceContext); + const appliedContext = mergeCommandContext( + { + ...(workflowId !== undefined ? { workflowId } : {}), + ...(commandId !== undefined ? { commandId } : {}), + ...(submissionId !== undefined ? { submissionId } : {}), + ...(normalizedTraceContext !== undefined ? { traceContext: normalizedTraceContext } : {}), + }, + context + ); + + return Object.freeze({ + ...submitParams, + ...(appliedContext?.workflowId !== undefined ? { workflowId: appliedContext.workflowId } : {}), + ...(appliedContext?.commandId !== undefined ? { commandId: appliedContext.commandId } : {}), + ...(appliedContext?.submissionId !== undefined ? { submissionId: appliedContext.submissionId } : {}), + ...(appliedContext?.traceContext !== undefined ? { traceContext: appliedContext.traceContext } : {}), + }); } function runBestEffort(callback: (() => unknown) | undefined): void { @@ -105,10 +283,16 @@ function observedErrorDiagnostics(error: unknown): ObservedErrorDiagnostics { }); } +/** + * Apply command context to a plain ledger-submit carrier. + * + * Runtime inputs must use own data properties on a plain or null-prototype object. + * Proxies, accessors, symbols, and custom prototypes are rejected without invoking traps. + */ export function applyCommandContext( params: T, options?: CommandObservabilityOptions -): T & TraceableSubmitTransactionTreeParams { +): AppliedCommandContext { const safeOptions = snapshotCommandObservabilityOptions(options); const context = mergeCommandContext(safeOptions?.defaultContext, safeOptions?.context); return applyMergedCommandContext(params, context); diff --git a/src/observabilityTypes.ts b/src/observabilityTypes.ts index d877080c..9e37953e 100644 --- a/src/observabilityTypes.ts +++ b/src/observabilityTypes.ts @@ -1,8 +1,10 @@ +import type { TraceContext } from '@fairmint/canton-node-sdk'; + export interface ReadonlyTraceContext { - readonly traceId?: string; - readonly spanId?: string; - readonly parentSpanId?: string; - readonly metadata?: Readonly>; + readonly traceId?: NonNullable; + readonly spanId?: NonNullable; + readonly parentSpanId?: NonNullable; + readonly metadata?: Readonly>; } export interface CommandContext { diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index e3bd4979..1b475e9f 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -448,10 +448,10 @@ export async function extractCantonOcfManifest( cantonState: CapTableState, options: ExtractCantonOcfOptions = {} ): Promise { - const { verbose = false, failOnReadErrors = true, readAs } = options; + const { verbose = false, failOnReadErrors = true } = options; // eslint-disable-next-line no-console const log = options.logger ?? (verbose ? (msg: string) => console.log(msg) : () => {}); - const readScopeOpts = ledgerReadScope({ readAs }); + const readScopeOpts = ledgerReadScope(options); const result: OcfManifest = { issuer: null, @@ -518,7 +518,7 @@ export async function extractCantonOcfManifest( objectId: issuerId, contractId: issuerCid, attempts: issuerAttempts, - ...ledgerReadScope({ readAs }), + ...readScopeOpts, }, }); if (failOnReadErrors) { @@ -639,7 +639,7 @@ export async function extractCantonOcfManifest( objectId, contractId, attempts: readAttempts, - ...ledgerReadScope({ readAs }), + ...readScopeOpts, }, }); if (failOnReadErrors) { diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index 830aebe8..aa3b3124 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -104,7 +104,7 @@ export function decodeGeneratedDaml( code: OcpErrorCodes.SCHEMA_MISMATCH, classification: options.classification ?? 'invalid_generated_daml_data', ...(cause ? { cause } : {}), - context: options.context, + ...(options.context !== undefined ? { context: options.context } : {}), }); } @@ -121,7 +121,7 @@ export function decodeGeneratedDaml( code: OcpErrorCodes.SCHEMA_MISMATCH, classification: 'invalid_generated_daml_encoding', ...(cause ? { cause } : {}), - context: options.context, + ...(options.context !== undefined ? { context: options.context } : {}), }); } assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`); @@ -131,7 +131,7 @@ export function decodeGeneratedDaml( source: lossyPath, code: OcpErrorCodes.SCHEMA_MISMATCH, classification: 'lossy_generated_decode', - context: options.context, + ...(options.context !== undefined ? { context: options.context } : {}), }); } return decoded; @@ -214,7 +214,7 @@ function generatedWrapperMismatch(source: string, message: string, context?: Rec source, code: OcpErrorCodes.SCHEMA_MISMATCH, classification: 'invalid_generated_create_argument', - context, + ...(context !== undefined ? { context } : {}), }); } diff --git a/src/utils/templateIdentity.ts b/src/utils/templateIdentity.ts index 5ab5eaad..b64395b4 100644 --- a/src/utils/templateIdentity.ts +++ b/src/utils/templateIdentity.ts @@ -12,17 +12,25 @@ export interface TemplateIdentityInput { packageName?: unknown; } -export interface TemplateIdentityComparison { - matches: boolean; - mismatch?: - | 'missing_template_id' - | 'invalid_template_id' - | 'invalid_expected_template_id' - | 'module_entity_mismatch' - | 'package_name_mismatch'; - actual?: ParsedTemplateIdentity; - expected?: ParsedTemplateIdentity; -} +type TemplateIdentityMismatch = + | 'missing_template_id' + | 'invalid_template_id' + | 'invalid_expected_template_id' + | 'module_entity_mismatch' + | 'package_name_mismatch'; + +export type TemplateIdentityComparison = + | Readonly<{ + matches: true; + actual: ParsedTemplateIdentity; + expected: ParsedTemplateIdentity; + }> + | Readonly<{ + matches: false; + mismatch: TemplateIdentityMismatch; + actual?: ParsedTemplateIdentity; + expected?: ParsedTemplateIdentity; + }>; export function parseTemplateIdentity(templateId: string): ParsedTemplateIdentity { if (templateId.length === 0) { @@ -41,10 +49,11 @@ export function parseTemplateIdentity(templateId: string): ParsedTemplateIdentit throw new Error('templateId must include non-empty package and module/entity path'); } + const packageName = packageRef.startsWith('#') ? packageRef.slice(1) : undefined; return { templateId, packageRef, - packageName: packageRef.startsWith('#') ? packageRef.slice(1) : undefined, + ...(packageName !== undefined ? { packageName } : {}), moduleEntityPath, }; } @@ -105,22 +114,28 @@ export function assertTemplateIdentity( ): ParsedTemplateIdentity { const comparison = compareTemplateIdentity(actual, expectedTemplateId); if (!comparison.matches) { + const actualTemplateId = typeof actual.templateId === 'string' ? actual.templateId : undefined; + const actualPackageName = typeof actual.packageName === 'string' ? actual.packageName : undefined; throw new OcpContractError(diagnostics.message ?? 'Contract template identity does not match expected template', { code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: diagnostics.contractId, - templateId: typeof actual.templateId === 'string' ? actual.templateId : undefined, - classification: comparison.mismatch ?? 'template_identity_mismatch', + ...(diagnostics.contractId !== undefined ? { contractId: diagnostics.contractId } : {}), + ...(actualTemplateId !== undefined ? { templateId: actualTemplateId } : {}), + classification: comparison.mismatch, context: { - operation: diagnostics.operation, - actualPackageName: typeof actual.packageName === 'string' ? actual.packageName : undefined, - actualTemplateId: typeof actual.templateId === 'string' ? actual.templateId : undefined, - actualModuleEntityPath: comparison.actual?.moduleEntityPath, + ...(diagnostics.operation !== undefined ? { operation: diagnostics.operation } : {}), + ...(actualPackageName !== undefined ? { actualPackageName } : {}), + ...(actualTemplateId !== undefined ? { actualTemplateId } : {}), + ...(comparison.actual !== undefined ? { actualModuleEntityPath: comparison.actual.moduleEntityPath } : {}), expectedTemplateId, - expectedPackageName: comparison.expected?.packageName, - expectedModuleEntityPath: comparison.expected?.moduleEntityPath, + ...(comparison.expected?.packageName !== undefined + ? { expectedPackageName: comparison.expected.packageName } + : {}), + ...(comparison.expected !== undefined + ? { expectedModuleEntityPath: comparison.expected.moduleEntityPath } + : {}), }, }); } - return comparison.actual as ParsedTemplateIdentity; + return comparison.actual; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 034babcd..2227fdab 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -332,7 +332,7 @@ export function damlMonetaryToNativeWithValidation(monetary: unknown, fieldPath if (error instanceof OcpValidationError) { throw new OcpValidationError(`${fieldPath}.amount`, 'Monetary amount must be a valid decimal string', { code: error.code, - expectedType: error.expectedType, + ...(error.expectedType !== undefined ? { expectedType: error.expectedType } : {}), receivedValue: error.receivedValue, }); } diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index a45d58b4..1dd642b1 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,17 +2,20 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { + applyCommandContext, authorizeIssuer, buildCreateIssuerCommand, CapTableBatch, OcpClient, OcpValidationError, withdrawAuthorization, + type AppliedCommandContext, type AuthorizeIssuerResult, type CapTableBatchExecuteResult, type CapTableBatchOperations, type CapTableBatchParams, type CapTableContractDetails, + type CommandContext, type ConversionTriggerFor, type ConvertibleConversionRight, type ConvertibleConversionTrigger, @@ -152,6 +155,42 @@ void withdrawAuthorization; declare const createIssuerParams: CreateIssuerParams; buildCreateIssuerCommand(createIssuerParams); +const paramsWithCallerMetadata = { + commands: [], + actAs: ['issuer::party'], + callerMetadata: 'preserved' as const, +}; +const contextualizedParams = applyCommandContext(paramsWithCallerMetadata); +const publishedContextUsesPlainResult: AppliedCommandContext = contextualizedParams; +const publishedContextKeysAreExact: Assert> = + true; +const publishedContextFieldsAreExact: Assert< + IsExactly, Pick> +> = true; +const publishedWorkflowId: string | undefined = contextualizedParams.workflowId; +const publishedActAs: string[] | undefined = contextualizedParams.actAs; +const publishedReadAs: string[] | undefined = contextualizedParams.readAs; + +const paramsWithLiteralCommandId = { + ...paramsWithCallerMetadata, + commandId: 'command-from-params' as const, +}; +const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { + context: { commandId: 'command-from-context' }, +}); +const publishedCommandId: string | undefined = contextualizedWithCommandOverride.commandId; +// @ts-expect-error Plain submit results do not promise arbitrary caller-specific members. +contextualizedParams.callerMetadata; +// @ts-expect-error Published applied submit fields are immutable at the top level. +contextualizedParams.commands = []; +void publishedContextUsesPlainResult; +void publishedContextKeysAreExact; +void publishedContextFieldsAreExact; +void publishedWorkflowId; +void publishedActAs; +void publishedReadAs; +void publishedCommandId; + // @ts-expect-error generated DAML wire unions are intentionally not root exports type RemovedGeneratedWireType = import('../../dist').OcfCreateData; declare const removedGeneratedWireType: RemovedGeneratedWireType; diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 55762a70..1484e13a 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -1,6 +1,8 @@ import { ENVIRONMENT_PRESETS, OcpNetworkError, + applyCommandContext, + type AppliedCommandContext, type AuthorizeIssuerParams, type CommandContext, type EnvironmentConfig, @@ -68,6 +70,20 @@ const errorStatusCodeIsRequired: IsOptional = fal const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; +const submitParamsWithHelper = { + commands: [], + actAs: ['issuer::party'], + readAs: ['reader::party'], + helper: () => 'caller-only', +}; +const appliedCommandContext = applyCommandContext(submitParamsWithHelper, { + context: { workflowId: 'workflow-from-context' }, +}); +const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; +const appliedCommands = appliedCommandContext.commands; +const appliedActAs: string[] | undefined = appliedCommandContext.actAs; +const appliedReadAs: string[] | undefined = appliedCommandContext.readAs; +const appliedContextContract: AppliedCommandContext = appliedCommandContext; // @ts-expect-error Built nested trace identifiers remain omission-only. const explicitUndefinedTraceId: CommandContext = { traceContext: { traceId: undefined } }; // @ts-expect-error Built nested span identifiers remain omission-only. @@ -154,6 +170,19 @@ client.observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Built nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; +// @ts-expect-error Built plain submit results do not promise caller-only input members. +appliedCommandContext.helper; +// @ts-expect-error Built applied command-context fields are immutable. +appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Built applied ledger submit fields are immutable at the top level. +appliedCommandContext.commands = []; +// @ts-expect-error Built applied optional context properties are omission-only. +const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; +const explicitUndefinedAppliedTraceId: AppliedCommandContext = { + commands: [], + // @ts-expect-error Built nested trace identifiers are omission-only too. + traceContext: { traceId: undefined }, +}; void validator; void factory; @@ -193,3 +222,10 @@ void resolved; void validationResult; void immutableDefaultContext; void immutableTraceMetadata; +void appliedWorkflowId; +void appliedCommands; +void appliedActAs; +void appliedReadAs; +void appliedContextContract; +void explicitUndefinedAppliedContext; +void explicitUndefinedAppliedTraceId; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 95d587ce..1f4549fb 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -14,6 +14,7 @@ import { } from '../../src/environment'; import { OcpNetworkError, type OcpValidationError } from '../../src/errors'; import type { AuthorizeIssuerParams } from '../../src/functions/OpenCapTable/issuerAuthorization/types'; +import { applyCommandContext, type AppliedCommandContext } from '../../src/observability'; import type { CommandContext, OcpObservabilityOptions } from '../../src/observabilityTypes'; import type { Assert, IsExactly, IsOptional } from '../typeContracts/typeAssertions'; @@ -77,6 +78,20 @@ const errorEndpointIsRequired: IsOptional = false; const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; +const submitParamsWithHelper = { + commands: [], + actAs: ['issuer::party'], + readAs: ['reader::party'], + helper: () => 'caller-only', +}; +const appliedCommandContext = applyCommandContext(submitParamsWithHelper, { + context: { workflowId: 'workflow-from-context' }, +}); +const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; +const appliedCommands = appliedCommandContext.commands; +const appliedActAs: string[] | undefined = appliedCommandContext.actAs; +const appliedReadAs: string[] | undefined = appliedCommandContext.readAs; +const appliedContextContract: AppliedCommandContext = appliedCommandContext; // @ts-expect-error Nested trace identifiers are omission-only under exact optional semantics. const explicitUndefinedTraceId: CommandContext = { traceContext: { traceId: undefined } }; // @ts-expect-error Nested trace span identifiers are omission-only under exact optional semantics. @@ -177,6 +192,19 @@ observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; +// @ts-expect-error A plain submit result does not promise caller-only input members. +appliedCommandContext.helper; +// @ts-expect-error Applied command-context fields are immutable. +appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Applied ledger submit fields are immutable at the top level. +appliedCommandContext.commands = []; +// @ts-expect-error Applied optional context properties are omission-only. +const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; +const explicitUndefinedAppliedTraceId: AppliedCommandContext = { + commands: [], + // @ts-expect-error Nested trace identifiers are omission-only too. + traceContext: { traceId: undefined }, +}; void oauthInput; void sharedSecretInput; @@ -215,3 +243,10 @@ void validationResult; void observability; void immutableDefaultContext; void immutableTraceMetadata; +void appliedWorkflowId; +void appliedCommands; +void appliedActAs; +void appliedReadAs; +void appliedContextContract; +void explicitUndefinedAppliedContext; +void explicitUndefinedAppliedTraceId; diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index 511414aa..39d2a322 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -31,9 +31,11 @@ describe('observability helpers', () => { const params = { commands: [], actAs: ['issuer::party'], + commandId: 'command-from-params' as const, + callerMetadata: 'preserved' as const, }; - const result = applyCommandContext(params as never, { + const result = applyCommandContext(params, { defaultContext: { workflowId: 'workflow-default', commandId: 'command-default', @@ -43,7 +45,9 @@ describe('observability helpers', () => { submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, }, - }) as Record; + }); + + const { commandId } = result; expect(result).toMatchObject({ workflowId: 'workflow-default', @@ -51,6 +55,155 @@ describe('observability helpers', () => { submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, }); + expect(result).not.toHaveProperty('callerMetadata'); + expect(commandId).toBe('command-call'); + }); + + it('projects canonical own data fields, strips unknown data members, and freezes the result', () => { + type SubmitParams = Parameters[0]; + type SubmitParamKey = keyof SubmitParams; + + const traceMetadata = { tenant: 'tenant-1' }; + const values: SubmitParams = { + commands: [], + commandId: 'command-1', + actAs: ['issuer::party'], + userId: 'user-1', + readAs: ['reader::party'], + workflowId: 'workflow-1', + deduplicationPeriod: { Empty: {} }, + minLedgerTimeAbs: '2026-07-12T00:00:00Z', + minLedgerTimeRel: { seconds: 5 }, + submissionId: 'submission-1', + traceContext: { + traceId: 'trace-1', + spanId: 'span-1', + parentSpanId: 'parent-span-1', + metadata: traceMetadata, + }, + disclosedContracts: [], + synchronizerId: 'synchronizer-1', + packageIdSelectionPreference: ['package-1'], + prefetchContractKeys: [], + }; + const canonicalKeys = Object.keys(values) as SubmitParamKey[]; + const params = { + callerMetadata: 'must-not-leak', + ownHelper: () => 'must-not-leak', + } as SubmitParams & { + callerMetadata: string; + ownHelper: () => string; + }; + for (const key of canonicalKeys) { + Object.defineProperty(params, key, { + configurable: false, + enumerable: key !== 'readAs', + value: values[key], + writable: false, + }); + } + + const result = applyCommandContext(params); + + expect(Object.keys(result).sort()).toEqual(canonicalKeys.sort()); + expect(result).toEqual({ + ...values, + traceContext: { + traceId: 'trace-1', + spanId: 'span-1', + parentSpanId: 'parent-span-1', + metadata: { tenant: 'tenant-1' }, + }, + }); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(result).not.toHaveProperty('callerMetadata'); + expect(result).not.toHaveProperty('ownHelper'); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.traceContext)).toBe(true); + expect(Object.isFrozen(result.traceContext?.metadata)).toBe(true); + + traceMetadata.tenant = 'mutated'; + expect(result.traceContext?.metadata).toEqual({ tenant: 'tenant-1' }); + }); + + it('rejects proxy, accessor, symbol, and inherited submit carriers without invoking traps', () => { + const proxyGet = jest.fn(() => []); + const proxy = new Proxy({ commands: [] }, { get: proxyGet }); + expect(() => applyCommandContext(proxy)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + expect(proxyGet).not.toHaveBeenCalled(); + + const commandsGetter = jest.fn(() => []); + const accessorParams = {} as Parameters[0]; + Object.defineProperty(accessorParams, 'commands', { enumerable: true, get: commandsGetter }); + expect(() => applyCommandContext(accessorParams)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.commands' }) + ); + expect(commandsGetter).not.toHaveBeenCalled(); + + const inheritedParams = Object.create({ commands: [] }) as Parameters[0]; + expect(() => applyCommandContext(inheritedParams)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + + const symbol = Symbol('hidden'); + expect(() => applyCommandContext({ commands: [], [symbol]: 'hidden' } as never)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + }); + + it('rejects trace accessors and explicit undefined optional submit fields without invoking accessors', () => { + const traceIdGetter = jest.fn(() => 'trace-1'); + const traceContext = {} as NonNullable[0]['traceContext']>; + Object.defineProperty(traceContext, 'traceId', { enumerable: true, get: traceIdGetter }); + expect(() => applyCommandContext({ commands: [], traceContext })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.traceContext.traceId' }) + ); + expect(traceIdGetter).not.toHaveBeenCalled(); + + expect(() => applyCommandContext({ commands: [], workflowId: undefined })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.workflowId' }) + ); + expect(() => applyCommandContext({ commands: [], traceContext: { traceId: undefined } })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.traceContext.traceId' }) + ); + expect(() => applyCommandContext({} as never)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.commands' }) + ); + }); + + it('applies params, default, and call context precedence without omitted fields clearing earlier values', () => { + const result = applyCommandContext( + { + commands: [], + actAs: ['params::party'], + workflowId: 'workflow-params', + commandId: 'command-params', + submissionId: 'submission-params', + traceContext: { traceId: 'trace-params' }, + }, + { + defaultContext: { + workflowId: 'workflow-default', + commandId: 'command-default', + submissionId: 'submission-default', + traceContext: { traceId: 'trace-default' }, + }, + context: { + workflowId: 'workflow-call', + submissionId: 'submission-call', + }, + } + ); + + expect(result).toMatchObject({ + actAs: ['params::party'], + workflowId: 'workflow-call', + commandId: 'command-default', + submissionId: 'submission-call', + traceContext: { traceId: 'trace-default' }, + }); }); it('merges trace context fields without discarding earlier identifiers', () => { diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts new file mode 100644 index 00000000..61ece9bc --- /dev/null +++ b/test/types/observability.types.ts @@ -0,0 +1,48 @@ +/** Compile-time contracts for the plain observability submit result. */ + +import { applyCommandContext, type AppliedCommandContext, type CommandContext } from '../../src'; +import type { Assert, IsExactly } from '../typeContracts/typeAssertions'; + +const paramsWithCallerMetadata = { + commands: [], + actAs: ['issuer::party'], + callerMetadata: 'preserved' as const, +}; + +const contextualizedParams = applyCommandContext(paramsWithCallerMetadata, { + context: { workflowId: 'workflow-1' }, +}); + +const sourceContextUsesPublicResult: AppliedCommandContext = contextualizedParams; +const sourceResultKeysAreExact: Assert> = + true; +const sourceContextFieldsAreExact: Assert< + IsExactly, Pick> +> = true; +const sourceWorkflowId: string | undefined = contextualizedParams.workflowId; +const sourceResultOmitsCallerMetadata: Assert< + IsExactly<'callerMetadata' extends keyof typeof contextualizedParams ? true : false, false> +> = true; + +const paramsWithLiteralCommandId = { + ...paramsWithCallerMetadata, + commandId: 'command-from-params' as const, +}; +const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { + context: { commandId: 'command-from-context' }, +}); +const sourceCommandId: string | undefined = contextualizedWithCommandOverride.commandId; + +// @ts-expect-error Arbitrary caller-specific members are not promised by a plain submit result. +contextualizedParams.callerMetadata; +// @ts-expect-error Applied command-context fields are immutable. +contextualizedParams.workflowId = 'mutated'; +// @ts-expect-error Applied ledger submit fields are immutable at the top level too. +contextualizedParams.commands = []; + +void sourceContextUsesPublicResult; +void sourceResultKeysAreExact; +void sourceContextFieldsAreExact; +void sourceWorkflowId; +void sourceResultOmitsCallerMetadata; +void sourceCommandId; diff --git a/tsconfig.exact-source.json b/tsconfig.exact-source.json new file mode 100644 index 00000000..b2c49bea --- /dev/null +++ b/tsconfig.exact-source.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}