nestjs-ddd-cli 3.2.2 → 3.3.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.
Files changed (210) hide show
  1. package/README.md +118 -9
  2. package/ddd.schema.json +11 -1
  3. package/dist/commands/doctor-enhanced.d.ts +1 -0
  4. package/dist/commands/doctor-enhanced.js +94 -79
  5. package/dist/commands/doctor-enhanced.js.map +1 -1
  6. package/dist/commands/doctor.d.ts +9 -0
  7. package/dist/commands/doctor.js +68 -21
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/generate-all.js +54 -28
  10. package/dist/commands/generate-all.js.map +1 -1
  11. package/dist/commands/generate-controller.d.ts +1 -0
  12. package/dist/commands/generate-controller.js +71 -0
  13. package/dist/commands/generate-controller.js.map +1 -0
  14. package/dist/commands/generate-domain-service.d.ts +1 -1
  15. package/dist/commands/generate-domain-service.js +6 -4
  16. package/dist/commands/generate-domain-service.js.map +1 -1
  17. package/dist/commands/generate-dto.d.ts +4 -0
  18. package/dist/commands/generate-dto.js +162 -0
  19. package/dist/commands/generate-dto.js.map +1 -0
  20. package/dist/commands/generate-entity.js +7 -2
  21. package/dist/commands/generate-entity.js.map +1 -1
  22. package/dist/commands/generate-event.js +10 -4
  23. package/dist/commands/generate-event.js.map +1 -1
  24. package/dist/commands/generate-migration-deployment.d.ts +15 -0
  25. package/dist/commands/generate-migration-deployment.js +326 -0
  26. package/dist/commands/generate-migration-deployment.js.map +1 -0
  27. package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
  28. package/dist/commands/generate-migration-deployment.spec.js +337 -0
  29. package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
  30. package/dist/commands/generate-module.js +4 -2
  31. package/dist/commands/generate-module.js.map +1 -1
  32. package/dist/commands/generate-query.js +23 -4
  33. package/dist/commands/generate-query.js.map +1 -1
  34. package/dist/commands/generate-repository.d.ts +1 -1
  35. package/dist/commands/generate-repository.js +17 -34
  36. package/dist/commands/generate-repository.js.map +1 -1
  37. package/dist/commands/generate-service.js +19 -4
  38. package/dist/commands/generate-service.js.map +1 -1
  39. package/dist/commands/generate-usecase.js +12 -31
  40. package/dist/commands/generate-usecase.js.map +1 -1
  41. package/dist/commands/generate-value-object.js +3 -14
  42. package/dist/commands/generate-value-object.js.map +1 -1
  43. package/dist/commands/init-project.d.ts +3 -0
  44. package/dist/commands/init-project.js +19 -9
  45. package/dist/commands/init-project.js.map +1 -1
  46. package/dist/commands/interactive-scaffold.js +10 -0
  47. package/dist/commands/interactive-scaffold.js.map +1 -1
  48. package/dist/commands/migration.d.ts +3 -0
  49. package/dist/commands/migration.js +83 -48
  50. package/dist/commands/migration.js.map +1 -1
  51. package/dist/commands/recipe.d.ts +2 -0
  52. package/dist/commands/recipe.js +229 -85
  53. package/dist/commands/recipe.js.map +1 -1
  54. package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
  55. package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
  56. package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
  57. package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
  58. package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
  59. package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
  60. package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
  61. package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
  62. package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
  63. package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
  64. package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
  65. package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
  66. package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
  67. package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
  68. package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
  69. package/dist/commands/recipes/index.d.ts +11 -0
  70. package/dist/commands/recipes/index.js +11 -0
  71. package/dist/commands/recipes/index.js.map +1 -1
  72. package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
  73. package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
  74. package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
  75. package/dist/commands/recipes/joi-env.recipe.js +1 -1
  76. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  77. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  79. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  80. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  82. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  83. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  85. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  86. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  88. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  89. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  91. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  92. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  94. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  95. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  96. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  97. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  98. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  100. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  101. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  103. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  104. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  106. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  107. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  109. package/dist/index.js +77 -9
  110. package/dist/index.js.map +1 -1
  111. package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
  112. package/dist/templates/ai-context/conventions.md.hbs +13 -8
  113. package/dist/templates/command/create-command.hbs +8 -3
  114. package/dist/templates/command/delete-command.hbs +1 -1
  115. package/dist/templates/command/update-command.hbs +1 -1
  116. package/dist/templates/controller/application-controller.hbs +7 -0
  117. package/dist/templates/controller/controller.hbs +6 -0
  118. package/dist/templates/dto/create-dto.hbs +17 -5
  119. package/dist/templates/dto/filter-dto.hbs +1 -1
  120. package/dist/templates/dto/response-dto.hbs +1 -1
  121. package/dist/templates/entity/entity.hbs +4 -0
  122. package/dist/templates/mapper/mapper.hbs +5 -1
  123. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  124. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  125. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  126. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  127. package/dist/templates/query/get-all.query.hbs +10 -2
  128. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  129. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  130. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  131. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  132. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  133. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  134. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  135. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  136. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  137. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  138. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  139. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  140. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  141. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  142. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  146. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  147. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  153. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  154. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  155. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  156. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  157. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  158. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  160. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  161. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  162. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  163. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  164. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  165. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  166. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  167. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  168. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  169. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  170. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  179. package/dist/templates/repository/repository.hbs +42 -4
  180. package/dist/templates/resolver/graphql-input.hbs +23 -6
  181. package/dist/templates/resolver/resolver.hbs +4 -0
  182. package/dist/templates/service/domain-service.hbs +2 -2
  183. package/dist/templates/test/controller.spec.hbs +5 -1
  184. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  185. package/dist/templates/test/repository.spec.hbs +40 -1
  186. package/dist/templates/test/usecase.spec.hbs +23 -4
  187. package/dist/templates/usecase/application-usecase.hbs +17 -0
  188. package/dist/templates/usecase/create-usecase.hbs +34 -3
  189. package/dist/templates/usecase/update-usecase.hbs +15 -1
  190. package/dist/utils/config.utils.d.ts +2 -0
  191. package/dist/utils/config.utils.js +14 -2
  192. package/dist/utils/config.utils.js.map +1 -1
  193. package/dist/utils/dependency.utils.d.ts +16 -2
  194. package/dist/utils/dependency.utils.js +100 -12
  195. package/dist/utils/dependency.utils.js.map +1 -1
  196. package/dist/utils/field.utils.d.ts +6 -0
  197. package/dist/utils/field.utils.js +50 -8
  198. package/dist/utils/field.utils.js.map +1 -1
  199. package/dist/utils/field.utils.spec.d.ts +1 -0
  200. package/dist/utils/field.utils.spec.js +38 -0
  201. package/dist/utils/field.utils.spec.js.map +1 -0
  202. package/dist/utils/file.utils.d.ts +23 -1
  203. package/dist/utils/file.utils.js +37 -1
  204. package/dist/utils/file.utils.js.map +1 -1
  205. package/dist/utils/naming.utils.js +5 -4
  206. package/dist/utils/naming.utils.js.map +1 -1
  207. package/dist/utils/static-recipe.utils.d.ts +4 -0
  208. package/dist/utils/static-recipe.utils.js +148 -0
  209. package/dist/utils/static-recipe.utils.js.map +1 -0
  210. package/package.json +12 -5
