@silkweave/nestjs 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/guards.ts DELETED
@@ -1,70 +0,0 @@
1
- import { ForbiddenException, type CanActivate, type Type } from '@nestjs/common'
2
- import { GUARDS_METADATA } from '@nestjs/common/constants.js'
3
- import { ModuleRef, Reflector } from '@nestjs/core'
4
- import { isObservable, lastValueFrom } from 'rxjs'
5
- import { SilkweaveExecutionContext } from './executionContext.js'
6
-
7
- type GuardRef = Type<CanActivate> | CanActivate
8
-
9
- /**
10
- * Read `@UseGuards(...)` metadata for both the method and its class and merge
11
- * the two lists. Method-level guards run AFTER class-level guards (matching
12
- * Nest's own behavior).
13
- */
14
- export function collectGuards(
15
- reflector: Reflector,
16
- classRef: Type<unknown>,
17
- handler: (...args: unknown[]) => unknown
18
- ): GuardRef[] {
19
- const classGuards = reflector.get<GuardRef[]>(GUARDS_METADATA, classRef) ?? []
20
- const methodGuards = reflector.get<GuardRef[]>(GUARDS_METADATA, handler) ?? []
21
- return [...classGuards, ...methodGuards]
22
- }
23
-
24
- async function resolveGuard(ref: GuardRef, moduleRef: ModuleRef): Promise<CanActivate> {
25
- if (typeof ref === 'function') {
26
- try {
27
- return await moduleRef.get(ref, { strict: false })
28
- } catch {
29
- return moduleRef.create(ref)
30
- }
31
- }
32
- return ref
33
- }
34
-
35
- /**
36
- * Run the configured guards against a request. Throws `ForbiddenException`
37
- * if any guard rejects, mirroring Nest's HTTP request-pipeline behavior.
38
- *
39
- * Pass `null` for `response` when running on top of a tRPC or MCP request that
40
- * doesn't surface a raw response object; guards that introspect the response
41
- * will receive `null`.
42
- *
43
- * `contextType` is reflected through `ExecutionContext.getType()` so guards can
44
- * branch on the transport. It is `'http'` for REST/tRPC and for MCP-over-HTTP
45
- * (where a header-bearing request stand-in is available); transports without any
46
- * HTTP request (e.g. MCP stdio) pass `'rpc'`.
47
- */
48
- export async function runGuards(
49
- guards: GuardRef[],
50
- moduleRef: ModuleRef,
51
- reflector: Reflector,
52
- classRef: Type<unknown>,
53
- handler: (...args: unknown[]) => unknown,
54
- request: unknown,
55
- response: unknown,
56
- contextType: 'http' | 'rpc' = 'http'
57
- ): Promise<void> {
58
- if (guards.length === 0) { return }
59
- const context = new SilkweaveExecutionContext([request, response], classRef, handler, contextType)
60
- for (const ref of guards) {
61
- const guard = await resolveGuard(ref, moduleRef)
62
- const result = guard.canActivate(context)
63
- const allowed = isObservable(result) ? await lastValueFrom(result) : await Promise.resolve(result)
64
- if (!allowed) {
65
- throw new ForbiddenException('Forbidden resource')
66
- }
67
- }
68
- // Reflector kept in the signature for future use (e.g., per-guard metadata) - unused here.
69
- void reflector
70
- }
@@ -1,38 +0,0 @@
1
- import type { z } from 'zod/v4'
2
-
3
- /** Reflect-metadata key carrying `@Mcp` options on a controller method. */
4
- export const MCP_METADATA = '__silkweave_mcp__'
5
-
6
- /**
7
- * Options for the `@Mcp()` method decorator. Every field is optional - an empty
8
- * `@Mcp()` exposes the decorated controller route as an MCP tool with its name,
9
- * description, and input schema fully reflected from the method's route
10
- * (`@Get`/`@Post`/...), parameter decorators (`@Param`/`@Query`/`@Body`), and
11
- * any `@nestjs/swagger` (`@ApiOperation`/`@ApiParam`/`@ApiProperty`) or
12
- * `class-validator` metadata it carries.
13
- */
14
- export interface McpMetadata {
15
- /**
16
- * MCP tool name override. When unset it is derived from the controller class
17
- * and method name (e.g. `ChannelsController.findOne` → `ChannelsFindOne`).
18
- */
19
- name?: string
20
- /**
21
- * Tool description override. When unset it falls back to the method's
22
- * `@ApiOperation({ summary | description })`, then a generated default.
23
- */
24
- description?: string
25
- /**
26
- * Zod raw-shape override merged over the reflected input fields (override
27
- * wins per field). The escape hatch for shapes reflection can't express
28
- * losslessly - discriminated unions, custom validators, `@Transform`, etc.
29
- */
30
- input?: Record<string, z.ZodType>
31
- /**
32
- * Whether to apply the controller method's parameter-bound pipes
33
- * (`@Param('id', ParseIntPipe)`) when re-binding the call. Default `'apply'`.
34
- * Global/`ValidationPipe`, interceptors, and exception filters never run -
35
- * the method is invoked directly, not through Nest's HTTP request pipeline.
36
- */
37
- pipes?: 'apply' | 'skip'
38
- }
package/src/lib/rebind.ts DELETED
@@ -1,117 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
- import { PARAMTYPE } from './reflect/params.js'
3
-
4
- /**
5
- * Instruction for reconstructing one positional handler argument from the flat
6
- * MCP tool input. Built at discovery time, consumed by {@link invokeRebound}.
7
- */
8
- export type Binding =
9
- | { kind: 'value'; field: string; source: 'path' | 'query' | 'body'; metatype?: unknown; pipes?: unknown[] }
10
- | { kind: 'object'; source: 'query' | 'body'; fields: string[]; metatype?: unknown; pipes?: unknown[] }
11
- | { kind: 'params'; fields: string[] }
12
- | { kind: 'request' }
13
- | { kind: 'response' }
14
- | { kind: 'headers'; data?: string }
15
- | { kind: 'ip' }
16
- | { kind: 'host'; data?: string }
17
- | { kind: 'missing' }
18
-
19
- interface RequestLike {
20
- headers?: Record<string, unknown>
21
- ip?: unknown
22
- hosts?: Record<string, unknown>
23
- }
24
-
25
- function newInstance(P: any): any | null {
26
- try { return new P() } catch { return null }
27
- }
28
-
29
- async function applyPipes(value: unknown, pipes: unknown[] | undefined, metatype: unknown, type: 'param' | 'query' | 'body', data: string | undefined): Promise<unknown> {
30
- if (!pipes || pipes.length === 0) { return value }
31
- let current = value
32
- for (const p of pipes) {
33
- const pipe = typeof p === 'function' ? newInstance(p) : p
34
- if (pipe && typeof (pipe).transform === 'function') {
35
- current = await (pipe).transform(current, { type, metatype, data })
36
- }
37
- }
38
- return current
39
- }
40
-
41
- /** Pick a subset of keys (skipping `undefined`) into a fresh object. */
42
- function pickFields(input: Record<string, unknown>, fields: string[]): Record<string, unknown> {
43
- const obj: Record<string, unknown> = {}
44
- for (const f of fields) {
45
- if (input[f] !== undefined) { obj[f] = input[f] }
46
- }
47
- return obj
48
- }
49
-
50
- /** Resolve a single positional argument from one binding. */
51
- async function resolveArg(
52
- b: Binding,
53
- input: Record<string, unknown>,
54
- request: RequestLike | undefined,
55
- applyParamPipes: boolean
56
- ): Promise<unknown> {
57
- switch (b.kind) {
58
- case 'value': {
59
- const raw = input[b.field]
60
- const type = b.source === 'path' ? 'param' : b.source
61
- return applyParamPipes ? applyPipes(raw, b.pipes, b.metatype, type, b.field) : raw
62
- }
63
- case 'object': {
64
- const obj = pickFields(input, b.fields)
65
- return applyParamPipes ? applyPipes(obj, b.pipes, b.metatype, b.source, undefined) : obj
66
- }
67
- case 'params':
68
- return pickFields(input, b.fields)
69
- case 'headers':
70
- return b.data ? request?.headers?.[b.data.toLowerCase()] : (request?.headers ?? {})
71
- case 'request':
72
- return request ?? { headers: {} }
73
- case 'ip':
74
- return request?.ip
75
- case 'host':
76
- return b.data ? request?.hosts?.[b.data] : request?.hosts
77
- default:
78
- return undefined
79
- }
80
- }
81
-
82
- /**
83
- * Reconstruct the controller method's positional arguments from the validated
84
- * tool `input` (and the request stand-in for `@Req`/`@Headers`/...), then call
85
- * it. `applyParamPipes` controls whether parameter-bound pipes run.
86
- */
87
- export async function invokeRebound(
88
- method: (...args: any[]) => any,
89
- instance: object,
90
- input: Record<string, unknown>,
91
- bindings: Binding[],
92
- request: RequestLike | undefined,
93
- applyParamPipes: boolean
94
- ): Promise<unknown> {
95
- const args: unknown[] = []
96
- for (let i = 0; i < bindings.length; i += 1) {
97
- args[i] = await resolveArg(bindings[i], input, request, applyParamPipes)
98
- }
99
- return await method.apply(instance, args)
100
- }
101
-
102
- /** Map a non-input parameter slot (`@Req`/`@Headers`/`@Ip`/...) to its runtime binding. */
103
- export function specialBinding(paramtype: number, data: string | undefined): Binding | null {
104
- switch (paramtype) {
105
- case PARAMTYPE.REQUEST: return { kind: 'request' }
106
- case PARAMTYPE.RESPONSE:
107
- case PARAMTYPE.NEXT: return { kind: 'response' }
108
- case PARAMTYPE.HEADERS: return { kind: 'headers', data }
109
- case PARAMTYPE.IP: return { kind: 'ip' }
110
- case PARAMTYPE.HOST: return { kind: 'host', data }
111
- case PARAMTYPE.SESSION:
112
- case PARAMTYPE.FILE:
113
- case PARAMTYPE.FILES:
114
- case PARAMTYPE.RAW_BODY: return { kind: 'missing' }
115
- default: return null
116
- }
117
- }
@@ -1,56 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
- import { createRequire } from 'node:module'
3
- import { join } from 'node:path'
4
-
5
- let cached: any | null | undefined
6
-
7
- /**
8
- * Lazily resolve the optional `class-validator` peer; `null` when not installed.
9
- * Resolution is attempted both from this package and from the app's working
10
- * directory - the latter is what finds it in a pnpm install where the optional
11
- * peer lives in the consumer app rather than alongside `@silkweave/nestjs`. A
12
- * pnpm-deduped install shares one physical copy, so the metadata singleton the
13
- * app's decorators wrote to is the same one we read.
14
- */
15
- function loadClassValidator(): any | null {
16
- if (cached !== undefined) { return cached }
17
- for (const base of [import.meta.url, join(process.cwd(), 'noop.js')]) {
18
- try {
19
- cached = createRequire(base)('class-validator')
20
- return cached
21
- } catch { /* try the next resolution base */ }
22
- }
23
- cached = null
24
- return cached
25
- }
26
-
27
- export interface ValidationMeta {
28
- /** `ValidationTypes` discriminator (e.g. `customValidation`, `conditionalValidation`). */
29
- type?: string
30
- /** Validator name - the actionable identity for built-ins (`isString`, `minLength`, ...). */
31
- name?: string
32
- constraints?: unknown[]
33
- }
34
-
35
- /**
36
- * Read `class-validator` metadata for a DTO class, grouped by property name.
37
- * Returns an empty map when `class-validator` is not installed or the class
38
- * carries no validation decorators - so callers degrade gracefully to swagger /
39
- * `design:type` reflection.
40
- */
41
- export function classValidatorMetas(dtoType: any): Record<string, ValidationMeta[]> {
42
- const cv = loadClassValidator()
43
- if (!cv?.getMetadataStorage) { return {} }
44
- let metas: Array<{ propertyName?: string; type?: string; name?: string; constraints?: unknown[] }>
45
- try {
46
- metas = cv.getMetadataStorage().getTargetValidationMetadatas(dtoType, null, false, false)
47
- } catch {
48
- return {}
49
- }
50
- const out: Record<string, ValidationMeta[]> = {}
51
- for (const m of metas) {
52
- if (!m.propertyName) { continue }
53
- (out[m.propertyName] ??= []).push({ type: m.type, name: m.name, constraints: m.constraints })
54
- }
55
- return out
56
- }
@@ -1,87 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
2
- import { type FieldDesc, mergeField, openapiSchemaToField } from './schema.js'
3
-
4
- /**
5
- * A minimal view of an OpenAPI document - the subset we read. Matches the shape
6
- * `SwaggerModule.createDocument()` returns, but typed loosely so callers can
7
- * pass any compatible object without a hard `@nestjs/swagger` dependency.
8
- */
9
- export interface OpenApiDocument {
10
- paths?: Record<string, Record<string, any>>
11
- components?: { schemas?: Record<string, any> }
12
- }
13
-
14
- export interface OpenApiLookup {
15
- doc: OpenApiDocument
16
- /** `${METHOD} ${path}` → operation object. */
17
- operations: Map<string, any>
18
- }
19
-
20
- /** Index a document's operations by `${METHOD} ${path}` for fast matching. */
21
- export function buildOpenApiLookup(doc: OpenApiDocument): OpenApiLookup {
22
- const operations = new Map<string, any>()
23
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
24
- for (const [verb, op] of Object.entries(item ?? {})) {
25
- operations.set(`${verb.toUpperCase()} ${path}`, op)
26
- }
27
- }
28
- return { doc, operations }
29
- }
30
-
31
- /** Locate the operation for a route, tolerating a global path prefix on the document side. */
32
- function findOperation(lookup: OpenApiLookup, method: string, openapiPath: string): any | undefined {
33
- const exact = lookup.operations.get(`${method} ${openapiPath}`)
34
- if (exact) { return exact }
35
- // Fall back to a suffix match so a `setGlobalPrefix('api')` document still resolves.
36
- for (const [key, op] of lookup.operations) {
37
- const [verb, path] = key.split(' ')
38
- if (verb === method && (path.endsWith(openapiPath) || openapiPath.endsWith(path))) { return op }
39
- }
40
- return undefined
41
- }
42
-
43
- function resolveRef(doc: OpenApiDocument, schema: any): any {
44
- let current = schema
45
- let guard = 0
46
- while (current && typeof current === 'object' && typeof current['$ref'] === 'string' && guard < 10) {
47
- const match = /^#\/components\/schemas\/(.+)$/.exec(current['$ref'])
48
- if (!match) { break }
49
- current = doc.components?.schemas?.[match[1]]
50
- guard += 1
51
- }
52
- return current
53
- }
54
-
55
- /**
56
- * Resolve the per-field descriptors for a route from an ingested OpenAPI
57
- * document. Merges `parameters` (path/query/header) and the JSON request-body
58
- * schema's properties into a single field map. Returns `{}` when the operation
59
- * isn't found - callers fall back to decorator reflection.
60
- */
61
- export function openApiFields(lookup: OpenApiLookup, method: string, openapiPath: string): Record<string, FieldDesc> {
62
- const op = findOperation(lookup, method, openapiPath)
63
- if (!op) { return {} }
64
- const out: Record<string, FieldDesc> = {}
65
-
66
- for (const param of (op['parameters'] as Array<Record<string, any>> | undefined) ?? []) {
67
- const name = typeof param['name'] === 'string' ? param['name'] : undefined
68
- if (!name) { continue }
69
- const schema = param['schema'] ? resolveRef(lookup.doc, param['schema']) : undefined
70
- let field = schema ? openapiSchemaToField(schema) : {}
71
- if (param['description']) { field = mergeField(field, { description: param['description'] }) }
72
- if (typeof param['required'] === 'boolean') { field = mergeField(field, { required: param['required'] }) }
73
- out[name] = field
74
- }
75
-
76
- const bodySchema = resolveRef(lookup.doc, op['requestBody']?.['content']?.['application/json']?.['schema'])
77
- if (bodySchema && typeof bodySchema === 'object' && bodySchema['properties']) {
78
- const required = new Set<string>(Array.isArray(bodySchema['required']) ? bodySchema['required'] : [])
79
- for (const [name, propSchema] of Object.entries(bodySchema['properties'] as Record<string, any>)) {
80
- const field = openapiSchemaToField(resolveRef(lookup.doc, propSchema))
81
- field.required = required.has(name)
82
- out[name] = field
83
- }
84
- }
85
-
86
- return out
87
- }
@@ -1,63 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'
3
-
4
- /**
5
- * `RouteParamtypes` numeric values from `@nestjs/common` (re-declared to avoid a
6
- * value import of an internal enum). These are how `@Param`/`@Query`/`@Body`/...
7
- * tag each handler argument in `ROUTE_ARGS_METADATA`.
8
- */
9
- export const PARAMTYPE = {
10
- REQUEST: 0,
11
- RESPONSE: 1,
12
- NEXT: 2,
13
- BODY: 3,
14
- QUERY: 4,
15
- PARAM: 5,
16
- HEADERS: 6,
17
- SESSION: 7,
18
- FILE: 8,
19
- FILES: 9,
20
- HOST: 10,
21
- IP: 11,
22
- RAW_BODY: 12
23
- } as const
24
-
25
- export interface ParamSlot {
26
- /** `PARAMTYPE` value - which decorator tagged this argument. */
27
- paramtype: number
28
- /** Handler argument position. */
29
- index: number
30
- /** Decorator sub-key: `@Param('id')` → `'id'`; a bare `@Body()` → `undefined`. */
31
- data?: string
32
- /** Parameter-bound pipes (`@Param('id', ParseIntPipe)`). */
33
- pipes: unknown[]
34
- /** TypeScript-emitted constructor at this position (e.g. `String`, `CreateDto`). */
35
- designType?: unknown
36
- }
37
-
38
- /**
39
- * Read and normalise the route-argument metadata for a controller method.
40
- * Returns one {@link ParamSlot} per decorated handler argument, sorted by
41
- * argument index.
42
- */
43
- export function readParamSlots(classRef: any, methodName: string, proto: any): ParamSlot[] {
44
- const raw = Reflect.getMetadata(ROUTE_ARGS_METADATA, classRef, methodName) as
45
- Record<string, { index: number; data?: unknown; pipes?: unknown[] }> | undefined
46
- if (!raw) { return [] }
47
- const designTypes = (Reflect.getMetadata('design:paramtypes', proto, methodName) as unknown[] | undefined) ?? []
48
-
49
- const slots: ParamSlot[] = []
50
- for (const key of Object.keys(raw)) {
51
- const entry = raw[key]
52
- const paramtype = Number(key.split(':')[0])
53
- if (Number.isNaN(paramtype)) { continue }
54
- slots.push({
55
- paramtype,
56
- index: entry.index,
57
- data: typeof entry.data === 'string' ? entry.data : undefined,
58
- pipes: entry.pipes ?? [],
59
- designType: designTypes[entry.index]
60
- })
61
- }
62
- return slots.sort((a, b) => a.index - b.index)
63
- }
@@ -1,58 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { PATH_METADATA, METHOD_METADATA } from '@nestjs/common/constants.js'
3
-
4
- /**
5
- * `RequestMethod` numeric values (from `@nestjs/common`) mapped to verbs. We
6
- * map our own table rather than importing the enum to avoid pulling a value
7
- * import for a handful of constants.
8
- */
9
- const REQUEST_METHOD: Record<number, string> = {
10
- 0: 'GET',
11
- 1: 'POST',
12
- 2: 'PUT',
13
- 3: 'DELETE',
14
- 4: 'PATCH',
15
- 6: 'OPTIONS',
16
- 7: 'HEAD'
17
- }
18
-
19
- export interface RouteInfo {
20
- /** HTTP verb (`GET`/`POST`/...). `PATCH` and others outside the REST set fall back to `POST`-ish handling by callers. */
21
- method: string
22
- /** Full route template in Nest form, e.g. `sessions/:sessionId/channels/:channelId` (no leading slash). */
23
- path: string
24
- /** Full route template in OpenAPI form, e.g. `/sessions/{sessionId}/channels/{channelId}`. */
25
- openapiPath: string
26
- /** Names of the `:param` placeholders across controller + method path. */
27
- pathParams: string[]
28
- }
29
-
30
- function normalizeSegment(value: unknown): string {
31
- if (typeof value !== 'string') { return '' }
32
- return value.replace(/^\/+/, '').replace(/\/+$/, '')
33
- }
34
-
35
- function joinPath(...segments: string[]): string {
36
- return segments.map(normalizeSegment).filter(Boolean).join('/')
37
- }
38
-
39
- /**
40
- * Resolve the composed route (controller prefix + method path), HTTP verb, and
41
- * path-param names for a decorated controller method, reading Nest's own
42
- * `PATH_METADATA`/`METHOD_METADATA` reflection.
43
- */
44
- export function reflectRoute(classRef: any, method: (...args: any[]) => any): RouteInfo {
45
- const classPath = Reflect.getMetadata(PATH_METADATA, classRef) as string | string[] | undefined
46
- const methodPath = Reflect.getMetadata(PATH_METADATA, method) as string | string[] | undefined
47
- const verbCode = Reflect.getMetadata(METHOD_METADATA, method) as number | undefined
48
-
49
- const classSeg = Array.isArray(classPath) ? (classPath[0] ?? '') : (classPath ?? '')
50
- const methodSeg = Array.isArray(methodPath) ? (methodPath[0] ?? '') : (methodPath ?? '')
51
- const path = joinPath(classSeg, methodSeg)
52
- const httpMethod = REQUEST_METHOD[verbCode ?? 0] ?? 'GET'
53
-
54
- const pathParams = [...path.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => m[1])
55
- const openapiPath = `/${path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
56
-
57
- return { method: httpMethod, path, openapiPath, pathParams }
58
- }