diff --git a/packages/cre-sdk-examples/.gitignore b/packages/cre-sdk-examples/.gitignore index 5f97ba11..386df487 100644 --- a/packages/cre-sdk-examples/.gitignore +++ b/packages/cre-sdk-examples/.gitignore @@ -2,4 +2,5 @@ node_modules dist .turbo tmp.js -tmp.wasm \ No newline at end of file +tmp.wasm +.cre_build_tmp.js \ No newline at end of file diff --git a/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/config.json b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/config.json new file mode 100644 index 00000000..6b8dce3c --- /dev/null +++ b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/config.json @@ -0,0 +1,4 @@ +{ + "schedule": "0 */1 * * * *", + "url": "https://api.mathjs.org/v4?expr=randomInt(1,101)" +} diff --git a/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/index.ts b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/index.ts new file mode 100644 index 00000000..7d2d9cf0 --- /dev/null +++ b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/index.ts @@ -0,0 +1,79 @@ +import { + ConfidentialHTTPClient, + CronCapability, + handler, + httpRequest, + json, + ok, + Runner, + type Runtime, + text, +} from '@chainlink/cre-sdk' +import { z } from 'zod' + +const configSchema = z.object({ + schedule: z.string(), + url: z.string(), +}) +type Config = z.infer + +// Workflow demonstrate a usage of `httpRequest` helper +// to build type-safe payloads for `ConfidentialHTTPClient`. +const onCronTrigger = (runtime: Runtime) => { + const client = new ConfidentialHTTPClient() + + // Example 1: request config as separate variable + const separateRequestConfig = { + request: httpRequest({ + url: runtime.config.url, + method: 'POST', + bodyString: '{ hello: "world" }', + multiHeaders: { + 'content-type': { values: ['application/json'] }, + }, + }), + } + + client.sendRequest(runtime, separateRequestConfig).result() + + // Example 2: using helper inline + client + .sendRequest(runtime, { + request: httpRequest({ + url: runtime.config.url, + method: 'POST', + body: { hello: 'world' }, + multiHeaders: { + 'content-type': { values: ['application/json'] }, + }, + }), + }) + .result() + + // Example 3: not using helper at all + client + .sendRequest(runtime, { + request: { + url: runtime.config.url, + method: 'POST', + // no helper -> must use bodyString/bodyBytes (proto oneof keys) + bodyString: JSON.stringify({ hello: 'world' }), + multiHeaders: { + 'content-type': { values: ['application/json'] }, + }, + }, + }) + .result() + + return { success: true } +} + +const initWorkflow = (config: Config) => { + const cron = new CronCapability() + return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] +} + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} diff --git a/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/workflow.yaml b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/workflow.yaml new file mode 100644 index 00000000..47a0ca82 --- /dev/null +++ b/packages/cre-sdk-examples/src/workflows/confidential-http-with-body/workflow.yaml @@ -0,0 +1,38 @@ +# ========================================================================== +# CRE WORKFLOW SETTINGS FILE +# ========================================================================== +# This file defines environment-specific workflow settings used by the CRE CLI. +# +# Each top-level key is a target (e.g., `production`, `production-testnet`, etc.). +# You can also define your own custom targets, such as `my-target`, and +# point the CLI to it via an environment variable. +# +# Note: If any setting in this file conflicts with a setting in the CRE Project Settings File, +# the value defined here in the workflow settings file will take precedence. +# +# Below is an example `my-target`: +# +# my-target: +# user-workflow: +# # Optional: The address of the workflow owner (wallet or MSIG contract). +# # Used to establish ownership for encrypting the workflow's secrets. +# # If omitted, defaults to an empty string. +# workflow-owner-address: "0x1234567890abcdef1234567890abcdef12345678" +# +# # Required: The name of the workflow to register with the Workflow Registry contract. +# workflow-name: "MyExampleWorkflow" + +# ========================================================================== +local-simulation: + user-workflow: + workflow-owner-address: "(optional) Multi-signature contract address" + workflow-name: "confidential-http-with-body" + workflow-artifacts: + workflow-path: "./index.ts" + config-path: "./config.json" + +# ========================================================================== +production-testnet: + user-workflow: + workflow-owner-address: "(optional) Multi-signature contract address" + workflow-name: "confidential-http-with-body" diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen.ts index 1c6d0ce1..d9e6e69f 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen.ts @@ -59,6 +59,7 @@ import type { Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import { hexToBytes } from '@cre/sdk/utils/hex-utils' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { CapabilityInput, NoExcess } from '@cre/sdk/utils/types/no-excess' export type WriteCreReportRequest = { receiver: Uint8Array @@ -180,6 +181,10 @@ export class ClientCapability { constructor(private readonly ChainSelector: bigint) {} + callContract( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => CallContractReply } callContract( runtime: Runtime, input: CallContractRequest | CallContractRequestJson, @@ -215,6 +220,10 @@ export class ClientCapability { } } + filterLogs( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => FilterLogsReply } filterLogs( runtime: Runtime, input: FilterLogsRequest | FilterLogsRequestJson, @@ -250,6 +259,10 @@ export class ClientCapability { } } + balanceAt( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => BalanceAtReply } balanceAt( runtime: Runtime, input: BalanceAtRequest | BalanceAtRequestJson, @@ -285,6 +298,10 @@ export class ClientCapability { } } + estimateGas( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => EstimateGasReply } estimateGas( runtime: Runtime, input: EstimateGasRequest | EstimateGasRequestJson, @@ -320,6 +337,10 @@ export class ClientCapability { } } + getTransactionByHash( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => GetTransactionByHashReply } getTransactionByHash( runtime: Runtime, input: GetTransactionByHashRequest | GetTransactionByHashRequestJson, @@ -361,6 +382,10 @@ export class ClientCapability { } } + getTransactionReceipt( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => GetTransactionReceiptReply } getTransactionReceipt( runtime: Runtime, input: GetTransactionReceiptRequest | GetTransactionReceiptRequestJson, @@ -402,6 +427,10 @@ export class ClientCapability { } } + headerByNumber( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => HeaderByNumberReply } headerByNumber( runtime: Runtime, input: HeaderByNumberRequest | HeaderByNumberRequestJson, @@ -437,12 +466,23 @@ export class ClientCapability { } } - logTrigger(config: FilterLogTriggerRequestJson): ClientLogTrigger { + logTrigger( + config: NoExcess, + ): ClientLogTrigger { // Include all labels in capability ID for routing when specified const capabilityId = `${ClientCapability.CAPABILITY_NAME}:ChainSelector:${this.ChainSelector}@${ClientCapability.CAPABILITY_VERSION}` - return new ClientLogTrigger(config, capabilityId, 'LogTrigger', this.ChainSelector) + return new ClientLogTrigger( + config as FilterLogTriggerRequestJson, + capabilityId, + 'LogTrigger', + this.ChainSelector, + ) } + writeReport( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => WriteReportReply } writeReport( runtime: Runtime, input: WriteCreReportRequest | WriteCreReportRequestJson, diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen.ts index 630b8277..8adbe0e9 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen.ts @@ -16,6 +16,7 @@ import type { Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import { hexToBytes } from '@cre/sdk/utils/hex-utils' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { CapabilityInput, NoExcess } from '@cre/sdk/utils/types/no-excess' /** * Basic Capability @@ -31,6 +32,10 @@ export class BasicCapability { static readonly CAPABILITY_NAME = 'basic-test-action-trigger' static readonly CAPABILITY_VERSION = '1.0.0' + action( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => Output } action(runtime: Runtime, input: Input | InputJson): { result: () => Output } { // Handle input conversion - unwrap if it's a wrapped type, convert from JSON if needed let payload: Input @@ -62,9 +67,9 @@ export class BasicCapability { } } - trigger(config: ConfigJson): BasicTrigger { + trigger(config: NoExcess): BasicTrigger { const capabilityId = BasicCapability.CAPABILITY_ID - return new BasicTrigger(config, capabilityId, 'Trigger') + return new BasicTrigger(config as ConfigJson, capabilityId, 'Trigger') } } diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen.ts index 81e07d89..cc101acb 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen.ts @@ -9,6 +9,7 @@ import { import type { Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import { hexToBytes } from '@cre/sdk/utils/hex-utils' +import type { CapabilityInput } from '@cre/sdk/utils/types/no-excess' /** * BasicAction Capability @@ -24,6 +25,10 @@ export class BasicActionCapability { static readonly CAPABILITY_NAME = 'basic-test-action' static readonly CAPABILITY_VERSION = '1.0.0' + performAction( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => Outputs } performAction(runtime: Runtime, input: Inputs | InputsJson): { result: () => Outputs } { // Handle input conversion - unwrap if it's a wrapped type, convert from JSON if needed let payload: Inputs diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen.ts index 47790d03..eda99178 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen.ts @@ -8,6 +8,7 @@ import { OutputsSchema, } from '@cre/generated/capabilities/internal/basictrigger/v1/basic_trigger_pb' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { NoExcess } from '@cre/sdk/utils/types/no-excess' /** * Basic Capability @@ -23,9 +24,9 @@ export class BasicCapability { static readonly CAPABILITY_NAME = 'basic-test-trigger' static readonly CAPABILITY_VERSION = '1.0.0' - trigger(config: ConfigJson): BasicTrigger { + trigger(config: NoExcess): BasicTrigger { const capabilityId = BasicCapability.CAPABILITY_ID - return new BasicTrigger(config, capabilityId, 'Trigger') + return new BasicTrigger(config as ConfigJson, capabilityId, 'Trigger') } } diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen.ts index 2077be10..c4fdb2ee 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen.ts @@ -13,6 +13,7 @@ import { type Value, ValueSchema } from '@cre/generated/values/v1/values_pb' import type { Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import { hexToBytes } from '@cre/sdk/utils/hex-utils' +import type { CapabilityInput } from '@cre/sdk/utils/types/no-excess' /** * Consensus Capability @@ -28,6 +29,10 @@ export class ConsensusCapability { static readonly CAPABILITY_NAME = 'consensus' static readonly CAPABILITY_VERSION = '1.0.0-alpha' + simple( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => Value } simple( runtime: Runtime, input: SimpleConsensusInputs | SimpleConsensusInputsJson, @@ -62,6 +67,10 @@ export class ConsensusCapability { } } + report( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => Report } report( runtime: Runtime, input: ReportRequest | ReportRequestJson, diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen.ts index 3046dfc2..82364fbb 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen.ts @@ -9,14 +9,17 @@ import { import type { NodeRuntime, Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import type { ConsensusAggregation, PrimitiveTypes, UnwrapOptions } from '@cre/sdk/utils' +import type { CapabilityInput } from '@cre/sdk/utils/types/no-excess' export class PerformActioner { constructor( private readonly runtime: NodeRuntime, private readonly client: BasicActionCapability, ) {} - performAction(input: NodeInputs | NodeInputsJson): { result: () => NodeOutputs } { - return this.client.performAction(this.runtime, input) + performAction(input: CapabilityInput): { + result: () => NodeOutputs + } { + return this.client.performAction(this.runtime, input) } } @@ -34,9 +37,9 @@ export class BasicActionCapability { static readonly CAPABILITY_NAME = 'basic-test-node-action' static readonly CAPABILITY_VERSION = '1.0.0' - performAction( + performAction( runtime: NodeRuntime, - input: NodeInputs | NodeInputsJson, + input: CapabilityInput, ): { result: () => NodeOutputs } performAction( runtime: Runtime, diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/networking/confidentialhttp/v1alpha/client_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/networking/confidentialhttp/v1alpha/client_sdk_gen.ts index 110fc54b..04435c4d 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/networking/confidentialhttp/v1alpha/client_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/networking/confidentialhttp/v1alpha/client_sdk_gen.ts @@ -9,6 +9,7 @@ import { import type { Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import { hexToBytes } from '@cre/sdk/utils/hex-utils' +import type { CapabilityInput } from '@cre/sdk/utils/types/no-excess' /** * Client Capability @@ -24,6 +25,10 @@ export class ClientCapability { static readonly CAPABILITY_NAME = 'confidential-http' static readonly CAPABILITY_VERSION = '1.0.0-alpha' + sendRequest( + runtime: Runtime, + input: CapabilityInput, + ): { result: () => HTTPResponse } sendRequest( runtime: Runtime, input: ConfidentialHTTPRequest | ConfidentialHTTPRequestJson, diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/client_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/client_sdk_gen.ts index 69beaf7b..ddb29b2d 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/client_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/client_sdk_gen.ts @@ -9,14 +9,17 @@ import { import type { NodeRuntime, Runtime } from '@cre/sdk' import { Report } from '@cre/sdk/report' import type { ConsensusAggregation, PrimitiveTypes, UnwrapOptions } from '@cre/sdk/utils' +import type { CapabilityInput } from '@cre/sdk/utils/types/no-excess' export class SendRequester { constructor( private readonly runtime: NodeRuntime, private readonly client: ClientCapability, ) {} - sendRequest(input: Request | RequestJson): { result: () => Response } { - return this.client.sendRequest(this.runtime, input) + sendRequest(input: CapabilityInput): { + result: () => Response + } { + return this.client.sendRequest(this.runtime, input) } } @@ -34,9 +37,9 @@ export class ClientCapability { static readonly CAPABILITY_NAME = 'http-actions' static readonly CAPABILITY_VERSION = '1.0.0-alpha' - sendRequest( + sendRequest( runtime: NodeRuntime, - input: Request | RequestJson, + input: CapabilityInput, ): { result: () => Response } sendRequest( runtime: Runtime, diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/http_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/http_sdk_gen.ts index 37547938..7009a670 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/http_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/networking/http/v1alpha/http_sdk_gen.ts @@ -8,6 +8,7 @@ import { PayloadSchema, } from '@cre/generated/capabilities/networking/http/v1alpha/trigger_pb' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { NoExcess } from '@cre/sdk/utils/types/no-excess' /** * HTTP Capability @@ -23,9 +24,9 @@ export class HTTPCapability { static readonly CAPABILITY_NAME = 'http-trigger' static readonly CAPABILITY_VERSION = '1.0.0-alpha' - trigger(config: ConfigJson): HTTPTrigger { + trigger(config: NoExcess): HTTPTrigger { const capabilityId = HTTPCapability.CAPABILITY_ID - return new HTTPTrigger(config, capabilityId, 'Trigger') + return new HTTPTrigger(config as ConfigJson, capabilityId, 'Trigger') } } diff --git a/packages/cre-sdk/src/generated-sdk/capabilities/scheduler/cron/v1/cron_sdk_gen.ts b/packages/cre-sdk/src/generated-sdk/capabilities/scheduler/cron/v1/cron_sdk_gen.ts index 9d158212..ad6e2d94 100644 --- a/packages/cre-sdk/src/generated-sdk/capabilities/scheduler/cron/v1/cron_sdk_gen.ts +++ b/packages/cre-sdk/src/generated-sdk/capabilities/scheduler/cron/v1/cron_sdk_gen.ts @@ -10,6 +10,7 @@ import { PayloadSchema, } from '@cre/generated/capabilities/scheduler/cron/v1/trigger_pb' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { NoExcess } from '@cre/sdk/utils/types/no-excess' /** * Cron Capability @@ -25,9 +26,9 @@ export class CronCapability { static readonly CAPABILITY_NAME = 'cron-trigger' static readonly CAPABILITY_VERSION = '1.0.0' - trigger(config: ConfigJson): CronTrigger { + trigger(config: NoExcess): CronTrigger { const capabilityId = CronCapability.CAPABILITY_ID - return new CronTrigger(config, capabilityId, 'Trigger') + return new CronTrigger(config as ConfigJson, capabilityId, 'Trigger') } } diff --git a/packages/cre-sdk/src/generator/generate-action.ts b/packages/cre-sdk/src/generator/generate-action.ts index 1915f6c5..a9635b03 100644 --- a/packages/cre-sdk/src/generator/generate-action.ts +++ b/packages/cre-sdk/src/generator/generate-action.ts @@ -29,6 +29,7 @@ export function generateActionMethod( const inputTypes = hasWrappedInput ? [wrappedInputType.name, `${wrappedInputType.name}Json`] : [method.input.name, `${method.input.name}Json`] + const [nativeInputType, jsonInputType] = inputTypes // Build output type const hasWrappedOutput = wrappedOutputType !== method.output @@ -40,8 +41,16 @@ export function generateActionMethod( ? 'Report' : method.output.name - const callSig = `(runtime: ${modePrefix}Runtime, input: ${inputTypes.join(' | ')}): {result: () => ${outputType}}` - const callSigAndBody = `${callSig} { + // Public-facing signature uses CapabilityInput, a conditional that + // branches on the `$typeName` brand: + // - native protobuf message -> pass-through + // - JSON shape -> wrapped in NoExcess so unknown keys (e.g. plain + // `body` instead of `bodyString`) fail at the call boundary even + // when the user lifts the request object into a variable. + const callSig = `(runtime: ${modePrefix}Runtime, input: CapabilityInput): {result: () => ${outputType}}` + // Internal impl signature - widest, accepts either form. + const implSig = `(runtime: ${modePrefix}Runtime, input: ${nativeInputType} | ${jsonInputType}): {result: () => ${outputType}}` + const callSigAndBody = `${implSig} { // Handle input conversion - unwrap if it's a wrapped type, convert from JSON if needed let payload: ${method.input.name} ${ @@ -126,5 +135,6 @@ export function generateActionMethod( }` } return ` + ${methodName}${callSig} ${methodName}${callSigAndBody}` } diff --git a/packages/cre-sdk/src/generator/generate-sdk.ts b/packages/cre-sdk/src/generator/generate-sdk.ts index 196d7f0e..3a0c5bda 100644 --- a/packages/cre-sdk/src/generator/generate-sdk.ts +++ b/packages/cre-sdk/src/generator/generate-sdk.ts @@ -108,6 +108,18 @@ export function generateSdk(file: GenFile, outputDir: string) { imports.add(`import { type Any, AnySchema, anyPack } from "@bufbuild/protobuf/wkt"`) } + // Gate JSON input shapes at every capability boundary. Actions use the + // $typeName-aware CapabilityInput; triggers (JSON-only configs) use + // NoExcess directly. Import only what each file actually references. + const noExcessImports: string[] = [] + if (hasActions) noExcessImports.push('CapabilityInput') + if (hasTriggers) noExcessImports.push('NoExcess') + if (noExcessImports.length > 0) { + imports.add( + `import type { ${noExcessImports.join(', ')} } from "@cre/sdk/utils/types/no-excess"`, + ) + } + if (hasActions) { if (modePrefix !== '') { imports.add(`import type { Runtime, ${modePrefix}Runtime } from "@cre/sdk"`) diff --git a/packages/cre-sdk/src/generator/generate-sugar.ts b/packages/cre-sdk/src/generator/generate-sugar.ts index 75394f8e..05154005 100644 --- a/packages/cre-sdk/src/generator/generate-sugar.ts +++ b/packages/cre-sdk/src/generator/generate-sugar.ts @@ -22,8 +22,8 @@ export function generateActionSugarClass( return ` export class ${sugarClassName} { constructor(private readonly runtime: NodeRuntime, private readonly client: ${capabilityClassName}) {} - ${methodName}(input: ${method.input.name} | ${method.input.name}Json): {result: () => ${outputType}} { - return this.client.${methodName}(this.runtime, input) + ${methodName}(input: CapabilityInput): {result: () => ${outputType}} { + return this.client.${methodName}(this.runtime, input) } }` } diff --git a/packages/cre-sdk/src/generator/generate-trigger.ts b/packages/cre-sdk/src/generator/generate-trigger.ts index 72dc566b..bad85a5d 100644 --- a/packages/cre-sdk/src/generator/generate-trigger.ts +++ b/packages/cre-sdk/src/generator/generate-trigger.ts @@ -26,9 +26,9 @@ export function generateTriggerMethod( labels.length > 0 ? ', ' + labels.map((label) => `this.${label.name}`).join(', ') : '' return ` - ${methodName}(config: ${method.input.name}Json): ${triggerClassName} { + ${methodName}(config: NoExcess): ${triggerClassName} { ${capabilityIdLogic} - return new ${triggerClassName}(config, capabilityId, "${method.name}"${labelArgs}); + return new ${triggerClassName}(config as ${method.input.name}Json, capabilityId, "${method.name}"${labelArgs}); }` } diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts index 45f566b1..fcd54467 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -662,7 +662,7 @@ describe('test getSecret', () => { test('getSecret in node mode throws DonModeError', () => { const helpers = createRuntimeHelpersMock() - ConsensusCapability.prototype.simple = mock(() => { + ;(ConsensusCapability.prototype as any).simple = mock(() => { return { result: () => Value.from(0).proto() } }) @@ -695,7 +695,7 @@ describe('test run in node mode', () => { }), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { expect(modes).toEqual([Mode.DON, Mode.NODE, Mode.DON]) expect(inputs.default).toBeUndefined() @@ -770,7 +770,7 @@ describe('test run in node mode', () => { switchModes: mock((_: Mode) => {}), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { expect(inputs.default).toBeUndefined() expect(inputs.descriptors).toEqual( @@ -804,7 +804,7 @@ describe('test run in node mode', () => { switchModes: mock((_: Mode) => {}), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { const inputsProto = inputs as SimpleConsensusInputs expect(inputsProto.observation.case).toEqual('value') @@ -837,7 +837,7 @@ describe('test run in node mode', () => { switchModes: mock((_: Mode) => {}), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { const inputsProto = inputs as SimpleConsensusInputs expect(inputsProto.observation.case).toEqual('error') @@ -869,7 +869,7 @@ describe('test run in node mode', () => { }), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { return { result: () => Value.from(0).proto() } }, @@ -896,7 +896,7 @@ describe('test run in node mode', () => { switchModes: mock((_: Mode) => {}), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { expect(inputs.default).toBeUndefined() expect(inputs.descriptors).toEqual( @@ -948,7 +948,7 @@ describe('test run in node mode', () => { }), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { return { result: () => Value.from(create(NodeOutputsSchema, { outputThing: 42 })).proto(), @@ -1026,7 +1026,7 @@ describe('test run in node mode', () => { switchModes: mock((_: Mode) => {}), }) - ConsensusCapability.prototype.simple = mock( + ;(ConsensusCapability.prototype as any).simple = mock( (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { const inputsProto = inputs as SimpleConsensusInputs if (inputsProto.observation.case === 'value') { diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts index 6e016899..f2b4ebf8 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts @@ -428,7 +428,8 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { */ report(input: ReportRequest | ReportRequestJson): { result: () => Report } { const consensus = new ConsensusCapability() - const call = consensus.report(this, input) + // Cast to native overload signature - the impl dispatches on $typeName. + const call = consensus.report(this, input as ReportRequest) return { result: () => call.result(), } diff --git a/packages/cre-sdk/src/sdk/index.ts b/packages/cre-sdk/src/sdk/index.ts index 9ca2643e..66ee7b37 100644 --- a/packages/cre-sdk/src/sdk/index.ts +++ b/packages/cre-sdk/src/sdk/index.ts @@ -7,6 +7,7 @@ export * from './runtime' export * from './types/bufbuild-types' export * from './utils' // Export HTTP response helpers +export * from './utils/capabilities/confidentialhttp/confidential-http-helpers' export * from './utils/capabilities/http/http-helpers' export * from './wasm' export * from './workflow' diff --git a/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.test.ts b/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.test.ts new file mode 100644 index 00000000..39065205 --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from 'bun:test' +import { httpRequest } from './confidential-http-helpers' + +describe('httpRequest', () => { + test('coerces string body to bodyString', () => { + const r = httpRequest({ url: 'https://x', method: 'POST', body: 'hello' }) + expect(r.bodyString).toBe('hello') + expect(r.bodyBytes).toBeUndefined() + }) + + test('coerces Uint8Array body to base64-encoded bodyBytes', () => { + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]) + const r = httpRequest({ url: 'https://x', method: 'POST', body: bytes }) + expect(r.bodyBytes).toBe('3q2+7w==') + expect(r.bodyString).toBeUndefined() + }) + + test('coerces object body to JSON-stringified bodyString', () => { + const r = httpRequest({ + url: 'https://x', + method: 'POST', + body: { hello: 'world', n: 42 }, + }) + expect(r.bodyString).toBe('{"hello":"world","n":42}') + expect(r.bodyBytes).toBeUndefined() + }) + + test('passes bodyString through verbatim', () => { + const r = httpRequest({ + url: 'https://x', + bodyString: '{"already":"json"}', + }) + expect(r.bodyString).toBe('{"already":"json"}') + expect(r.bodyBytes).toBeUndefined() + }) + + test('encodes bodyBytes Uint8Array to base64', () => { + const r = httpRequest({ + url: 'https://x', + bodyBytes: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + }) + expect(r.bodyBytes).toBe('3q2+7w==') + expect(r.bodyString).toBeUndefined() + }) + + test('passes already-base64 bodyBytes string through verbatim', () => { + const r = httpRequest({ url: 'https://x', bodyBytes: '3q2+7w==' }) + expect(r.bodyBytes).toBe('3q2+7w==') + expect(r.bodyString).toBeUndefined() + }) + + test('throws when more than one body field is supplied', () => { + const errMsg = + 'httpRequest: specify the request body using only one of: body, bodyString or bodyBytes' + expect(() => httpRequest({ url: 'https://x', body: 'a', bodyString: 'b' })).toThrow(errMsg) + expect(() => httpRequest({ url: 'https://x', bodyString: 'a', bodyBytes: 'b' })).toThrow(errMsg) + expect(() => + httpRequest({ + url: 'https://x', + body: 'a', + bodyBytes: new Uint8Array([1]), + }), + ).toThrow(errMsg) + }) + + test('wraps object body JSON serialization errors', () => { + const body: { self?: unknown } = {} + body.self = body + + expect(() => { + httpRequest({ + url: 'https://x', + method: 'POST', + body, + }) + }).toThrow('httpRequest: failed to serialize body as JSON') + }) + + test('omits body fields when no body provided', () => { + const r = httpRequest({ url: 'https://x' }) + expect(r.bodyString).toBeUndefined() + expect(r.bodyBytes).toBeUndefined() + expect(r.method).toBe('GET') + }) + + test('maps single-value headers into multiHeaders', () => { + const r = httpRequest({ + url: 'https://x', + headers: { 'content-type': 'application/json' }, + }) + expect(r.multiHeaders).toEqual({ + 'content-type': { values: ['application/json'] }, + }) + }) + + test('maps repeated-value headers into multiHeaders', () => { + const r = httpRequest({ + url: 'https://x', + headers: { 'set-cookie': ['a=1', 'b=2'] }, + }) + expect(r.multiHeaders).toEqual({ + 'set-cookie': { values: ['a=1', 'b=2'] }, + }) + }) + + test('passes native multiHeaders shape through', () => { + const r = httpRequest({ + url: 'https://x', + multiHeaders: { + 'set-cookie': { values: ['a=1', 'b=2'] }, + 'x-trace': { values: ['abc'] }, + }, + }) + expect(r.multiHeaders).toEqual({ + 'set-cookie': { values: ['a=1', 'b=2'] }, + 'x-trace': { values: ['abc'] }, + }) + }) + + test('headers override multiHeaders entries with same name; others retained', () => { + const r = httpRequest({ + url: 'https://x', + multiHeaders: { + 'content-type': { values: ['text/plain'] }, + 'x-trace': { values: ['abc'] }, + }, + headers: { 'content-type': 'application/json' }, + }) + expect(r.multiHeaders).toEqual({ + 'content-type': { values: ['application/json'] }, + 'x-trace': { values: ['abc'] }, + }) + }) + + test('passes templateValues through to templatePublicValues', () => { + const r = httpRequest({ + url: 'https://x', + templateValues: { region: 'us-east' }, + }) + expect(r.templatePublicValues).toEqual({ region: 'us-east' }) + }) + + test('passes timeout through verbatim', () => { + const r = httpRequest({ url: 'https://x', timeout: '30s' }) + expect(r.timeout).toBe('30s') + }) + + test('omits timeout when not supplied', () => { + const r = httpRequest({ url: 'https://x' }) + expect(r.timeout).toBeUndefined() + }) + + test('sets encryptOutput only when truthy', () => { + expect(httpRequest({ url: 'x', encryptOutput: true }).encryptOutput).toBe(true) + expect(httpRequest({ url: 'x' }).encryptOutput).toBeUndefined() + }) +}) diff --git a/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.ts b/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.ts new file mode 100644 index 00000000..d25eb0d9 --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/capabilities/confidentialhttp/confidential-http-helpers.ts @@ -0,0 +1,112 @@ +import type { DurationJson } from '@bufbuild/protobuf/wkt' +import type { + HTTPRequestJson as ConfidentialHTTPRequestJson, + HeaderValuesJson, +} from '@cre/generated/capabilities/networking/confidentialhttp/v1alpha/client_pb' + +/** + * Build an HTTPRequest JSON shape for the confidential-http capability. + * + * The proto defines `oneof body { string body_string = 3; bytes body_bytes = 8 }`, + * so the JSON wire keys are `bodyString` / `bodyBytes` — never plain `body`. + * Three mutually-exclusive ways to set the body, in order of preference: + * + * - `body` ergonomic; coerces by runtime type: + * - `string` -> bodyString (verbatim) + * - `Uint8Array` -> bodyBytes (base64) + * - object -> JSON.stringify -> bodyString + * (bigints as strings) + * - `bodyString` pass-through to the canonical proto field + * - `bodyBytes` `Uint8Array` is base64-encoded; a `string` is treated as + * already-encoded base64 and passed through verbatim + * + * Supplying more than one of these fields throws — the underlying proto is a + * oneof, so the call would otherwise be ambiguous. + * + * Headers can be passed in two ways: + * - `headers`: flat record with single or repeated values, mapped onto the + * `multiHeaders` shape under the hood. + * - `multiHeaders`: native proto shape `{ [name]: { values: string[] } }` — + * useful when forwarding headers already in canonical form. + * + * If both are supplied, `multiHeaders` is applied first and `headers` entries + * merge on top per-name (replacing the values list for that name). + */ +export interface HttpRequestOptions { + url: string + method?: string + body?: string | Uint8Array | object + bodyString?: string + bodyBytes?: Uint8Array | string + headers?: Record + multiHeaders?: Record + templateValues?: Record + timeout?: DurationJson + encryptOutput?: boolean +} + +export function httpRequest(opts: HttpRequestOptions): ConfidentialHTTPRequestJson { + const out: ConfidentialHTTPRequestJson = { + url: opts.url, + method: opts.method ?? 'GET', + } + + const bodyFieldsSet = + (opts.body !== undefined ? 1 : 0) + + (opts.bodyString !== undefined ? 1 : 0) + + (opts.bodyBytes !== undefined ? 1 : 0) + if (bodyFieldsSet > 1) { + throw new Error( + 'httpRequest: specify the request body using only one of: body, bodyString or bodyBytes', + ) + } + + if (opts.bodyString !== undefined) { + out.bodyString = opts.bodyString + } else if (opts.bodyBytes !== undefined) { + out.bodyBytes = + typeof opts.bodyBytes === 'string' + ? opts.bodyBytes + : Buffer.from(opts.bodyBytes).toString('base64') + } else if (typeof opts.body === 'string') { + out.bodyString = opts.body + } else if (opts.body instanceof Uint8Array) { + out.bodyBytes = Buffer.from(opts.body).toString('base64') + } else if (opts.body !== undefined) { + // Compact JSON encoding; bigints serialise as strings. + try { + out.bodyString = JSON.stringify(opts.body, (_k, v) => + typeof v === 'bigint' ? v.toString() : v, + ) + } catch (cause) { + throw new Error('httpRequest: failed to serialize body as JSON', { + cause, + }) + } + } + + if (opts.multiHeaders || opts.headers) { + const merged: Record = {} + for (const [name, value] of Object.entries(opts.multiHeaders ?? {})) { + merged[name] = { values: value.values ?? [] } + } + for (const [name, value] of Object.entries(opts.headers ?? {})) { + merged[name] = { values: Array.isArray(value) ? value : [value] } + } + out.multiHeaders = merged + } + + if (opts.templateValues) { + out.templatePublicValues = { ...opts.templateValues } + } + + if (opts.timeout) { + out.timeout = opts.timeout + } + + if (opts.encryptOutput) { + out.encryptOutput = true + } + + return out +} diff --git a/packages/cre-sdk/src/sdk/utils/capabilities/http/http-helpers.ts b/packages/cre-sdk/src/sdk/utils/capabilities/http/http-helpers.ts index f2c14fc1..94f061bc 100644 --- a/packages/cre-sdk/src/sdk/utils/capabilities/http/http-helpers.ts +++ b/packages/cre-sdk/src/sdk/utils/capabilities/http/http-helpers.ts @@ -203,7 +203,8 @@ function sendReport( ): { result: () => Response } { const rawReport = report.x_generatedCodeOnly_unwrap() const request = fn(rawReport) - return this.sendRequest(runtime, request) + // Cast to native overload signature - the impl dispatches on $typeName. + return this.sendRequest(runtime, request as Request) } /** @@ -223,7 +224,8 @@ function sendRequesterSendReport( ): { result: () => Response } { const rawReport = report.x_generatedCodeOnly_unwrap() const request = fn(rawReport) - return this.sendRequest(request) + // Cast to native overload signature - the impl dispatches on $typeName. + return this.sendRequest(request as Request) } // ============================================================================ diff --git a/packages/cre-sdk/src/sdk/utils/types/no-excess.test.ts b/packages/cre-sdk/src/sdk/utils/types/no-excess.test.ts new file mode 100644 index 00000000..f8ff230d --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/types/no-excess.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test' +import type { NoExcess } from './no-excess' + +// Compile-time tests. The runtime expects a single passing case so bun:test +// counts the file. The interesting assertions live in @ts-expect-error lines. +describe('NoExcess', () => { + test('rejects unknown top-level keys', () => { + type Shape = { url: string; method: string } + + const ok: NoExcess<{ url: string; method: string }, Shape> = { + url: 'x', + method: 'POST', + } + expect(ok.url).toBe('x') + + const bad: NoExcess<{ url: string; method: string; extra: string }, Shape> = { + url: 'x', + method: 'POST', + // @ts-expect-error 'extra' is not in Shape -> mapped to never + extra: 'no', + } + expect(bad).toBeDefined() + }) + + test('rejects unknown keys in nested objects', () => { + type HttpReq = { url: string; bodyString?: string } + type Wrapper = { request: HttpReq } + + const ok: NoExcess<{ request: { url: string; bodyString: string } }, Wrapper> = { + request: { url: 'x', bodyString: 'hi' }, + } + expect(ok.request.url).toBe('x') + + const bad: NoExcess< + { request: { url: string; body: { hello: string } } }, + Wrapper + // @ts-expect-error nested 'body' is not in HttpReq -> mapped to never + > = { request: { url: 'x', body: { hello: 'world' } } } + expect(bad).toBeDefined() + }) + + test('allows arbitrary keys inside index-signature maps', () => { + type HeaderValues = { values: string[] } + type Shape = { multiHeaders: { [k: string]: HeaderValues } } + + const ok: NoExcess< + { multiHeaders: { 'x-custom': HeaderValues; anything: HeaderValues } }, + Shape + > = { + multiHeaders: { + 'x-custom': { values: ['a'] }, + anything: { values: ['b'] }, + }, + } + expect(Object.keys(ok.multiHeaders)).toHaveLength(2) + }) +}) diff --git a/packages/cre-sdk/src/sdk/utils/types/no-excess.ts b/packages/cre-sdk/src/sdk/utils/types/no-excess.ts new file mode 100644 index 00000000..acdb02e2 --- /dev/null +++ b/packages/cre-sdk/src/sdk/utils/types/no-excess.ts @@ -0,0 +1,44 @@ +/** + * NoExcess rejects any property in T whose key is not present in + * Shape, while preserving the values of allowed properties. + * + * Native TS structural typing only triggers excess-property checks on object + * literals at the call site. Once a request object is bound to a variable + * its excess keys are tolerated. NoExcess closes that gap by mapping any + * unknown key to `never`, which fails to assign at the boundary. + * + * Recursion stops at: + * - primitives / Uint8Array / Date (`Shape[K]` not an indexable object), and + * - index-signature maps (every string key is "known"; nothing is excess). + * + * Depth bound is set to 6 to keep the tsc work bounded for nested protos. + */ +export type NoExcess = Depth extends 0 + ? T + : T extends object + ? Shape extends object + ? IsIndexed extends true + ? T + : { + [K in keyof T]: K extends keyof Shape + ? NoExcess, Prev> + : never + } + : T + : T + +type IsIndexed = string extends keyof T ? true : false + +type Prev = [-1, 0, 1, 2, 3, 4, 5, 6][N] + +/** + * Pick the right shape for a capability input. + * + * Native protobuf messages carry a `$typeName` brand; JSON shapes do not. + * If the caller passes a native message we leave it untouched. Otherwise we + * apply NoExcess against the JSON shape so unknown keys (a stale `body` + * field, a typo, ...) fail at the call boundary. + */ +export type CapabilityInput = [TInput] extends [{ $typeName: string }] + ? Native + : NoExcess