@@ -0,0 +1,343 @@
1
+ export interface BoundedJsonHttpOptions {
2
+ /** Exact origin from validated deployment configuration; no path or request data. */
3
+ origin: string;
4
+ requestHeaders?: Readonly<Record<string, string>>;
5
+ timeoutMs?: number;
6
+ maxResponseBytes?: number;
7
+ trustedInternalHttp?: boolean;
8
+ transport?: (url: string, init: RequestInit) => Promise<Response>;
9
+ }
10
+
11
+ export class BoundedJsonHttpUnavailableError extends Error {
12
+ constructor(
13
+ readonly reason: 'timeout' | 'network' | 'http' | 'invalid_response',
14
+ readonly status?: number,
15
+ ) {
16
+ super('Provider validation unavailable');
17
+ this.name = 'BoundedJsonHttpUnavailableError';
18
+ }
19
+ }
20
+
21
+ function bounded(
22
+ value: number | undefined,
23
+ fallback: number,
24
+ min: number,
25
+ max: number,
26
+ ): number {
27
+ const result = value ?? fallback;
28
+ if (!Number.isSafeInteger(result) || result < min || result > max)
29
+ throw new Error();
30
+ return result;
31
+ }
32
+
33
+ function freeze(value: unknown, depth = 0): void {
34
+ if (depth > 32) throw new BoundedJsonHttpUnavailableError('invalid_response');
35
+ if (value !== null && typeof value === 'object') {
36
+ for (const child of Object.values(value)) freeze(child, depth + 1);
37
+ Object.freeze(value);
38
+ }
39
+ }
40
+
41
+ function hasControls(value: string): boolean {
42
+ return [...value].some((character) => {
43
+ const code = character.charCodeAt(0);
44
+ return code < 32 || code === 127;
45
+ });
46
+ }
47
+
48
+ function pathSegments(value: unknown): value is readonly string[] {
49
+ return (
50
+ Array.isArray(value) &&
51
+ value.length <= 32 &&
52
+ value.every(
53
+ (segment: unknown) =>
54
+ typeof segment === 'string' &&
55
+ segment.length <= 1024 &&
56
+ !['.', '..'].includes(segment) &&
57
+ !hasControls(segment),
58
+ )
59
+ );
60
+ }
61
+
62
+ /** Minimal JSON transport. It neither authenticates a person nor grants permissions. */
63
+ export class BoundedJsonHttpClient {
64
+ private readonly origin: string;
65
+ readonly #headers: Readonly<Record<string, string>>;
66
+ private readonly timeoutMs: number;
67
+ private readonly maxResponseBytes: number;
68
+ private readonly transport: (
69
+ url: string,
70
+ init: RequestInit,
71
+ ) => Promise<Response>;
72
+
73
+ constructor(options: BoundedJsonHttpOptions) {
74
+ try {
75
+ const url = new URL(options.origin);
76
+ if (
77
+ url.username ||
78
+ url.password ||
79
+ url.hash ||
80
+ url.search ||
81
+ url.pathname !== '/' ||
82
+ (url.protocol !== 'https:' &&
83
+ !(url.protocol === 'http:' && options.trustedInternalHttp === true))
84
+ )
85
+ throw new Error();
86
+ this.origin = url.origin;
87
+ const headers = new Headers({ accept: 'application/json' });
88
+ for (const [key, value] of Object.entries(options.requestHeaders ?? {})) {
89
+ if (['accept', 'content-type'].includes(key.toLowerCase()))
90
+ throw new Error();
91
+ headers.set(key, value);
92
+ }
93
+ this.#headers = Object.freeze(Object.fromEntries(headers.entries()));
94
+ this.timeoutMs = bounded(options.timeoutMs, 3000, 10, 30000);
95
+ this.maxResponseBytes = bounded(
96
+ options.maxResponseBytes,
97
+ 65536,
98
+ 128,
99
+ 1048576,
100
+ );
101
+ this.transport = options.transport ?? ((url, init) => fetch(url, init));
102
+ } catch {
103
+ throw new Error('Invalid provider HTTP configuration');
104
+ }
105
+ }
106
+
107
+ get(
108
+ pathSegments: readonly string[],
109
+ query: Readonly<Record<string, string>> = {},
110
+ ): Promise<unknown> {
111
+ return this.execute('GET', pathSegments, query);
112
+ }
113
+
114
+ postForm(
115
+ pathSegments: readonly string[],
116
+ form: Readonly<Record<string, string>>,
117
+ ): Promise<unknown> {
118
+ if (Object.values(form).some((value) => typeof value !== 'string'))
119
+ throw new Error('Invalid provider request configuration');
120
+ const body = new URLSearchParams(form).toString();
121
+ if (new TextEncoder().encode(body).byteLength > 262144)
122
+ throw new Error('Invalid provider request configuration');
123
+ return this.execute(
124
+ 'POST',
125
+ pathSegments,
126
+ {},
127
+ body,
128
+ 'application/x-www-form-urlencoded',
129
+ );
130
+ }
131
+
132
+ postJson(
133
+ pathSegments: readonly string[],
134
+ payload: unknown,
135
+ ): Promise<unknown> {
136
+ return this.json('POST', pathSegments, payload);
137
+ }
138
+
139
+ patchJson(
140
+ pathSegments: readonly string[],
141
+ payload: unknown,
142
+ ): Promise<unknown> {
143
+ return this.json('PATCH', pathSegments, payload);
144
+ }
145
+
146
+ putJson(
147
+ pathSegments: readonly string[],
148
+ payload: unknown,
149
+ query: Readonly<Record<string, string>> = {},
150
+ ): Promise<unknown> {
151
+ return this.json('PUT', pathSegments, payload, query);
152
+ }
153
+
154
+ private json(
155
+ method: 'POST' | 'PATCH' | 'PUT',
156
+ pathSegments: readonly string[],
157
+ payload: unknown,
158
+ query: Readonly<Record<string, string>> = {},
159
+ ): Promise<unknown> {
160
+ let nodes = 0;
161
+ const seen = new Set<object>();
162
+ const visit = (value: unknown, depth: number): void => {
163
+ if (++nodes > 10000 || depth > 32) throw new Error();
164
+ if (
165
+ value === null ||
166
+ typeof value === 'string' ||
167
+ typeof value === 'boolean'
168
+ )
169
+ return;
170
+ if (typeof value === 'number' && Number.isFinite(value)) return;
171
+ if (typeof value !== 'object' || !value || seen.has(value))
172
+ throw new Error();
173
+ seen.add(value);
174
+ if (Array.isArray(value)) {
175
+ if (Object.getPrototypeOf(value) !== Array.prototype) throw new Error();
176
+ for (let i = 0; i < value.length; i++) {
177
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
178
+ if (!descriptor || !('value' in descriptor)) throw new Error();
179
+ visit(descriptor.value, depth + 1);
180
+ }
181
+ if (Object.getOwnPropertyNames(value).length !== value.length + 1)
182
+ throw new Error();
183
+ } else {
184
+ const prototype: unknown = Object.getPrototypeOf(value);
185
+ if (prototype !== Object.prototype && prototype !== null)
186
+ throw new Error();
187
+ for (const descriptor of Object.values(
188
+ Object.getOwnPropertyDescriptors(value),
189
+ )) {
190
+ if (!descriptor.enumerable || !('value' in descriptor))
191
+ throw new Error();
192
+ visit(descriptor.value, depth + 1);
193
+ }
194
+ }
195
+ if (Object.getOwnPropertySymbols(value).length) throw new Error();
196
+ seen.delete(value);
197
+ };
198
+ try {
199
+ visit(payload, 0);
200
+ const body = JSON.stringify(payload);
201
+ if (new TextEncoder().encode(body).byteLength > 262144) throw new Error();
202
+ return this.execute(method, pathSegments, query, body, 'application/json');
203
+ } catch {
204
+ throw new Error('Invalid provider request configuration');
205
+ }
206
+ }
207
+
208
+ private async execute(
209
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT',
210
+ segments: readonly string[],
211
+ query: Readonly<Record<string, string>>,
212
+ body?: string,
213
+ contentType?: string,
214
+ ): Promise<unknown> {
215
+ if (!pathSegments(segments)) {
216
+ throw new Error('Invalid provider request configuration');
217
+ }
218
+ const entries = Object.entries(query);
219
+ if (
220
+ entries.length > 32 ||
221
+ entries.some(
222
+ ([key, value]) =>
223
+ !key ||
224
+ key.length > 128 ||
225
+ typeof value !== 'string' ||
226
+ value.length > 2048 ||
227
+ hasControls(key + value),
228
+ )
229
+ ) {
230
+ throw new Error('Invalid provider request configuration');
231
+ }
232
+ let url: URL;
233
+ try {
234
+ url = new URL(
235
+ `${this.origin}/${segments.map((segment) => encodeURIComponent(segment)).join('/')}`,
236
+ );
237
+ for (const [key, value] of entries) url.searchParams.set(key, value);
238
+ if (url.origin !== this.origin || url.href.length > 8192)
239
+ throw new Error();
240
+ } catch {
241
+ throw new Error('Invalid provider request configuration');
242
+ }
243
+ const abort = new AbortController();
244
+ let timer: ReturnType<typeof setTimeout> | undefined;
245
+ const timeout = new Promise<never>((_, reject) => {
246
+ timer = setTimeout(() => {
247
+ abort.abort();
248
+ reject(new BoundedJsonHttpUnavailableError('timeout'));
249
+ }, this.timeoutMs);
250
+ });
251
+ try {
252
+ return await Promise.race([
253
+ this.request(url.href, method, body, abort.signal, contentType),
254
+ timeout,
255
+ ]);
256
+ } catch (error) {
257
+ if (error instanceof BoundedJsonHttpUnavailableError) throw error;
258
+ throw new BoundedJsonHttpUnavailableError('network');
259
+ } finally {
260
+ clearTimeout(timer);
261
+ abort.abort();
262
+ }
263
+ }
264
+
265
+ private async request(
266
+ url: string,
267
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT',
268
+ body: string | undefined,
269
+ signal: AbortSignal,
270
+ contentType?: string,
271
+ ): Promise<unknown> {
272
+ const response = await this.transport(url, {
273
+ method,
274
+ body,
275
+ headers:
276
+ method !== 'GET'
277
+ ? {
278
+ ...this.#headers,
279
+ 'content-type':
280
+ contentType ?? 'application/x-www-form-urlencoded',
281
+ }
282
+ : { ...this.#headers },
283
+ redirect: 'error',
284
+ cache: 'no-store',
285
+ signal,
286
+ });
287
+ if (!response.ok || response.redirected) {
288
+ void response.body?.cancel().catch(() => undefined);
289
+ throw new BoundedJsonHttpUnavailableError('http', response.status);
290
+ }
291
+ if (signal.aborted) {
292
+ void response.body?.cancel().catch(() => undefined);
293
+ throw new BoundedJsonHttpUnavailableError('timeout');
294
+ }
295
+ const length = response.headers.get('content-length');
296
+ if (
297
+ length !== null &&
298
+ (!/^\d+$/.test(length) || Number(length) > this.maxResponseBytes)
299
+ ) {
300
+ void response.body?.cancel().catch(() => undefined);
301
+ throw new BoundedJsonHttpUnavailableError('invalid_response');
302
+ }
303
+ if (!response.body)
304
+ throw new BoundedJsonHttpUnavailableError('invalid_response');
305
+ const reader = response.body.getReader();
306
+ const cancel = () => {
307
+ void reader.cancel().catch(() => undefined);
308
+ };
309
+ signal.addEventListener('abort', cancel, { once: true });
310
+ const chunks: Uint8Array[] = [];
311
+ let size = 0;
312
+ try {
313
+ while (true) {
314
+ const { done, value } = await reader.read();
315
+ if (done) break;
316
+ size += value.byteLength;
317
+ if (size > this.maxResponseBytes)
318
+ throw new BoundedJsonHttpUnavailableError('invalid_response');
319
+ chunks.push(value);
320
+ }
321
+ const bytes = new Uint8Array(size);
322
+ let offset = 0;
323
+ for (const chunk of chunks) {
324
+ bytes.set(chunk, offset);
325
+ offset += chunk.byteLength;
326
+ }
327
+ try {
328
+ const parsed: unknown = JSON.parse(
329
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes),
330
+ );
331
+ freeze(parsed);
332
+ return parsed;
333
+ } catch (error) {
334
+ if (error instanceof BoundedJsonHttpUnavailableError) throw error;
335
+ throw new BoundedJsonHttpUnavailableError('invalid_response');
336
+ }
337
+ } finally {
338
+ signal.removeEventListener('abort', cancel);
339
+ void reader.cancel().catch(() => undefined);
340
+ reader.releaseLock();
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,3 @@
1
+ export * from './oauth2-token-introspection.client';
2
+ export * from './oauth2-token-introspection.module';
3
+ export * from './bounded-json-http.client';
@@ -0,0 +1,58 @@
1
+ import {
2
+ DynamicModule,
3
+ FactoryProvider,
4
+ Module,
5
+ ModuleMetadata,
6
+ } from '@nestjs/common';
7
+ import {
8
+ OAUTH2_TOKEN_INTROSPECTION_OPTIONS,
9
+ OAuth2TokenIntrospectionClient,
10
+ OAuth2TokenIntrospectionOptions,
11
+ } from './oauth2-token-introspection.client';
12
+
13
+ export interface OAuth2TokenIntrospectionModuleAsyncOptions extends Pick<
14
+ ModuleMetadata,
15
+ 'imports'
16
+ > {
17
+ inject?: FactoryProvider<OAuth2TokenIntrospectionOptions>['inject'];
18
+ useFactory: FactoryProvider<OAuth2TokenIntrospectionOptions>['useFactory'];
19
+ }
20
+
21
+ const clientProvider: FactoryProvider<OAuth2TokenIntrospectionClient> = {
22
+ provide: OAuth2TokenIntrospectionClient,
23
+ inject: [OAUTH2_TOKEN_INTROSPECTION_OPTIONS],
24
+ useFactory: (options: OAuth2TokenIntrospectionOptions) =>
25
+ new OAuth2TokenIntrospectionClient(options),
26
+ };
27
+
28
+ @Module({})
29
+ export class OAuth2TokenIntrospectionModule {
30
+ static register(options: OAuth2TokenIntrospectionOptions): DynamicModule {
31
+ return {
32
+ module: OAuth2TokenIntrospectionModule,
33
+ providers: [
34
+ { provide: OAUTH2_TOKEN_INTROSPECTION_OPTIONS, useValue: options },
35
+ clientProvider,
36
+ ],
37
+ exports: [OAuth2TokenIntrospectionClient],
38
+ };
39
+ }
40
+
41
+ static registerAsync(
42
+ options: OAuth2TokenIntrospectionModuleAsyncOptions,
43
+ ): DynamicModule {
44
+ return {
45
+ module: OAuth2TokenIntrospectionModule,
46
+ imports: options.imports ?? [],
47
+ providers: [
48
+ {
49
+ provide: OAUTH2_TOKEN_INTROSPECTION_OPTIONS,
50
+ inject: options.inject ?? [],
51
+ useFactory: options.useFactory,
52
+ },
53
+ clientProvider,
54
+ ],
55
+ exports: [OAuth2TokenIntrospectionClient],
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,96 @@
1
+ # Confidential OIDC BFF sessions
2
+
3
+ Run `ddd recipe oidc-bff-session --path <app> --dry-run` before applying the same
4
+ command without `--dry-run`. Generated files live in `src/shared/oidc-bff`.
5
+ Generation preflights every target, rejects symlinks/different owner content,
6
+ preserves identical reruns and rolls back only unchanged files it created.
7
+ No module, route, cookie, environment variable, business policy, package manifest,
8
+ lockfile or provider configuration is changed automatically.
9
+
10
+ Consumer runtime requirements: Node 20+ or Bun, `jose@6.2.12` and
11
+ `@redis/client@6.3.0`. Add these through the app's approved dependency workflow;
12
+ on the local Bun host use `bun add --exact jose@6.2.12 @redis/client@6.3.0`.
13
+ Preserve an existing CI/container package-manager contract and its lockfile.
14
+
15
+ `createOidcBffClient` takes immutable explicit issuer, client ID/secret, exact
16
+ HTTPS callback, configured scopes, five same-origin HTTPS metadata endpoints
17
+ (discovery, authorization, token, revocation, JWKS), and optional configured
18
+ authorization parameters. Reserved OIDC/PKCE/state parameters cannot be
19
+ overridden. The consumer owns any provider-specific audience parameter and
20
+ exact issuer/path policy. Only `client_secret_basic`, code/S256 and RS256 signed
21
+ ID tokens are supported. Required scopes include `openid` and `offline_access`;
22
+ authenticated output requires a complete access/refresh token pair.
23
+
24
+ Supply the existing server-owned pending record to `beginAuthorization` and
25
+ `exchangeAuthorizationCode`; the client does not create a second pending record.
26
+ Fresh authentication is required. It verifies signed issuer, subject, client
27
+ audience, authorized party, nonce, expiration, issued time and authentication
28
+ time after pending start, with bounded clock tolerance. Optional signed
29
+ access/code hashes must match. Requested prompt alone does not prove fresh
30
+ authentication; the provider integration must actually perform it.
31
+
32
+ Discovery must match every configured endpoint exactly. No redirect, retry,
33
+ fallback or positive-validation cache exists. A total operation deadline covers
34
+ discovery, token exchange, token custody hook, streamed JWKS, verification and
35
+ best-effort rejected-token cleanup. Responses are capped at 64 KiB; safe token
36
+ fields at 16 KiB; public RSA JWKS at 16 keys and bounded moduli. Errors have fixed
37
+ messages and never include provider bodies, credentials or raw exceptions.
38
+
39
+ The async `onTokenPairIssued` hook stages every independently bounded safe
40
+ credential before scope/type/expiry/ID-token admission policy, including
41
+ access-only or refresh-only responses. At least one safe credential is needed.
42
+ Malformed expiry uses `maxAccessTokenSeconds` only as a retirement hint; this
43
+ fallback is never admitted as a verified token set. Persist the retirement
44
+ record before proceeding. Persistence failure rejects sign-in and attempts
45
+ immediate revocation. A wholly malformed/oversize response cannot safely
46
+ disclose token material; it is rejected without session admission.
47
+
48
+ `OpaqueSessionStore` accepts injected `SessionCommands`, a trusted stable client
49
+ namespace, optional clock (integer seconds) and immutable duration settings.
50
+ Browser cookies contain only random handles; pending PKCE and token material
51
+ remain server-side. Client-hashed Redis Cluster slots isolate keys. Lua owns
52
+ one-time pending claim/completion, browser/generation/client binding, fresh
53
+ account switch, current-session index, logout, staged exact-pair transfer and
54
+ durable revocation enqueue/lease/ack/retry. Only successful exact pending and
55
+ pair commit removes the staged ticket. Once any worker claims retirement, a
56
+ durable non-admissible marker prevents later session commit through retries or
57
+ expired leases; only completed retirement removes that marker. Expired access retains refresh custody.
58
+ Restart uses the same backend/client namespace; there is no local fallback.
59
+
60
+ Defaults: session 3300s, pending 600s, browser generation 1 year, provider access
61
+ maximum 3600s, provider refresh maximum 86400s, token custody 90000s, lease 30s,
62
+ retry 60s. These are conservative documented defaults, not discovery facts.
63
+ The owner must configure provider lifetime bounds explicitly and keep custody
64
+ strictly longer than both provider maxima. Options are bounded and cannot
65
+ reduce custody below those bounds. Retirement expiry hints are not session
66
+ admission. Do not expire the whole token row with browser access expiry.
67
+
68
+ `createValkeySessionCommands` takes an explicit trusted URL, timeout and optional
69
+ test connection seam. TLS (`rediss`) is default; plaintext requires an explicit
70
+ trusted-internal opt-in. Connect and command share a bounded deadline, disable
71
+ offline queues/reconnects, and share one connection attempt. No command runs
72
+ after its connection deadline. `close` permanently retires this command owner.
73
+ No URL/token logging occurs. The Redis library owns RESP framing; this adapter
74
+ bounds deadlines/queued commands and request arguments, and the store validates
75
+ returned records. It does not claim streamed HTTP-style response byte control.
76
+
77
+ Each app still owns configuration, cookie flags, same-origin guards, Next
78
+ server-only marker, authorization wrappers, locale/navigation and durable worker
79
+ scheduling. Each request must perform current provider and canonical domain
80
+ admission. Provider traits, JWT subject or an existing browser session are not
81
+ business permissions. Per-app token retirement leaves shared provider SSO;
82
+ global logout requires its explicitly owned provider operation.
83
+
84
+ Run generated unit cases with `bun test src/shared/oidc-bff`. Actual Lua proof
85
+ additionally requires `OIDC_BFF_TEST_VALKEY_URL` pointing to a disposable Redis
86
+ database with a separately authorized profile. Tests use random client
87
+ namespaces and never flush, stop or alter the server. Unit/source evidence is
88
+ separate from CI, deployed provider behavior, browser and authorized actor proof.
89
+
90
+ Storage custody: key hashing and Redis Cluster hash tags do not encrypt the JSON
91
+ values. Access/refresh credentials and PKCE material are plaintext within the
92
+ server store. Production activation requires authenticated app-scoped Redis
93
+ ACLs, verified private/TLS transport, and verified encryption/access controls
94
+ for persistent storage and backups. An unauthenticated shared Redis instance is
95
+ not a suitable production deployment. This recipe does not provision or prove
96
+ those operator controls.