nestjs-ddd-cli 3.2.1 → 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 (214) 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 +238 -176
  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 +44 -35
  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 +41 -31
  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 +282 -91
  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 +12 -0
  70. package/dist/commands/recipes/index.js +12 -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.d.ts +1 -0
  76. package/dist/commands/recipes/joi-env.recipe.js +301 -0
  77. package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
  78. package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
  79. package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
  80. package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
  81. package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
  82. package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
  83. package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
  84. package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
  85. package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
  86. package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
  87. package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
  88. package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
  89. package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
  90. package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
  91. package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
  92. package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
  93. package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
  94. package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
  95. package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
  96. package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
  97. package/dist/commands/recipes/platform-context.recipe.js +229 -0
  98. package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
  99. package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
  100. package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
  101. package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
  102. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
  103. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
  104. package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
  105. package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
  106. package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
  107. package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
  108. package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
  109. package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
  110. package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
  111. package/dist/index.js +82 -11
  112. package/dist/index.js.map +1 -1
  113. package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
  114. package/dist/templates/ai-context/conventions.md.hbs +20 -8
  115. package/dist/templates/command/create-command.hbs +8 -3
  116. package/dist/templates/command/delete-command.hbs +1 -1
  117. package/dist/templates/command/update-command.hbs +1 -1
  118. package/dist/templates/controller/application-controller.hbs +7 -0
  119. package/dist/templates/controller/controller.hbs +6 -0
  120. package/dist/templates/dto/create-dto.hbs +17 -5
  121. package/dist/templates/dto/filter-dto.hbs +1 -1
  122. package/dist/templates/dto/response-dto.hbs +1 -1
  123. package/dist/templates/entity/entity.hbs +4 -0
  124. package/dist/templates/mapper/mapper.hbs +6 -2
  125. package/dist/templates/orm-entity/orm-entity.hbs +9 -1
  126. package/dist/templates/prisma/prisma-mapper.hbs +6 -0
  127. package/dist/templates/prisma/prisma-repository.hbs +57 -5
  128. package/dist/templates/prisma/prisma-schema.hbs +2 -0
  129. package/dist/templates/query/get-all.query.hbs +10 -2
  130. package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
  131. package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
  132. package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
  133. package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
  134. package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
  135. package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
  136. package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
  137. package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
  138. package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
  139. package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
  140. package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
  141. package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
  142. package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
  143. package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
  144. package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
  145. package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
  146. package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
  147. package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
  148. package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
  149. package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
  150. package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
  151. package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
  152. package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
  153. package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
  154. package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
  155. package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
  156. package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
  157. package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
  158. package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
  159. package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
  160. package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
  161. package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
  162. package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
  163. package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
  164. package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
  165. package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
  166. package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
  167. package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
  168. package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
  169. package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
  170. package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
  171. package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
  172. package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
  173. package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
  174. package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
  175. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
  176. package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
  177. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
  178. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
  179. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
  180. package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
  181. package/dist/templates/repository/repository.hbs +43 -5
  182. package/dist/templates/resolver/graphql-input.hbs +23 -6
  183. package/dist/templates/resolver/resolver.hbs +4 -0
  184. package/dist/templates/service/domain-service.hbs +2 -2
  185. package/dist/templates/test/controller.spec.hbs +5 -1
  186. package/dist/templates/test/prisma-repository.spec.hbs +192 -0
  187. package/dist/templates/test/repository.spec.hbs +41 -2
  188. package/dist/templates/test/usecase.spec.hbs +24 -5
  189. package/dist/templates/usecase/application-usecase.hbs +17 -0
  190. package/dist/templates/usecase/create-usecase.hbs +34 -3
  191. package/dist/templates/usecase/update-usecase.hbs +15 -1
  192. package/dist/utils/config.utils.d.ts +2 -0
  193. package/dist/utils/config.utils.js +14 -2
  194. package/dist/utils/config.utils.js.map +1 -1
  195. package/dist/utils/dependency.utils.d.ts +18 -2
  196. package/dist/utils/dependency.utils.js +144 -15
  197. package/dist/utils/dependency.utils.js.map +1 -1
  198. package/dist/utils/field.utils.d.ts +6 -0
  199. package/dist/utils/field.utils.js +78 -24
  200. package/dist/utils/field.utils.js.map +1 -1
  201. package/dist/utils/field.utils.spec.d.ts +1 -0
  202. package/dist/utils/field.utils.spec.js +38 -0
  203. package/dist/utils/field.utils.spec.js.map +1 -0
  204. package/dist/utils/file.utils.d.ts +39 -3
  205. package/dist/utils/file.utils.js +150 -13
  206. package/dist/utils/file.utils.js.map +1 -1
  207. package/dist/utils/naming.utils.js +5 -4
  208. package/dist/utils/naming.utils.js.map +1 -1
  209. package/dist/utils/static-recipe.utils.d.ts +4 -0
  210. package/dist/utils/static-recipe.utils.js +148 -0
  211. package/dist/utils/static-recipe.utils.js.map +1 -0
  212. package/dist/utils/template-engine.utils.js +83 -68
  213. package/dist/utils/template-engine.utils.js.map +1 -1
  214. package/package.json +20 -11
