diff --git a/packages/cre-sdk-examples/.env.example b/packages/cre-sdk-examples/.env.example index e10aed2a..fe668a14 100644 --- a/packages/cre-sdk-examples/.env.example +++ b/packages/cre-sdk-examples/.env.example @@ -13,7 +13,10 @@ CRE_ETH_PRIVATE_KEY=000000000000000000000000000000000000000000000000000000000000 CRE_TARGET=local-simulation # This one will be used in PoR workflow SECRET_ADDRESS_ALL=0x4700A50d858Cb281847ca4Ee0938F80DEfB3F1dd -# This one will be used in secrets workflow -SECRET_CHARACTER_ID=5 +# These will be used in secrets workflow +SECRET_URL_VALUE="https://swapi.info/api/people/{characterId}" +SECRET_CHARACTER_ID1=5 +SECRET_CHARACTER_ID2=6 +SECRET_CHARACTER_ID3=7 # Secret header value for HTTP trigger SECRET_HEADER_VALUE=abcd1234 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/package.json b/packages/cre-sdk-examples/package.json index fe67082a..6b954aad 100644 --- a/packages/cre-sdk-examples/package.json +++ b/packages/cre-sdk-examples/package.json @@ -1,7 +1,7 @@ { "name": "@chainlink/cre-sdk-examples", "private": true, - "version": "1.6.0", + "version": "1.7.0-alpha.1", "type": "module", "author": "Ernest Nowacki", "license": "BUSL-1.1", diff --git a/packages/cre-sdk-examples/secrets.yaml b/packages/cre-sdk-examples/secrets.yaml index 1691a194..871840e6 100644 --- a/packages/cre-sdk-examples/secrets.yaml +++ b/packages/cre-sdk-examples/secrets.yaml @@ -1,7 +1,13 @@ secretsNames: SECRET_ADDRESS: - SECRET_ADDRESS_ALL - CHARACTER_ID: - - SECRET_CHARACTER_ID + SECRET_URL: + - SECRET_URL_VALUE + CHARACTER_ID1: + - SECRET_CHARACTER_ID1 + CHARACTER_ID2: + - SECRET_CHARACTER_ID2 + CHARACTER_ID3: + - SECRET_CHARACTER_ID3 SECRET_HEADER: - SECRET_HEADER_VALUE diff --git a/packages/cre-sdk-examples/src/workflows/secrets/index.ts b/packages/cre-sdk-examples/src/workflows/secrets/index.ts index 7b7e7089..e6602f82 100644 --- a/packages/cre-sdk-examples/src/workflows/secrets/index.ts +++ b/packages/cre-sdk-examples/src/workflows/secrets/index.ts @@ -41,9 +41,10 @@ type StarWarsCharacter = z.infer const fetchStarWarsCharacter = ( sendRequester: HTTPSendRequester, config: Config, + url: string, characterId: string, ): StarWarsCharacter => { - const url = config.url.replace('{characterId}', characterId) + url = config.url.replace('{characterId}', characterId) const response = sendRequester.sendRequest({ url, method: 'GET' }).result() // Check if the response is successful using the helper function @@ -58,14 +59,31 @@ const fetchStarWarsCharacter = ( const onHTTPTrigger = async (runtime: Runtime) => { const httpCapability = new HTTPClient() - const characterId = runtime.getSecret({ id: 'CHARACTER_ID' }).result().value + // Fetch a single secret + const secretUrlValue = runtime.getSecret({ id: 'SECRET_URL' }).result().value + + // Fetch multiple secrets + const secretsToFetch = [{ id: 'CHARACTER_ID1' }, { id: 'CHARACTER_ID2' }, { id: 'CHARACTER_ID3' }] + const secretResponses = runtime.getSecrets(secretsToFetch).result() + const characterIds = secretResponses.flatMap((response) => + response.response.case === 'secret' && response.response.value?.id + ? [response.response.value.value] + : [], + ) + if (characterIds.length === 0) { + throw new Error('No character ID secrets available') + } + + // choose a random character id + // Math.random() is safe to use in the workflow + const characterId = characterIds[Math.floor(Math.random() * characterIds.length)] const result: StarWarsCharacter = httpCapability - .sendRequest( - runtime, - fetchStarWarsCharacter, - consensusIdenticalAggregation(), - )(runtime.config, characterId) + .sendRequest(runtime, fetchStarWarsCharacter, consensusIdenticalAggregation())( + runtime.config, + secretUrlValue, + characterId, + ) .result() return result diff --git a/packages/cre-sdk/package.json b/packages/cre-sdk/package.json index 90de2554..b3c3094e 100644 --- a/packages/cre-sdk/package.json +++ b/packages/cre-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/cre-sdk", - "version": "1.6.0", + "version": "1.7.0-alpha.1", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/cre-sdk/src/sdk/errors.ts b/packages/cre-sdk/src/sdk/errors.ts index 02fa2186..b3681532 100644 --- a/packages/cre-sdk/src/sdk/errors.ts +++ b/packages/cre-sdk/src/sdk/errors.ts @@ -26,6 +26,18 @@ export class SecretsError extends Error { } } +export class SecretsBatchError extends Error { + constructor( + public readonly secretRequests: SecretRequest[], + public readonly error: string, + ) { + super( + `batch secret retrieval failed for ${secretRequests.length} request(s): ${error}. Verify the host response is complete and that the workflow has access to the requested secrets`, + ) + this.name = 'SecretsBatchError' + } +} + export class NullReportError extends Error { constructor() { super('null report') 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..9e6384cb 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -48,7 +48,7 @@ import { Value, } from '@cre/sdk/utils' import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' -import { DonModeError, NodeModeError, SecretsError } from '../errors' +import { DonModeError, NodeModeError, SecretsBatchError, SecretsError } from '../errors' import { RESPONSE_BUFFER_TOO_SMALL } from '../testutils/test-runtime' import { type RuntimeHelpers, RuntimeImpl } from './runtime-impl' @@ -358,6 +358,158 @@ describe('test now converts to date', () => { }) describe('test getSecret', () => { + test('getSecrets returns ordered batched responses', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock((request) => { + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(2) + expect(request.requests[0].id).toEqual('secret-1') + expect(request.requests[1].id).toEqual('secret-2') + }), + awaitSecrets: mock((request) => { + expect(request.ids.length).toEqual(1) + expect(request.ids[0]).toEqual(1) + return create(AwaitSecretsResponseSchema, { + responses: { + 1: create(SecretResponsesSchema, { + responses: [ + create(SecretResponseSchema, { + response: { + case: 'secret', + value: { + id: 'secret-1', + namespace: 'ns', + owner: 'owner-1', + value: 'value-1', + }, + }, + }), + create(SecretResponseSchema, { + response: { + case: 'secret', + value: { + id: 'secret-2', + namespace: 'ns', + owner: 'owner-2', + value: 'value-2', + }, + }, + }), + ], + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const responses = runtime + .getSecrets([ + { id: 'secret-1', namespace: 'ns' }, + { id: 'secret-2', namespace: 'ns' }, + ]) + .result() + + expect(responses.length).toEqual(2) + expect(responses[0].response.case).toEqual('secret') + expect(responses[1].response.case).toEqual('secret') + if (responses[0].response.case === 'secret') { + expect(responses[0].response.value.id).toEqual('secret-1') + } + if (responses[1].response.case === 'secret') { + expect(responses[1].response.value.id).toEqual('secret-2') + } + }) + + test('getSecrets returns mixed success and error responses without throwing', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => { + return create(AwaitSecretsResponseSchema, { + responses: { + 1: create(SecretResponsesSchema, { + responses: [ + create(SecretResponseSchema, { + response: { + case: 'secret', + value: { + id: 'ok-secret', + namespace: 'ns', + owner: 'owner', + value: 'ok-value', + }, + }, + }), + create(SecretResponseSchema, { + response: { + case: 'error', + value: { + id: 'missing-secret', + namespace: 'ns', + owner: 'owner', + error: 'secret not found', + }, + }, + }), + ], + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const responses = runtime + .getSecrets([ + { id: 'ok-secret', namespace: 'ns' }, + { id: 'missing-secret', namespace: 'ns' }, + ]) + .result() + + expect(responses.length).toEqual(2) + expect(responses[0].response.case).toEqual('secret') + expect(responses[1].response.case).toEqual('error') + }) + + test('getSecrets throws SecretsBatchError when host getSecrets call fails', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => { + throw new Error('vault: signer unreachable') + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + expect(() => + runtime + .getSecrets([ + { id: 'secret-a', namespace: 'ns' }, + { id: 'secret-b', namespace: 'ns' }, + ]) + .result(), + ).toThrow(SecretsBatchError) + }) + + test('getSecrets throws SecretsBatchError for malformed batched response envelope', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => + create(AwaitSecretsResponseSchema, { + responses: {}, + }), + ), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + expect(() => + runtime + .getSecrets([ + { id: 'secret-a', namespace: 'ns' }, + { id: 'secret-b', namespace: 'ns' }, + ]) + .result(), + ).toThrow(SecretsBatchError) + }) + test('successfully gets secret with SecretRequest (proto message)', () => { const secretRequest = create(SecretRequestSchema, { id: 'my-secret', diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts index 6e016899..6b5db062 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts @@ -18,6 +18,7 @@ import { type SecretRequest, type SecretRequestJson, SecretRequestSchema, + type SecretResponse, SimpleConsensusInputsSchema, } from '@cre/generated/sdk/v1alpha/sdk_pb' import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' @@ -39,7 +40,7 @@ import { Value, } from '@cre/sdk/utils' import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' -import { DonModeError, NodeModeError, SecretsError } from '../errors' +import { DonModeError, NodeModeError, SecretsBatchError, SecretsError } from '../errors' /** * Base implementation shared by DON and Node runtimes. @@ -350,8 +351,8 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { } } - getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret + getSecrets(requests: Array): { + result: () => SecretResponse[] } { // Enforce mode restrictions if (this.modeError) { @@ -362,17 +363,24 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { } } - // Normalize request (accept both protobuf and JSON formats) - const secretRequest = (request as unknown as { $typeName?: string }).$typeName - ? (request as SecretRequest) - : create(SecretRequestSchema, request) + // Normalize requests (accept both protobuf and JSON formats) + const normalizedRequests = requests.map((request) => + (request as unknown as { $typeName?: string }).$typeName + ? (request as SecretRequest) + : create(SecretRequestSchema, request), + ) + if (normalizedRequests.length === 0) { + return { + result: () => [], + } + } // Allocate callback ID and send request const id = this.nextCallId this.nextCallId++ const secretsReq = create(GetSecretsRequestSchema, { callbackId: id, - requests: [secretRequest], + requests: normalizedRequests, }) try { @@ -381,45 +389,77 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { const message = err instanceof Error ? err.message : String(err) return { result: () => { - throw new SecretsError(secretRequest, message) + throw new SecretsBatchError(normalizedRequests, message) }, } } // Return lazy result return { - result: () => this.awaitAndUnwrapSecret(id, secretRequest), + result: () => this.awaitAndUnwrapSecrets(id, normalizedRequests), + } + } + + getSecret(request: SecretRequest | SecretRequestJson): { + result: () => Secret + } { + const secretRequest = (request as unknown as { $typeName?: string }).$typeName + ? (request as SecretRequest) + : create(SecretRequestSchema, request) + + const getSecretsCall = this.getSecrets([secretRequest]) + return { + result: () => { + let responseList: SecretResponse[] + try { + responseList = getSecretsCall.result() + } catch (err) { + if (err instanceof SecretsBatchError) { + throw new SecretsError(secretRequest, err.error) + } + throw err + } + + return this.unwrapSingleSecretResult(responseList, secretRequest) + }, } } - private awaitAndUnwrapSecret(id: number, secretRequest: SecretRequest): Secret { + private awaitAndUnwrapSecrets(id: number, requests: SecretRequest[]): SecretResponse[] { const awaitRequest = create(AwaitSecretsRequestSchema, { ids: [id] }) let awaitResponse: AwaitSecretsResponse try { awaitResponse = this.helpers.awaitSecrets(awaitRequest, this.maxResponseSize) } catch (err) { const message = err instanceof Error ? err.message : String(err) - throw new SecretsError(secretRequest, message) + throw new SecretsBatchError(requests, message) } const secretsResponse = awaitResponse.responses[id] if (!secretsResponse) { - throw new SecretsError(secretRequest, 'no response') + throw new SecretsBatchError(requests, 'no response') } - const responses = secretsResponse.responses - if (responses.length !== 1) { - throw new SecretsError(secretRequest, 'invalid value returned from host') + if (secretsResponse.responses.length !== requests.length) { + throw new SecretsBatchError(requests, 'invalid value returned from host') + } + + return secretsResponse.responses + } + + private unwrapSingleSecretResult(responseList: SecretResponse[], request: SecretRequest): Secret { + if (responseList.length !== 1) { + throw new SecretsError(request, 'invalid value returned from host') } - const response = responses[0].response + const response = responseList[0].response switch (response.case) { case 'secret': return response.value case 'error': - throw new SecretsError(secretRequest, response.value.error) + throw new SecretsError(request, response.value.error) default: - throw new SecretsError(secretRequest, 'cannot unmarshal returned value from host') + throw new SecretsError(request, 'cannot unmarshal returned value from host') } } diff --git a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts index c1d2b281..25beb109 100644 --- a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts @@ -187,6 +187,23 @@ describe('TestRuntime / helper layer', () => { expect(result.namespace).toBe('ns1') }) + test('helper getSecrets: batched call returns mixed secret/error responses', () => { + const secrets = new Map>() + secrets.set('ns1', new Map([['id1', 'val1']])) + const rt = newTestRuntime(secrets) + + const responses = rt + .getSecrets([ + { id: 'id1', namespace: 'ns1' }, + { id: 'missing', namespace: 'ns1' }, + ]) + .result() + + expect(responses.length).toBe(2) + expect(responses[0].response.case).toBe('secret') + expect(responses[1].response.case).toBe('error') + }) + test('helper getSecrets: secret not found returns error response', () => { const rt = newTestRuntime() expect(() => rt.getSecret({ id: 'missing', namespace: 'ns' }).result()).toThrow(SecretsError) diff --git a/packages/cre-sdk/src/sdk/wasm/runner.test.ts b/packages/cre-sdk/src/sdk/wasm/runner.test.ts index 7359b2dc..e1ea7ba3 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.test.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.test.ts @@ -295,11 +295,22 @@ describe('runner', () => { const dr = getTestRunner(subscribeRequest) await (await dr).run(async (_: string, secretsProvider: SecretsProvider) => { - const secret = await secretsProvider.getSecret({ namespace: 'Foo', id: 'Bar' }).result() - expect(secret.namespace).toBe('Foo') - expect(secret.id).toBe('Bar') - expect(secret.owner).toBe('Baz') - expect(secret.value).toBe('Qux') + const batched = await secretsProvider.getSecrets([{ namespace: 'Foo', id: 'Bar' }]).result() + expect(batched.length).toBe(1) + expect(batched[0].response.case).toBe('secret') + if (batched[0].response.case === 'secret') { + expect(batched[0].response.value.namespace).toBe('Foo') + expect(batched[0].response.value.id).toBe('Bar') + expect(batched[0].response.value.owner).toBe('Baz') + expect(batched[0].response.value.value).toBe('Qux') + } + + // Keep compatibility coverage for single-secret API. + const single = await secretsProvider.getSecret({ namespace: 'Foo', id: 'Bar' }).result() + expect(single.namespace).toBe('Foo') + expect(single.id).toBe('Bar') + expect(single.owner).toBe('Baz') + expect(single.value).toBe('Qux') return [cre.handler(basicTrigger.trigger({}), () => 10)] }) expect(true).toBe(true) diff --git a/packages/cre-sdk/src/sdk/wasm/runner.ts b/packages/cre-sdk/src/sdk/wasm/runner.ts index 35bec786..80b0767a 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.ts @@ -29,7 +29,7 @@ export class Runner { private static getRequest(): ExecuteRequest { const argsString = hostBindings.getWasiArgs() - let args + let args: any try { args = JSON.parse(argsString) } catch (e) { @@ -64,6 +64,7 @@ export class Runner { let result: Promise | ExecutionResult try { const workflow = await initFn(this.config, { + getSecrets: runtime.getSecrets.bind(runtime), getSecret: runtime.getSecret.bind(runtime), }) diff --git a/packages/cre-sdk/src/sdk/workflow.ts b/packages/cre-sdk/src/sdk/workflow.ts index f033a784..bfb87293 100644 --- a/packages/cre-sdk/src/sdk/workflow.ts +++ b/packages/cre-sdk/src/sdk/workflow.ts @@ -1,13 +1,12 @@ import type { Message } from '@bufbuild/protobuf' import type { - CapabilityResponse, Secret, SecretRequest, SecretRequestJson, + SecretResponse, } from '@cre/generated/sdk/v1alpha/sdk_pb' -import { type Runtime } from '@cre/sdk/runtime' +import type { Runtime } from '@cre/sdk/runtime' import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' -import type { SecretsError } from './errors' import type { CreSerializable } from './utils' export type HandlerFn = ( @@ -41,6 +40,9 @@ export const handler = < }) export type SecretsProvider = { + getSecrets(requests: Array): { + result: () => SecretResponse[] + } getSecret(request: SecretRequest | SecretRequestJson): { result: () => Secret }