-
Notifications
You must be signed in to change notification settings - Fork 414
Fix/onboarding without trigger worker #3570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6eaf0be
d8db6b0
f167d46
b164c43
e71ffc4
f3bb62f
86f1ab0
ac5ecf8
9df829b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import { StreamableFile } from '@nestjs/common'; | ||
| import { Reflector } from '@nestjs/core'; | ||
| import { Readable } from 'node:stream'; | ||
| import { firstValueFrom, of } from 'rxjs'; | ||
| import { AuthContextResponseInterceptor } from './auth-context-response.interceptor'; | ||
| import { IS_PUBLIC_KEY } from './public.decorator'; | ||
| import { SKIP_AUTH_CONTEXT_RESPONSE_KEY } from './skip-auth-context-response.decorator'; | ||
|
|
||
| type RequestOverrides = { | ||
| authType?: 'api-key' | 'session' | 'service'; | ||
| userId?: string; | ||
| userEmail?: string; | ||
| }; | ||
|
|
||
| function run( | ||
| body: unknown, | ||
| { | ||
| request = { authType: 'api-key' as const }, | ||
| metadata = {}, | ||
| type = 'http', | ||
| }: { | ||
| request?: RequestOverrides; | ||
| metadata?: Record<string, boolean>; | ||
| type?: string; | ||
| } = {}, | ||
| ): Promise<unknown> { | ||
| const reflector = { | ||
| getAllAndOverride: (key: string) => metadata[key], | ||
| } as unknown as Reflector; | ||
|
|
||
| const context = { | ||
| getType: () => type, | ||
| getHandler: () => undefined, | ||
| getClass: () => undefined, | ||
| switchToHttp: () => ({ getRequest: () => request }), | ||
| } as never; | ||
|
|
||
| const interceptor = new AuthContextResponseInterceptor(reflector); | ||
| return firstValueFrom( | ||
| interceptor.intercept(context, { handle: () => of(body) }) as never, | ||
| ); | ||
| } | ||
|
|
||
| describe('AuthContextResponseInterceptor', () => { | ||
| describe('appends the context', () => { | ||
| it('adds authType for api-key auth', async () => { | ||
| await expect(run({ data: [] })).resolves.toEqual({ | ||
| data: [], | ||
| authType: 'api-key', | ||
| }); | ||
| }); | ||
|
|
||
| it('adds authenticatedUser for session auth', async () => { | ||
| const result = await run( | ||
| { id: 'x' }, | ||
| { | ||
| request: { | ||
| authType: 'session', | ||
| userId: 'usr_1', | ||
| userEmail: 'a@b.com', | ||
| }, | ||
| }, | ||
| ); | ||
| expect(result).toEqual({ | ||
| id: 'x', | ||
| authType: 'session', | ||
| authenticatedUser: { id: 'usr_1', email: 'a@b.com' }, | ||
| }); | ||
| }); | ||
|
|
||
| it('omits authenticatedUser when only one of id/email is present', async () => { | ||
| const result = (await run( | ||
| { id: 'x' }, | ||
| { request: { authType: 'session', userId: 'usr_1' } }, | ||
| )) as Record<string, unknown>; | ||
| expect(result.authenticatedUser).toBeUndefined(); | ||
| expect(result.authType).toBe('session'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('leaves the body alone', () => { | ||
| it('when the endpoint opts out', async () => { | ||
| const body = { data: [] }; | ||
| await expect( | ||
| run(body, { metadata: { [SKIP_AUTH_CONTEXT_RESPONSE_KEY]: true } }), | ||
| ).resolves.toEqual(body); | ||
| }); | ||
|
|
||
| it('when the endpoint is @Public()', async () => { | ||
| const body = { data: [] }; | ||
| await expect( | ||
| run(body, { metadata: { [IS_PUBLIC_KEY]: true } }), | ||
| ).resolves.toEqual(body); | ||
| }); | ||
|
|
||
| it('when the request carries no auth context', async () => { | ||
| const body = { data: [] }; | ||
| await expect(run(body, { request: {} })).resolves.toEqual(body); | ||
| }); | ||
|
|
||
| it('when the controller already set authType itself', async () => { | ||
| const body = { data: [], authType: 'session' }; | ||
| await expect(run(body)).resolves.toEqual(body); | ||
| }); | ||
|
|
||
| it('for a non-http context', async () => { | ||
| const body = { data: [] }; | ||
| await expect(run(body, { type: 'rpc' })).resolves.toEqual(body); | ||
| }); | ||
| }); | ||
|
|
||
| describe('refuses to corrupt non-object payloads', () => { | ||
| it('passes arrays through untouched', async () => { | ||
| await expect(run([{ id: 1 }])).resolves.toEqual([{ id: 1 }]); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['null', null], | ||
| ['undefined', undefined], | ||
| ['a string', 'ok'], | ||
| ['a number', 42], | ||
| ['a boolean', true], | ||
| ])('passes %s through untouched', async (_label, value) => { | ||
| await expect(run(value)).resolves.toEqual(value); | ||
| }); | ||
|
|
||
| it('passes a Buffer through untouched', async () => { | ||
| const buf = Buffer.from('pdf-bytes'); | ||
| const result = await run(buf); | ||
| expect(Buffer.isBuffer(result)).toBe(true); | ||
| expect(result).toEqual(buf); | ||
| }); | ||
|
|
||
| it('passes a stream through untouched', async () => { | ||
| const stream = Readable.from(['chunk']); | ||
| expect(await run(stream)).toBe(stream); | ||
| }); | ||
|
|
||
| it('passes a StreamableFile through untouched', async () => { | ||
| const file = new StreamableFile(Buffer.from('x')); | ||
| expect(await run(file)).toBe(file); | ||
| }); | ||
|
|
||
| it('passes a Date through untouched', async () => { | ||
| const date = new Date('2026-01-01T00:00:00Z'); | ||
| expect(await run(date)).toBe(date); | ||
| }); | ||
|
|
||
| it('passes a class instance through untouched', async () => { | ||
| class Dto { | ||
| constructor(public id: string) {} | ||
| } | ||
| const dto = new Dto('x'); | ||
| expect(await run(dto)).toBe(dto); | ||
| }); | ||
| }); | ||
|
|
||
| it('does not mutate the original body object', async () => { | ||
| const body = { data: [] }; | ||
| await run(body); | ||
| expect(body).toEqual({ data: [] }); | ||
| expect('authType' in body).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { | ||
| CallHandler, | ||
| ExecutionContext, | ||
| Injectable, | ||
| NestInterceptor, | ||
| StreamableFile, | ||
| } from '@nestjs/common'; | ||
| import { Reflector } from '@nestjs/core'; | ||
| import { Observable, map } from 'rxjs'; | ||
| import { IS_PUBLIC_KEY } from './public.decorator'; | ||
| import { SKIP_AUTH_CONTEXT_RESPONSE_KEY } from './skip-auth-context-response.decorator'; | ||
| import type { AuthenticatedRequest } from './types'; | ||
|
|
||
| /** | ||
| * Shape appended to authenticated responses. | ||
| */ | ||
| export interface AuthContextResponseFields { | ||
| authType: AuthenticatedRequest['authType']; | ||
| authenticatedUser?: { id: string; email: string }; | ||
| } | ||
|
|
||
| /** | ||
| * Appends `authType` (and `authenticatedUser` for session auth) to response | ||
| * bodies. | ||
| * | ||
| * These fields were previously hand-written into each controller — 81 copies | ||
| * across 14 of 94 controllers — so the same documented contract was honoured | ||
| * by some endpoints and not others. Generated clients (the OpenAPI-derived MCP | ||
| * server) key off consistent response shapes, so the inconsistency was a real | ||
| * interoperability problem rather than a cosmetic one. | ||
| * | ||
| * Deliberately conservative about what it will touch: only plain JSON objects. | ||
| * Arrays, primitives, null, buffers and streams pass through untouched, because | ||
| * grafting fields onto them would corrupt the payload rather than annotate it. | ||
| */ | ||
| @Injectable() | ||
| export class AuthContextResponseInterceptor implements NestInterceptor { | ||
| constructor(private readonly reflector: Reflector) {} | ||
|
|
||
| intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { | ||
| if (context.getType() !== 'http') { | ||
| return next.handle(); | ||
| } | ||
|
|
||
| const skip = this.reflector.getAllAndOverride<boolean>( | ||
| SKIP_AUTH_CONTEXT_RESPONSE_KEY, | ||
| [context.getHandler(), context.getClass()], | ||
| ); | ||
| const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ | ||
| context.getHandler(), | ||
| context.getClass(), | ||
| ]); | ||
|
|
||
| if (skip || isPublic) { | ||
| return next.handle(); | ||
| } | ||
|
|
||
| const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); | ||
|
|
||
| return next.handle().pipe( | ||
| map((body: unknown) => { | ||
| if (!request.authType) { | ||
| // No auth context (unauthenticated route, or a guard that does not | ||
| // populate it) — nothing meaningful to report. | ||
| return body; | ||
| } | ||
|
|
||
| if (!isPlainJsonObject(body)) { | ||
| return body; | ||
| } | ||
|
|
||
| // A controller that still sets the fields itself wins, so the migration | ||
| // can proceed file by file without double-writing. | ||
| if ('authType' in body) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an authenticated response has a domain-level Prompt for AI agents |
||
| return body; | ||
| } | ||
|
|
||
| const fields: AuthContextResponseFields = { authType: request.authType }; | ||
|
|
||
| if (request.userId && request.userEmail) { | ||
| fields.authenticatedUser = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This global APP_INTERCEPTOR appends Prompt for AI agents |
||
| id: request.userId, | ||
| email: request.userEmail, | ||
| }; | ||
| } | ||
|
|
||
| return { ...body, ...fields }; | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * True only for plain objects safe to spread. Excludes arrays, null, class | ||
| * instances with custom prototypes, buffers, streams and dates — anything | ||
| * where spreading would lose behaviour or corrupt the payload. | ||
| */ | ||
| function isPlainJsonObject(value: unknown): value is Record<string, unknown> { | ||
| if (value === null || typeof value !== 'object') return false; | ||
| if (Array.isArray(value)) return false; | ||
| if (value instanceof StreamableFile) return false; | ||
| if (value instanceof Date) return false; | ||
| if (Buffer.isBuffer(value)) return false; | ||
| // Streams expose pipe(); spreading one yields a broken object. | ||
| if (typeof (value as { pipe?: unknown }).pipe === 'function') return false; | ||
|
|
||
| const proto: unknown = Object.getPrototypeOf(value); | ||
| return proto === Object.prototype || proto === null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { SetMetadata } from '@nestjs/common'; | ||
|
|
||
| export const SKIP_AUTH_CONTEXT_RESPONSE_KEY = 'skipAuthContextResponse'; | ||
|
|
||
| /** | ||
| * Opt an endpoint out of having `authType` / `authenticatedUser` appended to | ||
| * its response body. | ||
| * | ||
| * Use it where echoing who called is wrong or unhelpful: | ||
| * - public and webhook endpoints, where there is no caller identity to report | ||
| * - trust-portal and other externally consumed payloads, where leaking an | ||
| * internal user id/email would be a disclosure | ||
| * - endpoints whose response shape is fixed by an external contract | ||
| */ | ||
| export const SkipAuthContextResponse = () => | ||
| SetMetadata(SKIP_AUTH_CONTEXT_RESPONSE_KEY, true); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Platform-admin endpoints will not receive the auth context fields because
PlatformAdminGuarddoes not populaterequest.authType. Either populate the same auth context used by the interceptor or explicitly exclude these routes from the uniform-response contract.Prompt for AI agents