@pattern-stack/codegen 0.7.8 → 0.8.1

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/runtime/base-classes/activity-entity-repository.js +98 -17
  3. package/dist/runtime/base-classes/activity-entity-repository.js.map +1 -1
  4. package/dist/runtime/base-classes/base-repository.d.ts +47 -3
  5. package/dist/runtime/base-classes/base-repository.js +98 -17
  6. package/dist/runtime/base-classes/base-repository.js.map +1 -1
  7. package/dist/runtime/base-classes/index.d.ts +1 -0
  8. package/dist/runtime/base-classes/index.js +137 -28
  9. package/dist/runtime/base-classes/index.js.map +1 -1
  10. package/dist/runtime/base-classes/junction-sync-repository.js +102 -21
  11. package/dist/runtime/base-classes/junction-sync-repository.js.map +1 -1
  12. package/dist/runtime/base-classes/knowledge-entity-repository.js +98 -17
  13. package/dist/runtime/base-classes/knowledge-entity-repository.js.map +1 -1
  14. package/dist/runtime/base-classes/metadata-entity-repository.js +101 -20
  15. package/dist/runtime/base-classes/metadata-entity-repository.js.map +1 -1
  16. package/dist/runtime/base-classes/synced-entity-repository.js +103 -22
  17. package/dist/runtime/base-classes/synced-entity-repository.js.map +1 -1
  18. package/dist/runtime/base-classes/tenant-context.d.ts +79 -0
  19. package/dist/runtime/base-classes/tenant-context.js +46 -0
  20. package/dist/runtime/base-classes/tenant-context.js.map +1 -0
  21. package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
  22. package/dist/runtime/subsystems/auth/index.d.ts +2 -0
  23. package/dist/runtime/subsystems/auth/index.js +55 -0
  24. package/dist/runtime/subsystems/auth/index.js.map +1 -1
  25. package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
  26. package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
  27. package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
  28. package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
  29. package/dist/runtime/subsystems/index.d.ts +1 -0
  30. package/dist/runtime/subsystems/index.js +4 -0
  31. package/dist/runtime/subsystems/index.js.map +1 -1
  32. package/dist/src/cli/index.js +15 -1
  33. package/dist/src/cli/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/runtime/base-classes/base-repository.ts +96 -20
  36. package/runtime/base-classes/index.ts +13 -0
  37. package/runtime/base-classes/tenant-context.ts +175 -0
  38. package/runtime/subsystems/auth/index.ts +8 -0
  39. package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
  40. package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.7.8",
3
+ "version": "0.8.1",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,10 +9,15 @@
9
9
  *
10
10
  * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.
11
11
  */
12
- import { eq, inArray, isNull, sql } from 'drizzle-orm';
12
+ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
13
13
  import type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';
14
14
  import type { SQL } from 'drizzle-orm';
15
15
  import type { DrizzleClient, DrizzleTx } from '../types/drizzle';
16
+ import {
17
+ requireRequester,
18
+ tryGetRequester,
19
+ type RequesterScope,
20
+ } from './tenant-context';
16
21
 
17
22
  // ============================================================================
18
23
  // Interfaces
@@ -59,6 +64,21 @@ export abstract class BaseRepository<TEntity> {
59
64
  userTracking: false,
60
65
  };
61
66
 
67
+ /**
68
+ * Ambient tenant-scope enforcement for `userTracking` repos (see
69
+ * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
70
+ *
71
+ * - `'lenient'` (default): when no ambient requester context is active,
72
+ * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
73
+ * ambient scoping is additive. Scoping kicks in automatically once a
74
+ * boundary installs `withRequester(...)`.
75
+ * - `'strict'`: a missing ambient context throws (`requireRequester`),
76
+ * making a forgotten boundary fail loud instead of silently returning
77
+ * cross-tenant rows. Recommended for new multi-tenant consumers — override
78
+ * in a concrete repo or a family base class.
79
+ */
80
+ protected readonly scopeEnforcement: 'lenient' | 'strict' = 'lenient';
81
+
62
82
  protected readonly db: DrizzleClient;