@@ -0,0 +1,440 @@
1
+ import { UnauthorizedException } from '@nestjs/common';
2
+ import {
3
+ constants,
4
+ createPublicKey,
5
+ type JsonWebKey,
6
+ type KeyObject,
7
+ verify as verifySignature,
8
+ } from 'node:crypto';
9
+
10
+ export interface OidcResourceServerOptions {
11
+ issuer: string;
12
+ jwksUri: string;
13
+ audiences: readonly string[];
14
+ authorizedClients: readonly string[];
15
+ clockToleranceSeconds?: number;
16
+ jwksCacheTtlSeconds?: number;
17
+ jwksRefreshCooldownSeconds?: number;
18
+ jwksFetchTimeoutMs?: number;
19
+ }
20
+
21
+ export interface VerifiedOidcAccessToken {
22
+ readonly [claim: string]: unknown;
23
+ readonly sub: string;
24
+ readonly iss: string;
25
+ readonly aud: string | readonly string[];
26
+ readonly azp: string;
27
+ readonly iat: number;
28
+ readonly exp: number;
29
+ readonly nbf?: number;
30
+ }
31
+
32
+ export const OIDC_RESOURCE_SERVER_OPTIONS = Symbol('OIDC_RESOURCE_SERVER_OPTIONS');
33
+
34
+ const MAX_TOKEN_BYTES = 32 * 1024;
35
+ const MAX_HEADER_BYTES = 4 * 1024;
36
+ const MAX_PAYLOAD_BYTES = 24 * 1024;
37
+ const MAX_SIGNATURE_BYTES = 1024;
38
+ const MAX_JWKS_BYTES = 256 * 1024;
39
+ const MAX_JWKS_KEYS = 100;
40
+ const MAX_KEY_ID_LENGTH = 256;
41
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
42
+
43
+ interface JwtHeader {
44
+ readonly alg: 'RS256';
45
+ readonly kid: string;
46
+ }
47
+
48
+ interface RsaJwk extends JsonWebKey {
49
+ readonly kty: 'RSA';
50
+ readonly kid: string;
51
+ readonly n: string;
52
+ readonly e: string;
53
+ readonly alg?: string;
54
+ readonly use?: string;
55
+ readonly key_ops?: readonly string[];
56
+ }
57
+
58
+ interface JwksDocument {
59
+ readonly keys: readonly unknown[];
60
+ }
61
+
62
+ interface NormalizedOptions {
63
+ readonly issuer: string;
64
+ readonly jwksUri: string;
65
+ readonly audiences: readonly string[];
66
+ readonly authorizedClients: readonly string[];
67
+ readonly clockToleranceSeconds: number;
68
+ readonly jwksCacheTtlSeconds: number;
69
+ readonly jwksRefreshCooldownSeconds: number;
70
+ readonly jwksFetchTimeoutMs: number;
71
+ }
72
+
73
+ function assertHttpsUrl(value: string): void {
74
+ const parsed = new URL(value);
75
+ if (
76
+ parsed.protocol !== 'https:' ||
77
+ parsed.username !== '' ||
78
+ parsed.password !== '' ||
79
+ parsed.hash !== ''
80
+ ) {
81
+ throw new Error('Explicit trusted HTTPS issuer/JWKS required');
82
+ }
83
+ }
84
+
85
+ function isNonemptyList(values: readonly string[]): boolean {
86
+ return (
87
+ Array.isArray(values) &&
88
+ values.length > 0 &&
89
+ values.every(
90
+ (value) => typeof value === 'string' && value.length > 0 && value === value.trim(),
91
+ )
92
+ );
93
+ }
94
+
95
+ function boundedNumber(
96
+ value: number | undefined,
97
+ fallback: number,
98
+ minimum: number,
99
+ maximum: number,
100
+ name: string,
101
+ ): number {
102
+ const selected = value ?? fallback;
103
+ if (!Number.isFinite(selected) || selected < minimum || selected > maximum) {
104
+ throw new Error(`${name} must be between ${minimum} and ${maximum}`);
105
+ }
106
+ return selected;
107
+ }
108
+
109
+ function decodeBase64Url(segment: string, maximumBytes: number): Buffer {
110
+ if (
111
+ segment.length === 0 ||
112
+ segment.length % 4 === 1 ||
113
+ !BASE64URL.test(segment) ||
114
+ segment.length > Math.ceil((maximumBytes * 4) / 3)
115
+ ) {
116
+ throw new Error('Malformed token');
117
+ }
118
+ const decoded = Buffer.from(segment, 'base64url');
119
+ if (decoded.length > maximumBytes || decoded.toString('base64url') !== segment) {
120
+ throw new Error('Malformed token');
121
+ }
122
+ return decoded;
123
+ }
124
+
125
+ function parseJsonRecord(segment: string, maximumBytes: number): Record<string, unknown> {
126
+ const value: unknown = JSON.parse(decodeBase64Url(segment, maximumBytes).toString('utf8'));
127
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
128
+ throw new Error('Malformed token');
129
+ }
130
+ return value as Record<string, unknown>;
131
+ }
132
+
133
+ function parseHeader(segment: string): JwtHeader {
134
+ const header = parseJsonRecord(segment, MAX_HEADER_BYTES);
135
+ if (
136
+ header['alg'] !== 'RS256' ||
137
+ typeof header['kid'] !== 'string' ||
138
+ header['kid'].length === 0 ||
139
+ header['kid'].length > MAX_KEY_ID_LENGTH ||
140
+ header['kid'] !== header['kid'].trim() ||
141
+ header['crit'] !== undefined ||
142
+ header['b64'] !== undefined
143
+ ) {
144
+ throw new Error('Unsupported token header');
145
+ }
146
+ return { alg: 'RS256', kid: header['kid'] };
147
+ }
148
+
149
+ function parseJwks(value: unknown): JwksDocument {
150
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
151
+ throw new Error('Invalid JWKS');
152
+ }
153
+ const keys = (value as Record<string, unknown>)['keys'];
154
+ if (!Array.isArray(keys) || keys.length === 0 || keys.length > MAX_JWKS_KEYS) {
155
+ throw new Error('Invalid JWKS');
156
+ }
157
+ return { keys };
158
+ }
159
+
160
+ function isUsableRsaJwk(value: unknown): value is RsaJwk {
161
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
162
+ const key = value as Record<string, unknown>;
163
+ return (
164
+ key['kty'] === 'RSA' &&
165
+ typeof key['kid'] === 'string' &&
166
+ key['kid'].length > 0 &&
167
+ key['kid'].length <= MAX_KEY_ID_LENGTH &&
168
+ key['kid'] === key['kid'].trim() &&
169
+ typeof key['n'] === 'string' &&
170
+ key['n'].length > 0 &&
171
+ key['n'].length <= 2048 &&
172
+ BASE64URL.test(key['n']) &&
173
+ typeof key['e'] === 'string' &&
174
+ key['e'].length > 0 &&
175
+ key['e'].length <= 16 &&
176
+ BASE64URL.test(key['e']) &&
177
+ (key['alg'] === undefined || key['alg'] === 'RS256') &&
178
+ (key['use'] === undefined || key['use'] === 'sig') &&
179
+ (key['key_ops'] === undefined ||
180
+ (Array.isArray(key['key_ops']) &&
181
+ key['key_ops'].every((operation) => typeof operation === 'string') &&
182
+ key['key_ops'].includes('verify')))
183
+ );
184
+ }
185
+
186
+ function candidateKeyId(value: unknown): string | undefined {
187
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
188
+ const keyId = (value as Record<string, unknown>)['kid'];
189
+ return typeof keyId === 'string' ? keyId : undefined;
190
+ }
191
+
192
+ function importRsaVerificationKey(
193
+ value: unknown,
194
+ ): { keyId: string; key: KeyObject } | undefined {
195
+ if (!isUsableRsaJwk(value)) return undefined;
196
+ try {
197
+ const key = createPublicKey({ key: value, format: 'jwk' });
198
+ const modulusLength = key.asymmetricKeyDetails?.modulusLength;
199
+ if (
200
+ key.asymmetricKeyType !== 'rsa' ||
201
+ typeof modulusLength !== 'number' ||
202
+ modulusLength < 2048 ||
203
+ modulusLength > 8192
204
+ ) {
205
+ return undefined;
206
+ }
207
+ return { keyId: value.kid, key };
208
+ } catch {
209
+ // An unusable provider key is not trusted and cannot poison other valid keys.
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ function audienceClaim(value: unknown): readonly string[] {
215
+ if (typeof value === 'string' && value.length > 0) return [value];
216
+ const values: readonly unknown[] = Array.isArray(value) ? value : [];
217
+ if (
218
+ values.length > 0 &&
219
+ values.every((audience) => typeof audience === 'string' && audience.length > 0)
220
+ ) {
221
+ return values as readonly string[];
222
+ }
223
+ return [];
224
+ }
225
+
226
+ async function readBoundedBody(
227
+ response: Awaited<ReturnType<typeof fetch>>,
228
+ maximumBytes: number,
229
+ ): Promise<string> {
230
+ const declaredLength = response.headers.get('content-length');
231
+ if (declaredLength !== null) {
232
+ const parsedLength = Number(declaredLength);
233
+ if (!Number.isFinite(parsedLength) || parsedLength < 0 || parsedLength > maximumBytes) {
234
+ throw new Error('JWKS response is too large');
235
+ }
236
+ }
237
+ if (response.body === null) throw new Error('JWKS response body is missing');
238
+
239
+ const reader = response.body.getReader();
240
+ const chunks: Buffer[] = [];
241
+ let size = 0;
242
+ try {
243
+ while (true) {
244
+ const chunk = await reader.read();
245
+ if (chunk.done) break;
246
+ size += chunk.value.byteLength;
247
+ if (size > maximumBytes) {
248
+ await reader.cancel();
249
+ throw new Error('JWKS response is too large');
250
+ }
251
+ chunks.push(Buffer.from(chunk.value));
252
+ }
253
+ } finally {
254
+ reader.releaseLock();
255
+ }
256
+ return Buffer.concat(chunks, size).toString('utf8');
257
+ }
258
+
259
+ export class OidcAccessTokenVerifier {
260
+ private readonly options: NormalizedOptions;
261
+ private readonly audiences: ReadonlySet<string>;
262
+ private readonly authorizedClients: ReadonlySet<string>;
263
+ private keys = new Map<string, KeyObject>();
264
+ private keysExpireAt = 0;
265
+ private lastRefreshAttemptAt = 0;
266
+ private refreshInFlight: Promise<void> | undefined;
267
+
268
+ constructor(options: OidcResourceServerOptions) {
269
+ assertHttpsUrl(options.issuer);
270
+ assertHttpsUrl(options.jwksUri);
271
+ if (!isNonemptyList(options.audiences) || !isNonemptyList(options.authorizedClients)) {
272
+ throw new Error('Explicit resource audiences and authorized clients required');
273
+ }
274
+ this.options = {
275
+ issuer: options.issuer,
276
+ jwksUri: options.jwksUri,
277
+ audiences: [...options.audiences],
278
+ authorizedClients: [...options.authorizedClients],
279
+ clockToleranceSeconds: boundedNumber(
280
+ options.clockToleranceSeconds,
281
+ 0,
282
+ 0,
283
+ 60,
284
+ 'Clock tolerance',
285
+ ),
286
+ jwksCacheTtlSeconds: boundedNumber(
287
+ options.jwksCacheTtlSeconds,
288
+ 600,
289
+ 30,
290
+ 86400,
291
+ 'JWKS cache TTL',
292
+ ),
293
+ jwksRefreshCooldownSeconds: boundedNumber(
294
+ options.jwksRefreshCooldownSeconds,
295
+ 30,
296
+ 1,
297
+ 300,
298
+ 'JWKS refresh cooldown',
299
+ ),
300
+ jwksFetchTimeoutMs: boundedNumber(
301
+ options.jwksFetchTimeoutMs,
302
+ 5000,
303
+ 250,
304
+ 10000,
305
+ 'JWKS fetch timeout',
306
+ ),
307
+ };
308
+ this.audiences = new Set(this.options.audiences);
309
+ this.authorizedClients = new Set(this.options.authorizedClients);
310
+ }
311
+
312
+ async verify(token: string): Promise<VerifiedOidcAccessToken> {
313
+ try {
314
+ if (
315
+ typeof token !== 'string' ||
316
+ token.length === 0 ||
317
+ Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES
318
+ ) {
319
+ throw new Error('Malformed token');
320
+ }
321
+ const segments = token.split('.');
322
+ if (segments.length !== 3) throw new Error('Malformed token');
323
+ const [encodedHeader, encodedPayload, encodedSignature] = segments as [
324
+ string,
325
+ string,
326
+ string,
327
+ ];
328
+ const header = parseHeader(encodedHeader);
329
+ const payload = parseJsonRecord(encodedPayload, MAX_PAYLOAD_BYTES);
330
+ const signature = decodeBase64Url(encodedSignature, MAX_SIGNATURE_BYTES);
331
+ const key = await this.resolveKey(header.kid);
332
+ const validSignature = verifySignature(
333
+ 'RSA-SHA256',
334
+ Buffer.from(`${encodedHeader}.${encodedPayload}`, 'ascii'),
335
+ { key, padding: constants.RSA_PKCS1_PADDING },
336
+ signature,
337
+ );
338
+ if (!validSignature) throw new Error('Invalid signature');
339
+ return this.validateClaims(payload);
340
+ } catch {
341
+ // Never include bearer tokens, claims, key data or upstream bodies in errors.
342
+ throw new UnauthorizedException('Invalid resource access token');
343
+ }
344
+ }
345
+
346
+ private validateClaims(payload: Record<string, unknown>): VerifiedOidcAccessToken {
347
+ const skew = this.options.clockToleranceSeconds;
348
+ const now = Date.now() / 1000;
349
+ const audience = audienceClaim(payload['aud']);
350
+ if (
351
+ payload['iss'] !== this.options.issuer ||
352
+ typeof payload['sub'] !== 'string' ||
353
+ payload['sub'].trim().length === 0 ||
354
+ !audience.some((value) => this.audiences.has(value)) ||
355
+ typeof payload['azp'] !== 'string' ||
356
+ !this.authorizedClients.has(payload['azp']) ||
357
+ typeof payload['iat'] !== 'number' ||
358
+ !Number.isFinite(payload['iat']) ||
359
+ payload['iat'] < 0 ||
360
+ payload['iat'] > now + skew ||
361
+ typeof payload['exp'] !== 'number' ||
362
+ !Number.isFinite(payload['exp']) ||
363
+ payload['exp'] <= payload['iat'] ||
364
+ payload['exp'] <= now - skew ||
365
+ (payload['nbf'] !== undefined &&
366
+ (typeof payload['nbf'] !== 'number' ||
367
+ !Number.isFinite(payload['nbf']) ||
368
+ payload['nbf'] > now + skew))
369
+ ) {
370
+ throw new Error('Rejected claims');
371
+ }
372
+ return payload as VerifiedOidcAccessToken;
373
+ }
374
+
375
+ private async resolveKey(keyId: string): Promise<KeyObject> {
376
+ const now = Date.now();
377
+ const cached = this.keys.get(keyId);
378
+ if (cached !== undefined && now < this.keysExpireAt) return cached;
379
+
380
+ const refreshCooldownMs = this.options.jwksRefreshCooldownSeconds * 1000;
381
+ const shouldRefresh =
382
+ this.lastRefreshAttemptAt === 0 || now - this.lastRefreshAttemptAt >= refreshCooldownMs;
383
+ if (this.refreshInFlight !== undefined) await this.refreshInFlight;
384
+ else if (shouldRefresh) await this.refreshKeys();
385
+
386
+ const selected = this.keys.get(keyId);
387
+ if (selected === undefined || Date.now() >= this.keysExpireAt) {
388
+ throw new Error('No trusted key');
389
+ }
390
+ return selected;
391
+ }
392
+
393
+ private async refreshKeys(): Promise<void> {
394
+ if (this.refreshInFlight !== undefined) return this.refreshInFlight;
395
+ this.lastRefreshAttemptAt = Date.now();
396
+ const operation = this.fetchKeys().then((keys) => {
397
+ this.keys = keys;
398
+ this.keysExpireAt = Date.now() + this.options.jwksCacheTtlSeconds * 1000;
399
+ });
400
+ this.refreshInFlight = operation;
401
+ try {
402
+ await operation;
403
+ } finally {
404
+ this.refreshInFlight = undefined;
405
+ }
406
+ }
407
+
408
+ private async fetchKeys(): Promise<Map<string, KeyObject>> {
409
+ const controller = new AbortController();
410
+ const timeout = setTimeout(() => controller.abort(), this.options.jwksFetchTimeoutMs);
411
+ try {
412
+ const response = await fetch(this.options.jwksUri, {
413
+ method: 'GET',
414
+ redirect: 'error',
415
+ signal: controller.signal,
416
+ headers: { accept: 'application/json' },
417
+ });
418
+ if (!response.ok) throw new Error('JWKS request failed');
419
+ const document: unknown = JSON.parse(await readBoundedBody(response, MAX_JWKS_BYTES));
420
+ const jwks = parseJwks(document);
421
+ const seenKeyIds = new Set<string>();
422
+ const imported = new Map<string, KeyObject>();
423
+ for (const candidate of jwks.keys) {
424
+ const keyId = candidateKeyId(candidate);
425
+ if (keyId !== undefined) {
426
+ if (seenKeyIds.has(keyId)) throw new Error('Duplicate JWKS key identifier');
427
+ seenKeyIds.add(keyId);
428
+ }
429
+ const verificationKey = importRsaVerificationKey(candidate);
430
+ if (verificationKey !== undefined) {
431
+ imported.set(verificationKey.keyId, verificationKey.key);
432
+ }
433
+ }
434
+ if (imported.size === 0) throw new Error('JWKS has no usable RS256 keys');
435
+ return imported;
436
+ } finally {
437
+ clearTimeout(timeout);
438
+ }
439
+ }
440
+ }
@@ -0,0 +1,92 @@
1
+ # PARC bound authorization
2
+
3
+ Generate with `ddd recipe platform-parc-bound-authorization --dry-run`, then
4
+ without `--dry-run`. Existing differing files and symlinks are refused. No
5
+ dependency installation, role assignment, application wiring or activation occurs.
6
+
7
+ This integration uses the current `POST /v1/decisions/check-bound` contract.
8
+ PARC consumes a fresh opaque Service Access lease with this service's admitted
9
+ machine credential. Do **not** place the legacy Service Access introspection
10
+ guard before this call: leases are single-use. Do not decode the lease, construct
11
+ an actor, relabel IAM as ZITADEL, forward browser bearer/cookie headers, or fall
12
+ back to `/v1/decisions/check`. The client makes one request and does not retry.
13
+
14
+ ```typescript
15
+ const parc = new ParcBoundAuthorizationClient(config.parcHttpsOrigin, 'owner-service');
16
+ const receipt = await parc.check({
17
+ lease: opaqueLease,
18
+ credential: config.admittedServiceCredential,
19
+ permission: 'owner-service.records.update',
20
+ action: 'update',
21
+ resource: { type: 'record', id: record.id },
22
+ contextDigest: ownerVerifiedOperationDigest,
23
+ expected: { businessId: record.businessId, clientAppId: configuredApplicationId },
24
+ });
25
+ // Enforce owner invariants and receipt expiry immediately before the mutation.
26
+ // Persist decisionReference with the owner transaction and its audit/outbox.
27
+ ```
28
+
29
+ Select permission/action/resource from the owner route and persisted domain
30
+ facts, never caller-supplied role or identity headers. Supply
31
+ `expected.subjectId` when independently verified and required by the operation.
32
+ The returned subject is PARC's canonical actor, not a locally reconstructed one.
33
+ The operation digest must cover the owner-verified material being authorized.
34
+
35
+ For multi-tenant create/quote intake, use a required tenant selector in the route
36
+ as `expected.businessId`. It is a claim to check, not authenticated scope: do not
37
+ introspect the single-use lease to derive it. `check` returns only after PARC's
38
+ canonical business exactly matches that selector. Use the returned `businessId`,
39
+ `subjectId` and `clientAppId` as authorized scope. No static tenant configuration
40
+ or optional-business mode is needed. An application selector, when the service
41
+ accepts several admitted applications, is likewise untrusted until exact receipt
42
+ matching; route-specific application restrictions remain the owner's concern.
43
+ Include the selected tenant and operation inputs in the digest, constrain every
44
+ existing-resource lookup to that tenant, and reject any conflicting tenant in
45
+ the body. Do not expose tenant data or make side effects before authorization.
46
+
47
+ The client checks freshness when the HTTP response arrives, not when a later
48
+ owner transaction commits. Preserve `expiresAt` alongside the immutable receipt;
49
+ after Contract resolution and database lock waits, check it immediately before
50
+ the protected write and again before committing. Expiry means rollback, not an
51
+ extended TTL or reuse of the consumed lease. A SQL expiry predicate must use a
52
+ current wall clock, not a transaction-start timestamp. For flows likely to exceed
53
+ five seconds, resolve through an independently authorized reader first, then
54
+ consume a fresh lease for the final decision bound to the resolved immutable
55
+ terms/version and mutation inputs. Revalidate those inputs in the transaction;
56
+ an earlier decision cannot authorize changed terms. If both preliminary and
57
+ final authorization are required, they need distinct fresh leases.
58
+
59
+ This recipe authorizes one owner operation. It does not issue downstream
60
+ delegated authority, turn a decision reference into a Contract credential, or
61
+ make caller-supplied realm/session headers trustworthy. A Billing-to-Contract
62
+ reader needs its own supported governed authority contract; this recipe alone
63
+ does not complete that integration.
64
+
65
+ The lease carries authentication context to PARC. Its Service Access adapter
66
+ validates the canonical actor, issuer/consumer grants, audience and membership.
67
+ For shared IAM external authentication PARC preserves realm/session evidence,
68
+ requires its enabled current relationship adapter and fresh IAM evaluation,
69
+ and excludes staff-managed assignments. This shared-external contract is not
70
+ the internal staff contract: it accepts only `SHARED_EXTERNAL`,
71
+ `shared-external-v2`, and issuer `https://id.joonalabs.com`.
72
+
73
+ For ZITADEL-issued staff tokens verified through IAM, Identity retains the
74
+ original ZITADEL issuer. SA's governed staff readback requires provider `zitadel`
75
+ and internal actor/organization scope; it emits `authenticatedBy: 'zitadel'`
76
+ without shared-external authentication provenance. PARC accepts that through its
77
+ existing bound staff path. Do not relabel staff as `iam-orchestrator` or require
78
+ the shared-external relationship path for this flow. A legacy consumer that
79
+ accepts this canonical ZITADEL context is not blocked merely because IAM did the
80
+ upstream verification; adopting this recipe is not a new staff-release gate.
81
+ Neither issuer label grants permission. Source compatibility does not establish
82
+ governed reader admission or prove a real staff login through deployed owners.
83
+ The receipt is PARC decision evidence; it does not independently prove an IAM
84
+ login or expose the session/realm evidence retained in PARC's decision record.
85
+
86
+ Missing admission, disabled relationships, revoked scope or upstream failure
87
+ denies access. No generic key or legacy fallback is provided. HTTPS verification
88
+ must remain enabled; use `NODE_EXTRA_CA_CERTS` for private CAs. Response bodies
89
+ are bounded at 16 KiB under a five-second deadline; redirects are refused.
90
+ Receipts must bind the exact scope, operation and resource and expire within
91
+ PARC's five-second bound. Never cache/reuse them as durable permissions. A failed
92
+ request may already have consumed its lease; obtain fresh authority explicitly.
@@ -0,0 +1,142 @@
1
+ import { BoundedJsonHttpClient } from '../../auth/oauth2-token-introspection/bounded-json-http.client';
2
+
3
+ export interface ParcBoundRequest {
4
+ /** Fresh opaque SA lease. PARC consumes it; do not introspect it first. */
5
+ lease: string;
6
+ /** This consuming service's admitted machine credential, from private configuration. */
7
+ credential: string;
8
+ permission: string;
9
+ action: string;
10
+ resource: { type: string; id: string };
11
+ /** SHA-256 of the owner-verified operation material. */
12
+ contextDigest: string;
13
+ /** Persisted owner scope or an untrusted route selector; trusted only after exact receipt matching. */
14
+ expected: { businessId: string; clientAppId: string; subjectId?: string };
15
+ }
16
+
17
+ export interface ParcBoundReceipt {
18
+ decisionReference: string;
19
+ allowed: true;
20
+ subjectId: string;
21
+ businessId: string;
22
+ clientAppId: string;
23
+ service: string;
24
+ permission: string;
25
+ action: string;
26
+ resource: Readonly<{ type: string; id: string }>;
27
+ contextDigest: string;
28
+ decidedAt: string;
29
+ expiresAt: string;
30
+ }
31
+
32
+ export class ParcBoundAuthorizationError extends Error {
33
+ constructor(readonly reason: 'invalid_request' | 'denied' | 'unavailable' | 'invalid_receipt') {
34
+ super('PARC bound authorization ' + reason);
35
+ this.name = 'ParcBoundAuthorizationError';
36
+ }
37
+ }
38
+
39
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
40
+ const identifier = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
41
+
42
+ /** No actor reconstruction, role assignment, retry, cache or legacy fallback. */
43
+ export class ParcBoundAuthorizationClient {
44
+ private readonly origin: string;
45
+ private readonly service: string;
46
+
47
+ constructor(origin: string, service: string) {
48
+ this.requireTls();
49
+ try {
50
+ const url = new URL(origin);
51
+ if (url.protocol !== 'https:' || url.username || url.password ||
52
+ url.pathname !== '/' || origin.includes('?') || origin.includes('#') ||
53
+ !/^[a-z][a-z0-9-]{0,62}$/.test(service)) throw new Error();
54
+ this.origin = url.origin;
55
+ this.service = service;
56
+ } catch { throw new ParcBoundAuthorizationError('invalid_request'); }
57
+ }
58
+
59
+ /** Checks freshness on receipt only; the owner must recheck expiresAt at its write/commit boundary. */
60
+ async check(input: ParcBoundRequest): Promise<Readonly<ParcBoundReceipt>> {
61
+ this.requireTls();
62
+ // Snapshot validated material before the asynchronous request.
63
+ const lease = this.authority(input?.lease);
64
+ const credential = this.authority(input?.credential);
65
+ const permission = this.token(input?.permission, 200, 3);
66
+ if (!permission.startsWith(this.service + '.')) this.invalid();
67
+ const request = {
68
+ permission,
69
+ action: this.token(input?.action, 200),
70
+ resource: {
71
+ type: this.token(input?.resource?.type, 100),
72
+ id: this.token(input?.resource?.id, 200),
73
+ },
74
+ contextDigest: input?.contextDigest,
75
+ };
76
+ if (typeof request.contextDigest !== 'string' ||
77
+ !/^[a-f0-9]{64}$/.test(request.contextDigest)) this.invalid();
78
+ const expected = {
79
+ businessId: this.id(input?.expected?.businessId),
80
+ clientAppId: this.id(input?.expected?.clientAppId),
81
+ subjectId: input?.expected?.subjectId === undefined
82
+ ? undefined : this.id(input.expected.subjectId),
83
+ };
84
+ const startedAt = Date.now();
85
+ let value: unknown;
86
+ try {
87
+ value = await new BoundedJsonHttpClient({
88
+ origin: this.origin,
89
+ timeoutMs: 5000,
90
+ maxResponseBytes: 16384,
91
+ requestHeaders: {
92
+ 'x-request-context-lease': lease,
93
+ 'x-service-access-credential': credential,
94
+ },
95
+ }).postJson(['v1', 'decisions', 'check-bound'], request);
96
+ } catch { throw new ParcBoundAuthorizationError('unavailable'); }
97
+ this.requireTls();
98
+ const fail = (): never => { throw new ParcBoundAuthorizationError('invalid_receipt'); };
99
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail();
100
+ const r = value as Record<string, unknown>;
101
+ const keys = ['decisionReference', 'allowed', 'subjectId', 'businessId', 'clientAppId',
102
+ 'service', 'permission', 'action', 'resource', 'contextDigest', 'decidedAt', 'expiresAt'];
103
+ if (Object.keys(r).length !== keys.length || keys.some(k => !Object.hasOwn(r, k))) fail();
104
+ const resource = r.resource as Record<string, unknown> | undefined;
105
+ if (typeof r.allowed !== 'boolean' || typeof r.subjectId !== 'string' || !uuid.test(r.subjectId) ||
106
+ r.businessId !== expected.businessId || r.clientAppId !== expected.clientAppId ||
107
+ (expected.subjectId !== undefined && r.subjectId !== expected.subjectId) ||
108
+ r.service !== this.service || r.permission !== request.permission || r.action !== request.action ||
109
+ r.contextDigest !== request.contextDigest || !resource || Array.isArray(resource) ||
110
+ Object.keys(resource).sort().join(',') !== 'id,type' ||
111
+ resource.type !== request.resource.type || resource.id !== request.resource.id ||
112
+ typeof r.decisionReference !== 'string' || !/^PARC-BND-[A-Za-z0-9_-]{24}$/.test(r.decisionReference)) fail();
113
+ const date = (v: unknown): number => {
114
+ if (typeof v !== 'string') return fail();
115
+ const time = Date.parse(v);
116
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== v) return fail();
117
+ return time;
118
+ };
119
+ const decided = date(r.decidedAt), expires = date(r.expiresAt), now = Date.now();
120
+ if (decided < startedAt - 1000 || decided > now + 1000 || expires <= now ||
121
+ expires <= decided || expires - decided > 5000) fail();
122
+ if (!r.allowed) throw new ParcBoundAuthorizationError('denied');
123
+ return Object.freeze({ ...r, resource: Object.freeze({ ...resource }) }) as unknown as Readonly<ParcBoundReceipt>;
124
+ }
125
+
126
+ private requireTls(): void {
127
+ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') this.invalid();
128
+ }
129
+ private token(value: unknown, max: number, min = 1): string {
130
+ if (typeof value !== 'string' || value.length < min || value.length > max || !identifier.test(value)) this.invalid();
131
+ return value as string;
132
+ }
133
+ private authority(value: unknown): string {
134
+ if (typeof value !== 'string' || !value || value.length > 4096 || /[\s\u0000-\u001f\u007f]/.test(value)) this.invalid();
135
+ return value as string;
136
+ }
137
+ private id(value: unknown): string {
138
+ if (typeof value !== 'string' || !uuid.test(value)) this.invalid();
139
+ return value as string;
140
+ }
141
+ private invalid(): never { throw new ParcBoundAuthorizationError('invalid_request'); }
142
+ }