diff --git a/bun.lock b/bun.lock index 5ddb7b0d..597653b7 100644 --- a/bun.lock +++ b/bun.lock @@ -237,7 +237,7 @@ "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], - "chain-selectors": ["chain-selectors@github:smartcontractkit/chain-selectors#3e718d1", {}, "smartcontractkit-chain-selectors-3e718d1"], + "chain-selectors": ["chain-selectors@github:smartcontractkit/chain-selectors#3e718d1", {}, "smartcontractkit-chain-selectors-3e718d1", "sha512-es+RbSudxuwfaxCBtfLLVxfO/Hd/aJ14FFHWH2ZshOISkAwcxyBcT5gZx08/sXK5NXiofxgZZEQCKmbBQFF4Dw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], diff --git a/packages/cre-sdk-examples/.env.example b/packages/cre-sdk-examples/.env.example index 64d40946..a6b7f280 100644 --- a/packages/cre-sdk-examples/.env.example +++ b/packages/cre-sdk-examples/.env.example @@ -9,11 +9,16 @@ ############################################################################### # Ethereum private key or 1Password reference (e.g. op://vault/item/field) CRE_ETH_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001 +# Solana 64-byte base58 keypair. +CRE_SOLANA_PRIVATE_KEY=1111111111111111111111111111111PPm2a2NNZH2EFJ5UkEjkH9Fcxn8cvjTmZDKQQisyLDmA # Profile to use for this environment (e.g. staging-settins, production-settings) CRE_TARGET=staging-settings # 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/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/main.ts b/packages/cre-sdk-examples/src/workflows/secrets/main.ts index 7b7e7089..16486d08 100644 --- a/packages/cre-sdk-examples/src/workflows/secrets/main.ts +++ b/packages/cre-sdk-examples/src/workflows/secrets/main.ts @@ -11,9 +11,7 @@ import { } from '@chainlink/cre-sdk' import { z } from 'zod' -const configSchema = z.object({ - url: z.string(), -}) +const configSchema = z.object({}) type Config = z.infer @@ -40,10 +38,10 @@ type StarWarsCharacter = z.infer const fetchStarWarsCharacter = ( sendRequester: HTTPSendRequester, - config: Config, + urlTemplate: string, characterId: string, ): StarWarsCharacter => { - const url = config.url.replace('{characterId}', characterId) + const url = urlTemplate.replace('{characterId}', characterId) const response = sendRequester.sendRequest({ url, method: 'GET' }).result() // Check if the response is successful using the helper function @@ -58,14 +56,24 @@ 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 — throws if any secret fails + const secretsToFetch = [{ id: 'CHARACTER_ID1' }, { id: 'CHARACTER_ID2' }, { id: 'CHARACTER_ID3' }] + const secrets = runtime.getSecrets(secretsToFetch).result() + const characterIds = secretsToFetch.map((secretRequest) => secrets[secretRequest.id].value) + + // 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) + )(secretUrlValue, characterId) .result() return result diff --git a/packages/cre-sdk/api-baseline.d.ts b/packages/cre-sdk/api-baseline.d.ts index ed39b73e..d0aed47e 100644 --- a/packages/cre-sdk/api-baseline.d.ts +++ b/packages/cre-sdk/api-baseline.d.ts @@ -74,7 +74,7 @@ export interface Runtime extends BaseRuntime, SecretsProvider { } import type { Message } from '@bufbuild/protobuf'; import type { Secret, SecretRequest, SecretRequestJson } 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 { CreSerializable } from './utils'; export type HandlerFn = (runtime: Runtime, triggerOutput: TTriggerOutput) => Promise> | CreSerializable; @@ -85,6 +85,9 @@ export interface HandlerEntry export type Workflow = ReadonlyArray>; export declare const handler: , TTriggerOutput, TConfig, TResult>(trigger: Trigger, fn: HandlerFn) => HandlerEntry; export type SecretsProvider = { + getSecrets(requests: Array): { + result: () => Record; + }; getSecret(request: SecretRequest | SecretRequestJson): { result: () => Secret; }; @@ -102,6 +105,11 @@ export declare class SecretsError extends Error { error: string; constructor(secretRequest: SecretRequest, error: string); } +export declare class SecretsBatchError extends Error { + readonly secretRequests: SecretRequest[]; + readonly error: string; + constructor(secretRequests: SecretRequest[], error: string); +} export declare class NullReportError extends Error { constructor(); } diff --git a/packages/cre-sdk/src/sdk/errors.ts b/packages/cre-sdk/src/sdk/errors.ts index e51db0d7..81ef11fc 100644 --- a/packages/cre-sdk/src/sdk/errors.ts +++ b/packages/cre-sdk/src/sdk/errors.ts @@ -29,6 +29,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):\n${error}\nVerify 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 52fac1eb..b8af61d3 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -54,7 +54,13 @@ import { median, Value, } from '@cre/sdk/utils' -import { CapabilityRuntimeError, DonModeError, NodeModeError, SecretsError } from '../errors' +import { + CapabilityRuntimeError, + DonModeError, + NodeModeError, + SecretsBatchError, + SecretsError, +} from '../errors' import { RESPONSE_BUFFER_TOO_SMALL } from '../testutils/test-runtime' import { type RuntimeHelpers, RuntimeImpl } from './runtime-impl' @@ -420,16 +426,13 @@ describe('test sleep delegates to helpers', () => { }) describe('test getSecret', () => { - test('successfully gets secret with SecretRequest (proto message)', () => { - const secretRequest = create(SecretRequestSchema, { - id: 'my-secret', - namespace: 'test-ns', - }) - + test('getSecrets returns dict of secrets keyed by id', () => { const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { expect(request.callbackId).toEqual(1) - expect(request.requests.length).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) @@ -442,10 +445,21 @@ describe('test getSecret', () => { response: { case: 'secret', value: { - id: 'my-secret', - namespace: 'test-ns', - owner: 'test-owner', - value: 'secret-value-123', + 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', }, }, }), @@ -457,23 +471,50 @@ describe('test getSecret', () => { }) const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const result = runtime.getSecret(secretRequest).result() - expect(result.id).toEqual('my-secret') - expect(result.namespace).toEqual('test-ns') - expect(result.value).toEqual('secret-value-123') - }) + const secrets = runtime + .getSecrets([ + { id: 'secret-1', namespace: 'ns' }, + { id: 'secret-2', namespace: 'ns' }, + ]) + .result() - test('successfully gets secret with SecretRequestJson (plain JSON)', () => { - const secretRequestJson = { id: 'another-secret', namespace: 'another-ns' } + expect(Object.keys(secrets)).toHaveLength(2) + expect(secrets['secret-1'].id).toEqual('secret-1') + expect(secrets['secret-1'].value).toEqual('value-1') + expect(secrets['secret-2'].id).toEqual('secret-2') + expect(secrets['secret-2'].value).toEqual('value-2') + }) + test('getSecrets throws SecretsBatchError for duplicate secret ids', () => { const helpers = createRuntimeHelpersMock({ - getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1) - expect(request.requests.length).toEqual(1) + getSecrets: mock(() => { + throw new Error('host should not be called for duplicate ids') }), - awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1) - expect(request.ids[0]).toEqual(1) + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + expect(() => + runtime + .getSecrets([ + { id: 'same-id', namespace: 'ns1' }, + { id: 'same-id', namespace: 'ns2' }, + ]) + .result(), + ).toThrow(SecretsBatchError) + expect(() => + runtime + .getSecrets([ + { id: 'same-id', namespace: 'ns1' }, + { id: 'same-id', namespace: 'ns2' }, + ]) + .result(), + ).toThrow('duplicate secret id requested: same-id') + }) + + test('getSecrets throws SecretsBatchError when any secret fails', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => { return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { @@ -482,10 +523,21 @@ describe('test getSecret', () => { response: { case: 'secret', value: { - id: 'another-secret', - namespace: 'another-ns', - owner: 'another-owner', - value: 'value-456', + 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', }, }, }), @@ -497,10 +549,71 @@ describe('test getSecret', () => { }) const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const result = runtime.getSecret(secretRequestJson).result() - expect(result.id).toEqual('another-secret') - expect(result.namespace).toEqual('another-ns') - expect(result.value).toEqual('value-456') + try { + runtime + .getSecrets([ + { id: 'ok-secret', namespace: 'ns' }, + { id: 'missing-secret', namespace: 'ns' }, + ]) + .result() + throw new Error('expected getSecrets to throw') + } catch (err) { + expect(err).toBeInstanceOf(SecretsBatchError) + expect((err as SecretsBatchError).message).toContain('missing-secret: secret not found') + } + }) + + test('getSecrets throws SecretsBatchError with all error messages when multiple secrets fail', () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => { + return create(AwaitSecretsResponseSchema, { + responses: { + 1: create(SecretResponsesSchema, { + responses: [ + create(SecretResponseSchema, { + response: { + case: 'error', + value: { + id: 'missing-a', + namespace: 'ns', + owner: 'owner', + error: 'not found', + }, + }, + }), + create(SecretResponseSchema, { + response: { + case: 'error', + value: { + id: 'missing-b', + namespace: 'ns', + owner: 'owner', + error: 'access denied', + }, + }, + }), + ], + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + try { + runtime + .getSecrets([ + { id: 'missing-a', namespace: 'ns' }, + { id: 'missing-b', namespace: 'ns' }, + ]) + .result() + throw new Error('expected getSecrets to throw') + } catch (err) { + expect(err).toBeInstanceOf(SecretsBatchError) + expect((err as SecretsBatchError).message).toContain('missing-a: not found') + expect((err as SecretsBatchError).message).toContain('missing-b: access denied') + } }) test('normalizes missing secret namespace to default for JSON and protobuf requests', () => { @@ -541,6 +654,128 @@ describe('test getSecret', () => { expect(observedNamespaces).toEqual(['main', 'main']) }) + 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', + namespace: 'test-ns', + }) + + const helpers = createRuntimeHelpersMock({ + getSecrets: mock((request) => { + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(1) + }), + 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: 'my-secret', + namespace: 'test-ns', + owner: 'test-owner', + value: 'secret-value-123', + }, + }, + }), + ], + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime.getSecret(secretRequest).result() + expect(result.id).toEqual('my-secret') + expect(result.namespace).toEqual('test-ns') + expect(result.value).toEqual('secret-value-123') + }) + + test('successfully gets secret with SecretRequestJson (plain JSON)', () => { + const secretRequestJson = { id: 'another-secret', namespace: 'another-ns' } + + const helpers = createRuntimeHelpersMock({ + getSecrets: mock((request) => { + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(1) + }), + 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: 'another-secret', + namespace: 'another-ns', + owner: 'another-owner', + value: 'value-456', + }, + }, + }), + ], + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime.getSecret(secretRequestJson).result() + expect(result.id).toEqual('another-secret') + expect(result.namespace).toEqual('another-ns') + expect(result.value).toEqual('value-456') + }) + test('getSecrets throws → wrapped as SecretsError', () => { const secretRequest = create(SecretRequestSchema, { id: 'test-secret', @@ -677,7 +912,10 @@ describe('test getSecret', () => { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: 'error', value: { error: errorMessage } }, + response: { + case: 'error', + value: { id: 'test-secret', error: errorMessage }, + }, }), ], }), @@ -688,7 +926,7 @@ describe('test getSecret', () => { const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, errorMessage), + new SecretsError(secretRequest, 'test-secret: secret not found'), ) }) diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts index 8198e416..d037de6e 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts @@ -19,6 +19,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,13 @@ import { type UnwrapOptions, Value, } from '@cre/sdk/utils' -import { CapabilityRuntimeError, DonModeError, NodeModeError, SecretsError } from '../errors' +import { + CapabilityRuntimeError, + DonModeError, + NodeModeError, + SecretsBatchError, + SecretsError, +} from '../errors' const DEFAULT_SECRET_NAMESPACE = 'main' @@ -349,8 +356,8 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { } } - getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret + getSecrets(requests: Array): { + result: () => Record } { // Enforce mode restrictions if (this.modeError) { @@ -361,17 +368,46 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { } } - const secretRequest = create(SecretRequestSchema, { - id: request.id, - namespace: request.namespace || DEFAULT_SECRET_NAMESPACE, - }) + // Normalize requests (accept both protobuf and JSON formats) + const normalizedRequests = requests.map((request) => + (request as unknown as { $typeName?: string }).$typeName + ? create(SecretRequestSchema, { + id: (request as SecretRequest).id, + namespace: (request as SecretRequest).namespace || DEFAULT_SECRET_NAMESPACE, + }) + : create(SecretRequestSchema, { + id: request.id, + namespace: request.namespace || DEFAULT_SECRET_NAMESPACE, + }), + ) + if (normalizedRequests.length === 0) { + return { + result: () => ({}), + } + } + + // Reject duplicate ids since the response is keyed by id + const seenIds = new Set() + for (const request of normalizedRequests) { + if (seenIds.has(request.id)) { + return { + result: () => { + throw new SecretsBatchError( + normalizedRequests, + `duplicate secret id requested: ${request.id}`, + ) + }, + } + } + seenIds.add(request.id) + } // Allocate callback ID and send request const id = this.nextCallId this.nextCallId++ const secretsReq = create(GetSecretsRequestSchema, { callbackId: id, - requests: [secretRequest], + requests: normalizedRequests, }) try { @@ -380,46 +416,95 @@ 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), } } - private awaitAndUnwrapSecret(id: number, secretRequest: SecretRequest): Secret { + 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 secretMap: Record + try { + secretMap = getSecretsCall.result() + } catch (err) { + if (err instanceof SecretsBatchError) { + throw new SecretsError(secretRequest, err.error) + } + throw err + } + + return this.unwrapSingleSecretResult(secretMap, secretRequest) + }, + } + } + + private awaitAndUnwrapSecrets(id: number, requests: SecretRequest[]): Record { 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') } - const response = responses[0].response - switch (response.case) { - case 'secret': - return response.value - case 'error': - throw new SecretsError(secretRequest, response.value.error) - default: - throw new SecretsError(secretRequest, 'cannot unmarshal returned value from host') + const failedResponses = secretsResponse.responses.filter( + (response) => response.response.case === 'error', + ) + if (failedResponses.length > 0) { + const errorMessages = failedResponses.map((response) => { + if (response.response.case !== 'error') { + return 'unknown: unknown error' + } + const { id, error } = response.response.value + return `${id || 'unknown'}: ${error || 'unknown error'}` + }) + throw new SecretsBatchError(requests, errorMessages.join('\n')) + } + + const result: Record = {} + for (let i = 0; i < requests.length; i++) { + const response = secretsResponse.responses[i] + if (response.response.case !== 'secret') { + throw new SecretsBatchError(requests, 'cannot unmarshal returned value from host') + } + result[requests[i].id] = response.response.value + } + return result + } + + private unwrapSingleSecretResult( + secretMap: Record, + request: SecretRequest, + ): Secret { + const secret = secretMap[request.id] + if (!secret) { + throw new SecretsError(request, 'invalid value returned from host') } + return secret } /** 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 3fbc6eb6..f6213d9f 100644 --- a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts @@ -8,7 +8,7 @@ import { create } from '@bufbuild/protobuf' import { AnySchema } from '@bufbuild/protobuf/wkt' import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen' import { consensusMedianAggregation } from '@cre/sdk/utils' -import { CapabilityRuntimeError, SecretsError } from '../errors' +import { CapabilityRuntimeError, SecretsBatchError, SecretsError } from '../errors' import { BasicTestActionMock } from '../test/generated/capabilities/internal/basicaction/v1/basic_test_action_mock_gen' import { __testOnlyRegistryStore, @@ -202,6 +202,51 @@ describe('TestRuntime / helper layer', () => { expect(result.namespace).toBe('ns1') }) + test('helper getSecrets: batched call returns secrets keyed by id', () => { + const secrets = new Map>() + secrets.set( + 'ns1', + new Map([ + ['id1', 'val1'], + ['id2', 'val2'], + ]), + ) + const rt = newTestRuntime(secrets) + + const result = rt + .getSecrets([ + { id: 'id1', namespace: 'ns1' }, + { id: 'id2', namespace: 'ns1' }, + ]) + .result() + expect(Object.keys(result)).toHaveLength(2) + expect(result['id1'].value).toBe('val1') + expect(result['id2'].value).toBe('val2') + }) + + test('helper getSecrets: batched call throws SecretsBatchError when any secret fails', () => { + const secrets = new Map>() + secrets.set('ns1', new Map([['id1', 'val1']])) + const rt = newTestRuntime(secrets) + + expect(() => + rt + .getSecrets([ + { id: 'id1', namespace: 'ns1' }, + { id: 'missing', namespace: 'ns1' }, + ]) + .result(), + ).toThrow(SecretsBatchError) + expect(() => + rt + .getSecrets([ + { id: 'id1', namespace: 'ns1' }, + { id: 'missing', namespace: 'ns1' }, + ]) + .result(), + ).toThrow('missing:') + }) + 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 4ff3edd7..d1098150 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.test.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.test.ts @@ -298,11 +298,19 @@ 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(Object.keys(batched)).toHaveLength(1) + expect(batched['Bar'].namespace).toBe('Foo') + expect(batched['Bar'].id).toBe('Bar') + expect(batched['Bar'].owner).toBe('Baz') + expect(batched['Bar'].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..96c92725 100644 --- a/packages/cre-sdk/src/sdk/workflow.ts +++ b/packages/cre-sdk/src/sdk/workflow.ts @@ -1,13 +1,7 @@ import type { Message } from '@bufbuild/protobuf' -import type { - CapabilityResponse, - Secret, - SecretRequest, - SecretRequestJson, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import { type Runtime } from '@cre/sdk/runtime' +import type { Secret, SecretRequest, SecretRequestJson } from '@cre/generated/sdk/v1alpha/sdk_pb' +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 +35,9 @@ export const handler = < }) export type SecretsProvider = { + getSecrets(requests: Array): { + result: () => Record + } getSecret(request: SecretRequest | SecretRequestJson): { result: () => Secret }