63
83
 
64
84
  constructor(db: DrizzleClient) {
@@ -85,9 +105,7 @@ export abstract class BaseRepository<TEntity> {
85
105
  * Returns null if not found (or soft-deleted when softDelete=true).
86
106
  */
87
107
  async findById(id: string): Promise<TEntity | null> {
88
- const rows = await this.baseQuery()
89
- .where(eq(this.table['id'], id))
90
- .limit(1);
108
+ const rows = await this.baseQuery(eq(this.table['id'], id)).limit(1);
91
109
  return (rows[0] as TEntity) ?? null;
92
110
  }
93
111
 
@@ -97,7 +115,7 @@ export abstract class BaseRepository<TEntity> {
97
115
  */
98
116
  async findByIds(ids: string[]): Promise<TEntity[]> {
99
117
  if (ids.length === 0) return [];
100
- const rows = await this.baseQuery().where(inArray(this.table['id'], ids));
118
+ const rows = await this.baseQuery(inArray(this.table['id'], ids));
101
119
  return rows as TEntity[];
102
120
  }
103
121
 
@@ -105,11 +123,8 @@ export abstract class BaseRepository<TEntity> {
105
123
  * List entities with optional filtering, pagination, and ordering.
106
124
  */
107
125
  async list(options?: ListOptions): Promise<TEntity[]> {
108
- let query = this.baseQuery();
126
+ let query = this.baseQuery(options?.where);
109
127
 
110
- if (options?.where) {
111
- query = query.where(options.where) as typeof query;
112
- }
113
128
  if (options?.orderBy) {
114
129
  query = query.orderBy(options.orderBy as SQL) as typeof query;
115
130
  }
@@ -137,6 +152,10 @@ export abstract class BaseRepository<TEntity> {
137
152
  if (this.behaviors.softDelete) {
138
153
  conditions.push(isNull(this.table['deletedAt']));
139
154
  }
155
+ const scope = this.scopePredicate();
156
+ if (scope) {
157
+ conditions.push(scope);
158
+ }
140
159
  if (where) {
141
160
  conditions.push(where);
142
161
  }
@@ -144,8 +163,6 @@ export abstract class BaseRepository<TEntity> {
144
163
  if (conditions.length === 1) {
145
164
  query = query.where(conditions[0]) as typeof query;
146
165
  } else if (conditions.length > 1) {
147
- // Combine with AND by building the condition inline
148
- const { and } = await import('drizzle-orm');
149
166
  query = query.where(and(...conditions)) as typeof query;
150
167
  }
151
168
 
@@ -186,7 +203,7 @@ export abstract class BaseRepository<TEntity> {
186
203
  const rows = await this.runner(tx)
187
204
  .update(this.table)
188
205
  .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
189
- .where(eq(this.table['id'], id))
206
+ .where(this.scopeAnd(eq(this.table['id'], id)))
190
207
  .returning();
191
208
  return rows[0] as TEntity;
192
209
  }
@@ -202,11 +219,11 @@ export abstract class BaseRepository<TEntity> {
202
219
  await runner
203
220
  .update(this.table)
204
221
  .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
205
- .where(eq(this.table['id'], id));
222
+ .where(this.scopeAnd(eq(this.table['id'], id)));
206
223
  } else {
207
224
  await runner
208
225
  .delete(this.table)
209
- .where(eq(this.table['id'], id));
226
+ .where(this.scopeAnd(eq(this.table['id'], id)));
210
227
  }
211
228
  }
212
229
 
@@ -224,15 +241,74 @@ export abstract class BaseRepository<TEntity> {
224
241
  // ============================================================================
225
242
 
226
243
  /**
227
- * Base SELECT query that automatically excludes soft-deleted rows
228
- * when softDelete behavior is enabled.
244
+ * Base SELECT query that automatically applies the ambient guards —
245
+ * soft-delete exclusion (when `softDelete`) and tenant scope (when
246
+ * `userTracking` + an active requester context) — combined with an optional
247
+ * caller `extra` predicate into a SINGLE `WHERE`.
248
+ *
249
+ * Pass the leaf predicate as `extra` rather than chaining a second
250
+ * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
251
+ * `.where()` on a `$dynamic()` query, so a chained call would silently drop
252
+ * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
229
253
  */
230
- protected baseQuery() {
254
+ protected baseQuery(extra?: SQL) {
231
255
  const query = this.db.select().from(this.table).$dynamic();
232
- if (this.behaviors.softDelete) {
233
- return query.where(isNull(this.table['deletedAt']));
256
+ const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });
257
+ return where ? query.where(where) : query;
258
+ }
259
+
260
+ /**
261
+ * Build the ambient tenant-scope predicate for this repo's table.
262
+ *
263
+ * Returns `undefined` (no scoping) when:
264
+ * - `behaviors.userTracking` is false (repo is not user-owned), or
265
+ * - no ambient requester context is active AND `scopeEnforcement` is
266
+ * `'lenient'` (the default — preserves pre-scoping behavior).
267
+ *
268
+ * When a requester context is active, scopes by `user_id` per the ambient
269
+ * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
270
+ * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
271
+ * → no filter. See `tenant-context.ts` for the boundary-install contract.
272
+ */
273
+ protected scopePredicate(): SQL | undefined {
274
+ if (!this.behaviors.userTracking) return undefined;
275
+ const ctx =
276
+ this.scopeEnforcement === 'strict'
277
+ ? requireRequester()
278
+ : tryGetRequester();
279
+ if (!ctx) return undefined;
280
+ const scope: RequesterScope = ctx.scope ?? 'user';
281
+ switch (scope) {
282
+ case 'superuser':
283
+ return undefined;
284
+ case 'org':
285
+ return ctx.orgUserIds && ctx.orgUserIds.length > 0
286
+ ? inArray(this.table['userId'], ctx.orgUserIds as string[])
287
+ : sql`false`;
288
+ case 'user':
289
+ default:
290
+ return eq(this.table['userId'], ctx.userId);
234
291
  }
235
- return query;
292
+ }
293
+
294
+ /**
295
+ * Combine the ambient scope predicate (and, optionally, the soft-delete
296
+ * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
297
+ * when nothing applies. Used by read + by-id write paths so a single
298
+ * `.where(...)` carries every guard.
299
+ */
300
+ protected scopeAnd(
301
+ extra?: SQL,
302
+ opts?: { softDelete?: boolean },
303
+ ): SQL | undefined {
304
+ const conditions: SQL[] = [];
305
+ if (opts?.softDelete) conditions.push(isNull(this.table['deletedAt']));
306
+ const scope = this.scopePredicate();
307
+ if (scope) conditions.push(scope);
308
+ if (extra) conditions.push(extra);
309
+ if (conditions.length === 0) return undefined;
310
+ if (conditions.length === 1) return conditions[0];
311
+ return and(...conditions);
236
312
  }
237
313
 
238
314
  /**
@@ -4,6 +4,19 @@
4
4
  export { BaseRepository } from './base-repository';
5
5
  export type { BehaviorConfig, ListOptions } from './base-repository';
6
6
 
7
+ // Ambient tenant scope (AsyncLocalStorage) — read by BaseRepository.scopePredicate,
8
+ // set at request/worker boundaries via withRequester/withUserScope/etc.
9
+ export {
10
+ withRequester,
11
+ requireRequester,
12
+ tryGetRequester,
13
+ requireRequesterScope,
14
+ withUserScope,
15
+ withOrgScope,
16
+ withSuperuserScope,
17
+ } from './tenant-context';
18
+ export type { RequesterContext, RequesterScope } from './tenant-context';
19
+
7
20
  export { BaseService } from './base-service';
8
21
  export type { IBaseRepository } from './base-service';
9
22
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Ambient requester context — AsyncLocalStorage-backed tenant scope.
3
+ *
4
+ * The alternative to threading `userId`/`organizationId` through every
5
+ * repository/service signature. Set ONCE at each boundary the generated app
6
+ * owns, read implicitly inside `BaseRepository` (see `scopePredicate`).
7
+ *
8
+ * ## Where to set it (boundaries)
9
+ *
10
+ * - HTTP / tRPC handlers — from the authenticated `ctx.user`
11
+ * - OAuth callback controllers — from the authenticated session
12
+ * - Queue/worker `process()` — from the job's owning user after the
13
+ * job's record is loaded
14
+ *
15
+ * Each boundary wraps the rest of the request in `withRequester({ userId,
16
+ * organizationId }, () => ...)`. The context propagates through every `await`
17
+ * to all downstream repo/service calls without being passed explicitly.
18
+ *
19
+ * ## Where to read it
20
+ *
21
+ * - `BaseRepository.scopePredicate()` reads it (via `tryGetRequester` in
22
+ * lenient mode, `requireRequester` in strict mode) and filters every read
23
+ * by the ambient scope when the repo declares `userTracking: true`.
24
+ *
25
+ * ## Why AsyncLocalStorage over an explicit parameter
26
+ *
27
+ * Threading `userId` (and later `organizationId`) through dozens of method
28
+ * signatures is pure parameter pollution. Ambient context also lets a repo
29
+ * make the "I forgot to scope" mistake impossible at runtime: in strict mode
30
+ * `requireRequester()` throws when no context is active, surfacing a missing
31
+ * boundary call loudly rather than silently leaking cross-tenant data.
32
+ *
33
+ * ## Not-found semantics
34
+ *
35
+ * When a row exists but belongs to a different requester, scoped reads return
36
+ * `null`/`[]` — identical to "truly doesn't exist". No existence oracle;
37
+ * callers throw NotFound uniformly. Standard security practice.
38
+ *
39
+ * ## Testing
40
+ *
41
+ * Tests that exercise scoped repos must wrap the call in `withRequester(...)`.
42
+ * In strict mode an unwrapped call hitting `requireRequester()` throws — by
43
+ * design. In lenient mode (the default) an unwrapped call is simply unscoped.
44
+ */
45
+ import { AsyncLocalStorage } from 'node:async_hooks';
46
+
47
+ /**
48
+ * Data-visibility scope. The auth layer decides which scope a request is
49
+ * allowed to claim; the repo trusts whatever the ambient context says.
50
+ *
51
+ * - `'user'`: filter every read by `user_id = ctx.userId`. Default.
52
+ * - `'org'`: filter every read by membership in the requester's org, resolved
53
+ * via `user_id IN (ctx.orgUserIds)` rather than via a per-entity
54
+ * `organization_id` column. Works for every user-owned table and keeps repos
55
+ * single-table — the org member list is pre-resolved at the boundary.
56
+ * - `'superuser'`: no scope filter. Engineering / internal-tools only.
57
+ *
58
+ * AUTHORIZATION (who is allowed to claim each scope) lives in boundary
59
+ * middleware, not in the repo. The repo trusts the ambient context — same
60
+ * trust model as a threaded `userId`.
61
+ */
62
+ export type RequesterScope = 'user' | 'org' | 'superuser';
63
+
64
+ export interface RequesterContext {
65
+ /**
66
+ * The user making the request. Always present — even in `'org'` and
67
+ * `'superuser'` scopes it is the audit-trail "who actually did this".
68
+ */
69
+ readonly userId: string;
70
+ /**
71
+ * The organization the requester belongs to. Required when
72
+ * `scope === 'org'`; may be null for `'user'` (users with no org) and for
73
+ * `'superuser'` (cross-org reads).
74
+ */
75
+ readonly organizationId: string | null;
76
+ /**
77
+ * Data-visibility scope. Defaults to `'user'` when omitted.
78
+ */
79
+ readonly scope?: RequesterScope;
80
+ /**
81
+ * For `scope === 'org'`: the list of user IDs in the requester's org,
82
+ * pre-resolved by the boundary middleware that established the `'org'`
83
+ * scope (one `SELECT users.id WHERE organization_id = X` at the trust
84
+ * boundary). Repos use this as a literal `IN (...)` filter — they never
85
+ * JOIN to `users` themselves. Required when `scope === 'org'`.
86
+ */
87
+ readonly orgUserIds?: readonly string[];
88
+ }
89
+
90
+ const als = new AsyncLocalStorage<RequesterContext>();
91
+
92
+ /**
93
+ * Set the ambient requester context for the duration of `fn`. The context
94
+ * propagates through `await` boundaries to all downstream calls. Nesting is
95
+ * fine — an inner `withRequester` overrides the outer for its callback.
96
+ */
97
+ export function withRequester<T>(
98
+ ctx: RequesterContext,
99
+ fn: () => Promise<T>,
100
+ ): Promise<T> {
101
+ return als.run(ctx, fn);
102
+ }
103
+
104
+ /**
105
+ * Read the ambient requester context. Throws if no context is active — by
106
+ * design. Used by repos in strict scope-enforcement mode; an unwrapped call
107
+ * site is a missing boundary.
108
+ */
109
+ export function requireRequester(): RequesterContext {
110
+ const ctx = als.getStore();
111
+ if (!ctx) {
112
+ throw new Error(
113
+ 'No requester context active. Wrap the entry point in ' +
114
+ 'withRequester({ userId, organizationId }, fn). See tenant-context.ts.',
115
+ );
116
+ }
117
+ return ctx;
118
+ }
119
+
120
+ /**
121
+ * Read the ambient requester context without throwing. Returns `undefined`
122
+ * when no context is active. Used by repos in lenient scope-enforcement mode
123
+ * (the default) and by code paths that legitimately run outside a request.
124
+ */
125
+ export function tryGetRequester(): RequesterContext | undefined {
126
+ return als.getStore();
127
+ }
128
+
129
+ /**
130
+ * Resolve the effective scope for the ambient context, defaulting to `'user'`.
131
+ */
132
+ export function requireRequesterScope(): RequesterScope {
133
+ return requireRequester().scope ?? 'user';
134
+ }
135
+
136
+ /**
137
+ * Convenience helpers for setting scope explicitly. All three preserve
138
+ * `userId` in the context (audit trail) regardless of scope.
139
+ *
140
+ * - `withUserScope`: regular end-user requests. Most call sites.
141
+ * - `withOrgScope`: admin / org-shared resource access. The caller MUST verify
142
+ * the requester's role permits `'org'` before calling — the helper does not
143
+ * enforce authorization. `orgUserIds` is pre-resolved at the boundary.
144
+ * - `withSuperuserScope`: engineering scripts / internal tools. `organizationId`
145
+ * is null (cross-org is the point). Same authorization caveat applies.
146
+ */
147
+ export function withUserScope<T>(
148
+ userId: string,
149
+ organizationId: string | null,
150
+ fn: () => Promise<T>,
151
+ ): Promise<T> {
152
+ return withRequester({ userId, organizationId, scope: 'user' }, fn);
153
+ }
154
+
155
+ export function withOrgScope<T>(
156
+ userId: string,
157
+ organizationId: string,
158
+ orgUserIds: readonly string[],
159
+ fn: () => Promise<T>,
160
+ ): Promise<T> {
161
+ return withRequester(
162
+ { userId, organizationId, scope: 'org', orgUserIds },
163
+ fn,
164
+ );
165
+ }
166
+
167
+ export function withSuperuserScope<T>(
168
+ userId: string,
169
+ fn: () => Promise<T>,
170
+ ): Promise<T> {
171
+ return withRequester(
172
+ { userId, organizationId: null, scope: 'superuser' },
173
+ fn,
174
+ );
175
+ }
@@ -112,5 +112,13 @@ export {
112
112
  // Controller
113
113
  export { AuthController } from './controllers/auth.controller';
114
114
 
115
+ // Middleware — RequesterContext boundary (bridges auth → ambient tenant scope)
116
+ export {
117
+ installRequesterContext,
118
+ makeRequesterContextMiddleware,
119
+ resolveRequesterContext,
120
+ type RequesterContextOptions,
121
+ } from './middleware/requester-context';
122
+
115
123
  // Module
116
124
  export { AuthModule, type AuthModuleOptions } from './auth.module';
@@ -0,0 +1,141 @@
1
+ /**
2
+ * RequesterContext boundary install — bridges authentication to ambient
3
+ * tenant scoping.
4
+ *
5
+ * This is the missing link that makes `BaseRepository`'s ambient scoping
6
+ * (see `base-classes/tenant-context.ts`) actually engage on HTTP requests:
7
+ * it reads the requester off each request (via the consumer-bound
8
+ * `IUserContext`) and runs the rest of the request inside `withRequester(...)`,
9
+ * so every downstream repository read/write is automatically scoped — no
10
+ * threaded `userId`.
11
+ *
12
+ * ## Wiring (one line in your bootstrap)
13
+ *
14
+ * In `main.ts`, after `NestFactory.create`:
15
+ *
16
+ * ```ts
17
+ * import { installRequesterContext } from './shared/subsystems/auth/middleware/requester-context';
18
+ * const app = await NestFactory.create(AppModule);
19
+ * installRequesterContext(app); // no-op + warn if AUTH_USER_CONTEXT is unbound
20
+ * ```
21
+ *
22
+ * `installRequesterContext` resolves `AUTH_USER_CONTEXT` from the root DI
23
+ * container (so it sees the binding the consumer provides in AppModule) and
24
+ * registers a global Express middleware. Pairs with Swagger's `@ApiBearerAuth`
25
+ * "Authorize" button: paste a token there and every request it sends now flows
26
+ * through this boundary into a scoped repository call.
27
+ *
28
+ * ## Trust + failure model
29
+ *
30
+ * - The middleware TRUSTS whatever `IUserContext` returns — authentication and
31
+ * authorization (validating the token, deciding which scope a requester may
32
+ * claim) are the `IUserContext` implementation's job, exactly as for a
33
+ * hand-threaded `userId`.
34
+ * - When the requester cannot be resolved (no/invalid credentials — e.g. a
35
+ * public route, or the OAuth callback itself), the request proceeds WITHOUT
36
+ * an ambient context (`onUnresolved: 'unscoped'`, the default). A
37
+ * `userTracking` repo in lenient mode then runs unscoped; in strict mode it
38
+ * throws downstream — which is correct: unauthenticated callers must not
39
+ * reach scoped data. Set `onUnresolved: 'reject'` to fail the request at the
40
+ * boundary instead.
41
+ */
42
+ import type { INestApplication } from '@nestjs/common';
43
+ import {
44
+ withRequester,
45
+ type RequesterContext,
46
+ } from '../../../base-classes/tenant-context';
47
+ import { AUTH_USER_CONTEXT } from '../auth.tokens';
48
+ import type { IUserContext } from '../protocols/user-context';
49
+
50
+ /** Minimal Express-style middleware signature (avoids an `express` dep). */
51
+ type NextFn = (err?: unknown) => void;
52
+ type RequestHandler = (req: unknown, res: unknown, next: NextFn) => void;
53
+
54
+ export interface RequesterContextOptions {
55
+ /**
56
+ * What to do when `IUserContext` cannot resolve a requester (throws, or
57
+ * returns no `userId`).
58
+ * - `'unscoped'` (default): proceed without a context — public routes work;
59
+ * scoped repos run unscoped (lenient) or throw downstream (strict).
60
+ * - `'reject'`: fail the request at the boundary (`next(error)`).
61
+ */
62
+ onUnresolved?: 'unscoped' | 'reject';
63
+ }
64
+
65
+ /**
66
+ * Resolve the ambient context for a request: prefer the richer
67
+ * `resolveRequester` (org/superuser), else derive plain `'user'` scope from
68
+ * `getCurrentUserId`. Returns `undefined` when no requester can be determined.
69
+ */
70
+ export async function resolveRequesterContext(
71
+ userContext: IUserContext,
72
+ req: unknown,
73
+ ): Promise<RequesterContext | undefined> {
74
+ if (typeof userContext.resolveRequester === 'function') {
75
+ const ctx = await userContext.resolveRequester(req);
76
+ return ctx?.userId ? ctx : undefined;
77
+ }
78
+ const userId = await userContext.getCurrentUserId(req);
79
+ return userId ? { userId, organizationId: null } : undefined;
80
+ }
81
+
82
+ /**
83
+ * Build the global middleware. Runs the remainder of the request inside
84
+ * `withRequester(...)` so the ambient context propagates through every `await`
85
+ * to downstream repositories.
86
+ */
87
+ export function makeRequesterContextMiddleware(
88
+ userContext: IUserContext,
89
+ options: RequesterContextOptions = {},
90
+ ): RequestHandler {
91
+ const onUnresolved = options.onUnresolved ?? 'unscoped';
92
+ return (req, _res, next) => {
93
+ resolveRequesterContext(userContext, req).then(
94
+ (ctx) => {
95
+ if (!ctx) {
96
+ next();
97
+ return;
98
+ }
99
+ // als.run executes its callback synchronously; Express dispatches the
100
+ // rest of the pipeline inside next(), so all downstream handlers (and
101
+ // their awaits) inherit this context.
102
+ withRequester(ctx, async () => {
103
+ next();
104
+ });
105
+ },
106
+ (err) => {
107
+ if (onUnresolved === 'reject') {
108
+ next(err);
109
+ return;
110
+ }
111
+ next();
112
+ },
113
+ );
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Register the requester-context boundary on a Nest app. Resolves
119
+ * `AUTH_USER_CONTEXT` from the root container (so it sees the consumer's
120
+ * AppModule binding) and installs the global middleware. No-ops with a warning
121
+ * when `AUTH_USER_CONTEXT` is not bound, so calling it unconditionally in
122
+ * bootstrap is safe.
123
+ */
124
+ export function installRequesterContext(
125
+ app: INestApplication,
126
+ options: RequesterContextOptions = {},
127
+ ): void {
128
+ const userContext = app.get<IUserContext>(AUTH_USER_CONTEXT, {
129
+ strict: false,
130
+ });
131
+ if (!userContext) {
132
+ // eslint-disable-next-line no-console
133
+ console.warn(
134
+ '[auth] installRequesterContext: AUTH_USER_CONTEXT is not bound — ' +
135
+ 'request scoping NOT installed. Provide an IUserContext under ' +
136
+ 'AUTH_USER_CONTEXT in your AppModule to enable ambient tenant scoping.',
137
+ );
138
+ return;
139
+ }
140
+ app.use(makeRequesterContextMiddleware(userContext, options));
141
+ }
@@ -17,6 +17,23 @@
17
17
  * dependency on `express` / `fastify` / NestJS request types. The concrete
18
18
  * adapter narrows it (e.g. via a `Request` import).
19
19
  */
20
+ import type { RequesterContext } from '../../../base-classes/tenant-context';
21
+
20
22
  export interface IUserContext {
21
23
  getCurrentUserId(req: unknown): Promise<string>;
24
+ /**
25
+ * Optional richer resolution of the full ambient requester context — the
26
+ * org/superuser dimensions on top of `userId`. When implemented, the
27
+ * `RequesterContextMiddleware` (see `../middleware/requester-context`) uses
28
+ * it verbatim to scope reads/writes; when omitted, the middleware falls back
29
+ * to `{ userId: await getCurrentUserId(req), organizationId: null }` (plain
30
+ * `'user'` scope).
31
+ *
32
+ * Implement this when the app supports org-shared (`'org'`) or admin
33
+ * (`'superuser'`) data visibility — resolve `organizationId` + the
34
+ * `orgUserIds` member list here, at the trust boundary, so repositories stay
35
+ * single-table. AUTHORIZATION (which scope a requester may claim) is the
36
+ * implementation's responsibility; the repo trusts what this returns.
37
+ */
38
+ resolveRequester?(req: unknown): Promise<RequesterContext>;
22
39
  }