diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md new file mode 100644 index 0000000000..0fee1366e7 --- /dev/null +++ b/.changeset/lucky-pandas-listen.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Authenticate `deploymentId: "latest"` with the deployment's own OIDC identity instead of an ambient `VERCEL_TOKEN`, and scope the request to the configured team, fixing spurious 404s when resolving the latest deployment diff --git a/.changeset/project-step-provenance.md b/.changeset/project-step-provenance.md new file mode 100644 index 0000000000..960839e74e --- /dev/null +++ b/.changeset/project-step-provenance.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Allow trace callers to add product-specific attributes to event-derived step spans. diff --git a/docs/content/docs/v4/api-reference/workflow-api/start.mdx b/docs/content/docs/v4/api-reference/workflow-api/start.mdx index 5d6a701ac8..96cf752b60 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/start.mdx @@ -104,6 +104,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development. + +Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it +needs an identity that can see the calling deployment. Inside a Vercel +deployment the SDK authenticates with the deployment's own OIDC token, which +carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in +the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no +team, so authenticating with it scopes the lookup to that user's default team +and fails with a 404 whenever that is not the team that owns the deployment. +Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used; +configure the World's `teamId` so the request is scoped explicitly. + + When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility: diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx index 0fe6e443ec..c8c70e1f11 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx @@ -108,6 +108,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development. + +Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it +needs an identity that can see the calling deployment. Inside a Vercel +deployment the SDK authenticates with the deployment's own OIDC token, which +carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in +the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no +team, so authenticating with it scopes the lookup to that user's default team +and fails with a 404 whenever that is not the team that owns the deployment. +Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used; +configure the World's `teamId` so the request is scoped explicitly. + + When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility: diff --git a/packages/web-shared/src/components/trace-viewer.tsx b/packages/web-shared/src/components/trace-viewer.tsx index b64da197ec..612d3ec046 100644 --- a/packages/web-shared/src/components/trace-viewer.tsx +++ b/packages/web-shared/src/components/trace-viewer.tsx @@ -8,6 +8,7 @@ import { } from './sidebar/sidebar-data-context'; import { TraceViewerSkeleton } from './trace-viewer/components/trace-viewer-skeleton'; import { TraceViewer as TraceViewerComponent } from './trace-viewer/trace-viewer'; +import type { GetStepAttributes } from './workflow-traces/trace-span-construction'; const TraceViewer = ({ run, @@ -17,6 +18,7 @@ const TraceViewer = ({ hasMore, isLoadingMore, loading = false, + getStepAttributes, }: { run: WorkflowRun; events: Event[]; @@ -25,6 +27,8 @@ const TraceViewer = ({ hasMore?: boolean; isLoadingMore?: boolean; loading?: boolean; + /** Adds product-specific attributes to event-derived step span data. */ + getStepAttributes?: GetStepAttributes; }) => { const trace: TraceWithMeta | undefined = useMemo(() => { if (!run?.runId) { @@ -35,9 +39,10 @@ const TraceViewer = ({ // repeats with the whole log in hand. return buildTrace(run, events, new Date(), { isCompleteHistory: !hasMore, + getStepAttributes, }); // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep - }, [run, events, hasMore]); + }, [run, events, hasMore, getStepAttributes]); // The sidebar shows one entity's slice of the log, so it takes the trace's // answer rather than recomputing one from the slice. diff --git a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts index c71448712e..876477df2c 100644 --- a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts +++ b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts @@ -150,6 +150,10 @@ export function waitToSpan( }; } +export type GetStepAttributes = ( + events: Event[] +) => Record | undefined; + export const stepEventsToStepEntity = ( events: Event[] ): { @@ -231,7 +235,11 @@ export const stepEventsToStepEntity = ( /** * Converts step events to an OpenTelemetry Span */ -export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null { +export function stepToSpan( + stepEvents: Event[], + maxEndTime: Date, + getStepAttributes?: GetStepAttributes +): Span | null { const step = stepEventsToStepEntity(stepEvents); if (!step) { return null; @@ -242,7 +250,11 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null { const attributes = { resource: 'step' as const, - data: step, + data: { + ...getStepAttributes?.(stepEvents), + // Canonical event-derived fields cannot be overridden by extensions. + ...step, + }, }; const resource = 'step'; diff --git a/packages/web-shared/src/lib/trace-builder.test.ts b/packages/web-shared/src/lib/trace-builder.test.ts index 8de5282d24..00dae7c556 100644 --- a/packages/web-shared/src/lib/trace-builder.test.ts +++ b/packages/web-shared/src/lib/trace-builder.test.ts @@ -9,7 +9,11 @@ let nextId = 0; function event( eventType: EventType, - options: { correlationId?: string; at: number } + options: { + correlationId?: string; + at: number; + externalAttemptId?: string; + } ): Event { nextId += 1; return { @@ -20,6 +24,7 @@ function event( createdAt: new Date(BASE_TIME + options.at * 1000), occurredAt: new Date(BASE_TIME + options.at * 1000), eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {}, + externalAttemptId: options.externalAttemptId, } as unknown as Event; } @@ -31,6 +36,42 @@ const run = { } as unknown as WorkflowRun; describe('buildTrace', () => { + it('adds caller-derived attributes to step span data', () => { + const events = [ + event('run_created', { at: 0 }), + event('run_started', { at: 0 }), + event('step_created', { correlationId: 'step_a', at: 1 }), + event('step_started', { + correlationId: 'step_a', + at: 2, + externalAttemptId: 'attempt_first', + }), + event('step_retrying', { correlationId: 'step_a', at: 3 }), + event('step_started', { + correlationId: 'step_a', + at: 4, + externalAttemptId: 'attempt_latest', + }), + ]; + + const trace = buildTrace(run, events, new Date(BASE_TIME + 5000), { + getStepAttributes(stepEvents) { + const latestStart = stepEvents + .slice() + .reverse() + .find((candidate) => candidate.eventType === 'step_started') as + | (Event & { externalAttemptId?: string }) + | undefined; + return { externalAttemptId: latestStart?.externalAttemptId }; + }, + }); + const stepSpan = trace.spans.find((span) => span.resource === 'step'); + + expect(stepSpan?.attributes.data).toMatchObject({ + externalAttemptId: 'attempt_latest', + }); + }); + it('ends a step span on the terminal event the run acted on', () => { const events = [ event('run_created', { at: 0 }), diff --git a/packages/web-shared/src/lib/trace-builder.ts b/packages/web-shared/src/lib/trace-builder.ts index ec671ebc66..f4015d40ee 100644 --- a/packages/web-shared/src/lib/trace-builder.ts +++ b/packages/web-shared/src/lib/trace-builder.ts @@ -14,6 +14,7 @@ import { type WorkflowRun, } from '@workflow/world'; import { + type GetStepAttributes, getEventTimestamp, hookToSpan, runToSpan, @@ -137,7 +138,8 @@ function buildSpans( run: WorkflowRun, groupedEvents: GroupedEvents, now: Date, - latestKnownTime: Date + latestKnownTime: Date, + getStepAttributes?: GetStepAttributes ) { // Active child spans cap at latestKnownTime so they don't extend into // unknown territory. Even when the run is completed, we may not have loaded @@ -146,7 +148,7 @@ function buildSpans( const runMaxEnd = run.completedAt ?? now; const stepSpans = Array.from(groupedEvents.eventsByStepId.values()) - .map((events) => stepToSpan(events, childMaxEnd)) + .map((events) => stepToSpan(events, childMaxEnd, getStepAttributes)) .filter((span): span is Span => span !== null); const hookSpans = Array.from(groupedEvents.hookEvents.values()) @@ -208,7 +210,13 @@ export function buildTrace( * from the only copy the caller was given, and dropping the wrong one moves * a span. See {@link findDuplicateEventIds}. */ - { isCompleteHistory = false }: { isCompleteHistory?: boolean } = {} + { + isCompleteHistory = false, + getStepAttributes, + }: { + isCompleteHistory?: boolean; + getStepAttributes?: GetStepAttributes; + } = {} ): TraceWithMeta { // Span geometry comes from what the run acted on. A repeat of a class the // log already records is read past by every replay, and letting one through @@ -232,7 +240,8 @@ export function buildTrace( run, groupedEvents, now, - latestKnownTime + latestKnownTime, + getStepAttributes ); const sortedCascadingSpans = cascadeSpans(runSpan, spans); diff --git a/packages/world-vercel/src/resolve-latest-deployment.test.ts b/packages/world-vercel/src/resolve-latest-deployment.test.ts index e89ac9608a..f9f4081855 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.test.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.test.ts @@ -154,6 +154,129 @@ describe('createResolveLatestDeploymentId', () => { expect(headers.get('Authorization')).toBe('Bearer oidc-token-456'); }); + it('prefers the OIDC token over VERCEL_TOKEN inside the Vercel runtime', async () => { + // The shape that broke production: a deployed function whose environment + // also carries a VERCEL_TOKEN for unrelated tooling. The token is a user + // credential and carries no team, so authenticating with it scopes the + // lookup to that user's default team and 404s. + process.env.VERCEL = '1'; + process.env.VERCEL_TOKEN = 'user-token-wrong-team'; + + const { getVercelOidcToken } = await import('@vercel/oidc'); + vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789'); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_latest_from_oidc' }), { + status: 200, + }) + ); + + const result = await createResolveLatestDeploymentId({})(); + + expect(result).toBe('dpl_latest_from_oidc'); + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer oidc-token-789'); + }); + + it('keeps using VERCEL_TOKEN outside the Vercel runtime', async () => { + // CLI and CI callers export VERCEL_TOKEN deliberately and have no + // deployment identity to prefer, so the original order stands there. + delete process.env.VERCEL; + process.env.VERCEL_TOKEN = 'env-token-123'; + + const { getVercelOidcToken } = await import('@vercel/oidc'); + vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789'); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 }) + ); + + await createResolveLatestDeploymentId({})(); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer env-token-123'); + }); + + it('still prefers an explicit config token inside the Vercel runtime', async () => { + process.env.VERCEL = '1'; + process.env.VERCEL_TOKEN = 'env-token-123'; + + // Queueing an OIDC value here would leak into the next test, since an + // explicit config token short-circuits before OIDC is ever consulted. + // Assert that directly instead. + const { getVercelOidcToken } = await import('@vercel/oidc'); + vi.mocked(getVercelOidcToken).mockClear(); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_from_config' }), { status: 200 }) + ); + + await createResolveLatestDeploymentId({ token: 'config-token' })(); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer config-token'); + expect(getVercelOidcToken).not.toHaveBeenCalled(); + }); + + it('falls back to VERCEL_TOKEN inside the runtime when OIDC is unavailable', async () => { + process.env.VERCEL = '1'; + process.env.VERCEL_TOKEN = 'env-token-123'; + + const { getVercelOidcToken } = await import('@vercel/oidc'); + // mockReset clears any queue a prior test left behind; the rejection is + // scoped to this call so it does not leak into later tests. + vi.mocked(getVercelOidcToken).mockReset(); + vi.mocked(getVercelOidcToken).mockRejectedValueOnce(new Error('no OIDC')); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 }) + ); + + await createResolveLatestDeploymentId({})(); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer env-token-123'); + }); + + it('scopes the request to the configured team', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 }) + ); + + await createResolveLatestDeploymentId({ + token: 'test-token', + projectConfig: { projectId: 'prj_123', teamId: 'team_abc' }, + })(); + + const url = new URL(mockFetch.mock.calls[0][0] as string); + expect(url.pathname).toBe( + '/v1/workflow/resolve-latest-deployment/dpl_current_abc123' + ); + expect(url.searchParams.get('teamId')).toBe('team_abc'); + }); + + it('omits the team parameter when no team is configured', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 }) + ); + + await createResolveLatestDeploymentId({ token: 'test-token' })(); + + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://api.vercel.com/v1/workflow/resolve-latest-deployment/dpl_current_abc123' + ); + }); + + it('explains the identity cause on a 404', async () => { + mockFetch.mockResolvedValueOnce( + new Response('Deployment not found.', { status: 404 }) + ); + + await expect( + createResolveLatestDeploymentId({ token: 'test-token' })() + ).rejects.toThrow(/not visible to the identity/); + }); + it('should throw on non-ok HTTP response', async () => { mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 })); diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts index 291a1afcd9..0d32e99e09 100644 --- a/packages/world-vercel/src/resolve-latest-deployment.ts +++ b/packages/world-vercel/src/resolve-latest-deployment.ts @@ -6,6 +6,7 @@ * deployments) as the provided current deployment. */ +import { getVercelOidcToken } from '@vercel/oidc'; import * as z from 'zod'; import { missingDeploymentIdMessage } from './deployment-id.js'; import { getDispatcher } from './http-client.js'; @@ -16,6 +17,41 @@ const ResolveLatestDeploymentResponseSchema = z.object({ id: z.string(), }); +/** + * Resolve the credential this call should authenticate with. + * + * This endpoint is scoped to a *team* on the API side: it reads the source + * deployment out of a team-partitioned store, so the identity we present + * decides which team's deployments are visible. That makes the credential + * choice a correctness concern here, not just an auth detail, and it is why + * this path does not simply use `resolveVercelApiToken`. + * + * Inside the Vercel runtime the OIDC token is preferred over `VERCEL_TOKEN`, + * reversing that helper's order. The OIDC token carries the deployment's own + * `owner_id`/`project_id` claims, so the API resolves exactly the team that + * owns the deployment we are asking about. A `VERCEL_TOKEN` in the function's + * environment is a *user's* credential: it carries no team, so the API falls + * back to that user's default team, and the lookup then misses on every team + * but that one and returns 404. An ambient token set for unrelated tooling + * must not displace the deployment's own identity. + * + * Outside the runtime (CLI, CI, the dashboard) the original order is kept. + * There is no deployment identity to prefer, `VERCEL_TOKEN` is usually set + * deliberately, and those callers pass an explicit `teamId` instead. + */ +async function resolveDeploymentIdentityToken( + config?: APIConfig +): Promise { + if (config?.token) return config.token; + + if (process.env.VERCEL === '1') { + const oidcToken = await getVercelOidcToken().catch(() => null); + if (oidcToken) return oidcToken; + } + + return resolveVercelApiToken(config); +} + /** * Create the `resolveLatestDeploymentId` implementation for a Vercel World. * @@ -38,14 +74,21 @@ export function createResolveLatestDeploymentId( ); } - const token = await resolveVercelApiToken(config); + const token = await resolveDeploymentIdentityToken(config); if (!token) { throw new Error( 'Cannot resolve latest deployment: no OIDC token or VERCEL_TOKEN available' ); } - const url = `https://api.vercel.com/v1/workflow/resolve-latest-deployment/${encodeURIComponent(currentDeploymentId)}`; + // Scope the request to the owning team when the caller knows it, the same + // way the sibling run-key request does. Without either this parameter or + // an OIDC token's `owner_id` claim, the API scopes the lookup to the + // token owner's *default* team and 404s when that is not the team that + // owns the deployment. + const teamId = config?.projectConfig?.teamId; + const query = teamId ? `?${new URLSearchParams({ teamId })}` : ''; + const url = `https://api.vercel.com/v1/workflow/resolve-latest-deployment/${encodeURIComponent(currentDeploymentId)}${query}`; // 429/5xx retries are handled by the shared RetryAgent from getDispatcher(). // instrumentedFetch adds the OTEL client span + DEBUG logging the v3/v4 @@ -66,8 +109,19 @@ export function createResolveLatestDeploymentId( } catch { body = ''; } + // A 404 here reads as "the deployment does not exist", but the far + // more common cause is that the request resolved to a team that does + // not own it, so the team-partitioned lookup missed. Name that, or + // the next reader debugs deployment state instead of identity. + const hint = + res.status === 404 + ? '. The deployment exists but was not visible to the identity this' + + ' request authenticated as, which means the request resolved to a' + + " different team. Set the World's `projectConfig.teamId` to scope" + + ' it explicitly.' + : ''; return new Error( - `Failed to resolve latest deployment for ${currentDeploymentId}: HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}` + `Failed to resolve latest deployment for ${currentDeploymentId}: HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}${hint}` ); }, });