nestjs-ddd-cli 3.2.2 → 3.3.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 (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,61 @@
1
+ # Platform Service Access Request Context
2
+
3
+ Use this recipe in a NestJS owner service to exchange the opaque, single-use
4
+ `x-service-access-request-context` lease for canonical actor and machine
5
+ principal context. Service Access owns lease verification. The owner service
6
+ must not decode the lease or reconstruct identity from caller headers.
7
+
8
+ ## Guard Order
9
+
10
+ Import `ServiceAccessRequestContextModule`, then run its guard before the
11
+ route-specific PARC guard:
12
+
13
+ ```typescript
14
+ @RequireParcAuthorization({
15
+ service: "owner-service",
16
+ capability: "records.write",
17
+ action: "create",
18
+ })
19
+ @UseGuards(
20
+ ServiceAccessRequestContextGuard,
21
+ PlatformParcAuthorizationGuard,
22
+ )
23
+ ```
24
+
25
+ The generated client sends only `{token}`, `x-internal-api-key`, and
26
+ `x-service-access-credential` to
27
+ `POST /api/v1/internal/request-context/introspect`. It never forwards the
28
+ actor's bearer token. A response is trusted only when it is well formed,
29
+ `active:true`, unexpired, and its audience exactly matches
30
+ `SERVICE_ACCESS_EXPECTED_AUDIENCE`. Inactive results cover unknown, expired,
31
+ revoked, and replayed leases. Non-2xx responses, malformed bodies, audience
32
+ mismatches, and Service Access failures deny in enforce mode.
33
+
34
+ This recipe supports the legacy ZITADEL principal only. IAM-authenticated
35
+ principals and responses carrying `provenance.authentication` are rejected,
36
+ rather than discarding their realm/session proof. They require PARC's bound
37
+ decision integration and its current IAM relationship checks. Do not relabel
38
+ an IAM actor or fall back to the legacy decision endpoint.
39
+
40
+ ## Environment Contract
41
+
42
+ - `SERVICE_ACCESS_URL`: Service Access base URL, without `/api/v1`.
43
+ - `SERVICE_ACCESS_ALLOW_INSECURE_HTTP`: defaults to `false`; may be `true` only
44
+ for loopback or Kubernetes `.svc`/`.svc.cluster.local` URLs.
45
+ - `SERVICE_ACCESS_INTERNAL_API_KEY`: internal transport authentication key.
46
+ - `SERVICE_ACCESS_CREDENTIAL`: this owner service's active Service Access machine credential.
47
+ - `SERVICE_ACCESS_EXPECTED_AUDIENCE`: exact owner-service slug expected in the lease.
48
+ - `SERVICE_ACCESS_REQUEST_CONTEXT_MODE`: `enforce` by default, or explicit `shadow`/`disabled` rollout mode.
49
+ - `SERVICE_ACCESS_TIMEOUT_MS`: positive introspection timeout; defaults to 3000.
50
+
51
+ `enforce` requires successful introspection. `shadow` attempts introspection and
52
+ attaches context only on success, but allows failure for rollout observation.
53
+ `disabled` skips lease consumption. The guard clears any earlier request
54
+ `platformContext`, so shadow and disabled modes never turn actor, business,
55
+ application, or machine headers into identity. A following PARC guard therefore
56
+ still fails closed unless verified platform context exists.
57
+
58
+ Only `x-request-id`, `x-correlation-id`, and `idempotency-key` are copied as
59
+ request metadata. They are not identity or authorization evidence. Owner domain
60
+ invariants and transactional audit/outbox recording still apply after PARC
61
+ allows the route-specific action.
@@ -0,0 +1,7 @@
1
+ SERVICE_ACCESS_URL=
2
+ SERVICE_ACCESS_ALLOW_INSECURE_HTTP=false
3
+ SERVICE_ACCESS_INTERNAL_API_KEY=
4
+ SERVICE_ACCESS_CREDENTIAL=
5
+ SERVICE_ACCESS_EXPECTED_AUDIENCE=replace-me-service
6
+ SERVICE_ACCESS_REQUEST_CONTEXT_MODE=enforce
7
+ SERVICE_ACCESS_TIMEOUT_MS=3000
@@ -0,0 +1,7 @@
1
+ export * from "./service-access-request-context.config";
2
+ export * from "./request-context-introspection.types";
3
+ export * from "./request-context-introspection.parser";
4
+ export * from "./request-context-headers";
5
+ export * from "./service-access-request-context.client";
6
+ export * from "./service-access-request-context.guard";
7
+ export * from "./service-access-request-context.module";
@@ -0,0 +1,61 @@
1
+ export const SERVICE_ACCESS_REQUEST_CONTEXT_HEADER =
2
+ "x-service-access-request-context";
3
+
4
+ export type RequestHeaders = Record<string, string | string[] | undefined>;
5
+
6
+ export interface PlatformRequestMetadataHeaders {
7
+ requestId?: string;
8
+ correlationId?: string;
9
+ idempotencyKey?: string;
10
+ }
11
+
12
+ export class RequestContextHeaderError extends Error {
13
+ constructor() {
14
+ super("Invalid Service Access request-context headers");
15
+ this.name = "RequestContextHeaderError";
16
+ }
17
+ }
18
+
19
+ export function extractOpaqueRequestContextLease(headers: RequestHeaders): string {
20
+ const token = requiredSingleHeader(headers, SERVICE_ACCESS_REQUEST_CONTEXT_HEADER);
21
+ if (token.length > 512) {
22
+ throw new RequestContextHeaderError();
23
+ }
24
+ return token;
25
+ }
26
+
27
+ export function platformRequestMetadataFromHeaders(
28
+ headers: RequestHeaders,
29
+ ): PlatformRequestMetadataHeaders {
30
+ return {
31
+ requestId: optionalSingleHeader(headers, "x-request-id"),
32
+ correlationId: optionalSingleHeader(headers, "x-correlation-id"),
33
+ idempotencyKey: optionalSingleHeader(headers, "idempotency-key"),
34
+ };
35
+ }
36
+
37
+ function requiredSingleHeader(headers: RequestHeaders, name: string): string {
38
+ const value = optionalSingleHeader(headers, name);
39
+ if (!value) {
40
+ throw new RequestContextHeaderError();
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function optionalSingleHeader(
46
+ headers: RequestHeaders,
47
+ name: string,
48
+ ): string | undefined {
49
+ const value = headers[name];
50
+ if (value === undefined) {
51
+ return undefined;
52
+ }
53
+ if (typeof value !== "string") {
54
+ throw new RequestContextHeaderError();
55
+ }
56
+ const cleaned = value.trim();
57
+ if (!cleaned || cleaned.includes(",")) {
58
+ throw new RequestContextHeaderError();
59
+ }
60
+ return cleaned;
61
+ }
@@ -0,0 +1,132 @@
1
+ import type {
2
+ ActiveRequestContextIntrospectionResponse,
3
+ ServiceAccessRequestContextProvenance,
4
+ ServiceAccessVerifiedPrincipal,
5
+ } from "./request-context-introspection.types";
6
+
7
+ type UnknownRecord = Record<string, unknown>;
8
+
9
+ export class RequestContextIntrospectionParseError extends Error {
10
+ constructor() {
11
+ super("Invalid Service Access request-context introspection response");
12
+ this.name = "RequestContextIntrospectionParseError";
13
+ }
14
+ }
15
+
16
+ export function parseActiveRequestContextIntrospection(
17
+ payload: unknown,
18
+ expectedAudience: string,
19
+ now = new Date(),
20
+ ): ActiveRequestContextIntrospectionResponse {
21
+ const record = requiredRecord(payload);
22
+ if (record["active"] !== true) {
23
+ throw invalidResponse();
24
+ }
25
+
26
+ const audience = requiredString(record["audience"]);
27
+ if (audience !== requiredString(expectedAudience)) {
28
+ throw invalidResponse();
29
+ }
30
+
31
+ const expiresAt = requiredIsoDate(record["expiresAt"]);
32
+ if (new Date(expiresAt).getTime() <= now.getTime()) {
33
+ throw invalidResponse();
34
+ }
35
+
36
+ const principal = parsePrincipal(record["principal"]);
37
+ if (principal.machine.serviceName !== audience) {
38
+ throw invalidResponse();
39
+ }
40
+
41
+ return {
42
+ active: true,
43
+ audience,
44
+ expiresAt,
45
+ principal,
46
+ provenance: parseProvenance(record["provenance"]),
47
+ };
48
+ }
49
+
50
+ function parsePrincipal(value: unknown): ServiceAccessVerifiedPrincipal {
51
+ const principal = requiredRecord(value);
52
+ const actor = requiredRecord(principal["actor"]);
53
+ const machine = requiredRecord(principal["machine"]);
54
+ // This recipe models the legacy ZITADEL lease only. Do not flatten a
55
+ // realm-bound IAM principal into a legacy authorization context.
56
+ if (actor["authenticatedBy"] !== "zitadel") {
57
+ throw invalidResponse();
58
+ }
59
+ const sessionId = optionalString(actor["sessionId"]);
60
+ const tokenId = optionalString(actor["tokenId"]);
61
+ const serviceInstanceId = optionalString(machine["serviceInstanceId"]);
62
+
63
+ return {
64
+ actor: {
65
+ subjectId: requiredString(actor["subjectId"]),
66
+ businessId: requiredString(actor["businessId"]),
67
+ applicationId: requiredString(actor["applicationId"]),
68
+ ...(sessionId ? { sessionId } : {}),
69
+ ...(tokenId ? { tokenId } : {}),
70
+ authenticatedBy: requiredString(actor["authenticatedBy"]),
71
+ },
72
+ machine: {
73
+ serviceName: requiredString(machine["serviceName"]),
74
+ clientId: requiredString(machine["clientId"]),
75
+ ...(serviceInstanceId ? { serviceInstanceId } : {}),
76
+ grantId: requiredString(machine["grantId"]),
77
+ credentialId: requiredString(machine["credentialId"]),
78
+ },
79
+ };
80
+ }
81
+
82
+ function parseProvenance(value: unknown): ServiceAccessRequestContextProvenance {
83
+ const provenance = requiredRecord(value);
84
+ if (
85
+ provenance["identityAuthority"] !== "identity-manager" ||
86
+ provenance["credentialAuthority"] !== "service-access" ||
87
+ provenance["authentication"] !== undefined
88
+ ) {
89
+ throw invalidResponse();
90
+ }
91
+
92
+ return {
93
+ identityAuthority: "identity-manager",
94
+ membershipId: requiredString(provenance["membershipId"]),
95
+ credentialAuthority: "service-access",
96
+ parcDecisionReference: requiredString(provenance["parcDecisionReference"]),
97
+ };
98
+ }
99
+
100
+ function requiredRecord(value: unknown): UnknownRecord {
101
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
102
+ throw invalidResponse();
103
+ }
104
+ return value as UnknownRecord;
105
+ }
106
+
107
+ function requiredString(value: unknown): string {
108
+ if (typeof value !== "string" || !value.trim()) {
109
+ throw invalidResponse();
110
+ }
111
+ return value.trim();
112
+ }
113
+
114
+ function optionalString(value: unknown): string | undefined {
115
+ if (value === undefined) {
116
+ return undefined;
117
+ }
118
+ return requiredString(value);
119
+ }
120
+
121
+ function requiredIsoDate(value: unknown): string {
122
+ const date = requiredString(value);
123
+ const parsed = new Date(date);
124
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== date) {
125
+ throw invalidResponse();
126
+ }
127
+ return date;
128
+ }
129
+
130
+ function invalidResponse(): RequestContextIntrospectionParseError {
131
+ return new RequestContextIntrospectionParseError();
132
+ }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ PlatformActorContext,
3
+ PlatformMachineContext,
4
+ VerifiedPlatformPrincipal,
5
+ } from "../../platform-context";
6
+
7
+ export interface ServiceAccessVerifiedPrincipal extends VerifiedPlatformPrincipal {
8
+ actor: PlatformActorContext & {
9
+ businessId: string;
10
+ applicationId: string;
11
+ };
12
+ machine: PlatformMachineContext & {
13
+ clientId: string;
14
+ grantId: string;
15
+ credentialId: string;
16
+ };
17
+ }
18
+
19
+ export interface ServiceAccessRequestContextProvenance {
20
+ identityAuthority: "identity-manager";
21
+ membershipId: string;
22
+ credentialAuthority: "service-access";
23
+ parcDecisionReference: string;
24
+ }
25
+
26
+ export interface ActiveRequestContextIntrospectionResponse {
27
+ active: true;
28
+ audience: string;
29
+ expiresAt: string;
30
+ principal: ServiceAccessVerifiedPrincipal;
31
+ provenance: ServiceAccessRequestContextProvenance;
32
+ }
33
+
34
+ export interface InactiveRequestContextIntrospectionResponse {
35
+ active: false;
36
+ }
37
+
38
+ export type RequestContextIntrospectionResponse =
39
+ | ActiveRequestContextIntrospectionResponse
40
+ | InactiveRequestContextIntrospectionResponse;
@@ -0,0 +1,45 @@
1
+ import { Injectable, UnauthorizedException } from "@nestjs/common";
2
+ import type { ActiveServiceAccessRequestContextConfig } from "./service-access-request-context.config";
3
+ import { parseActiveRequestContextIntrospection } from "./request-context-introspection.parser";
4
+ import type { ActiveRequestContextIntrospectionResponse } from "./request-context-introspection.types";
5
+
6
+ @Injectable()
7
+ export class ServiceAccessRequestContextClient {
8
+ async introspect(
9
+ token: string,
10
+ config: ActiveServiceAccessRequestContextConfig,
11
+ ): Promise<ActiveRequestContextIntrospectionResponse> {
12
+ try {
13
+ const response = await fetch(
14
+ config.serviceAccessUrl + "/api/v1/internal/request-context/introspect",
15
+ {
16
+ method: "POST",
17
+ redirect: "error",
18
+ headers: {
19
+ Accept: "application/json",
20
+ "Content-Type": "application/json",
21
+ "x-internal-api-key": config.internalApiKey,
22
+ "x-service-access-credential": config.serviceAccessCredential,
23
+ },
24
+ signal: AbortSignal.timeout(config.timeoutMs),
25
+ body: JSON.stringify({ token }),
26
+ },
27
+ );
28
+
29
+ if (!response.ok) {
30
+ throw new Error("Service Access request-context introspection failed");
31
+ }
32
+
33
+ return parseActiveRequestContextIntrospection(
34
+ await response.json(),
35
+ config.expectedAudience,
36
+ );
37
+ } catch {
38
+ throw this.denied();
39
+ }
40
+ }
41
+
42
+ private denied(): UnauthorizedException {
43
+ return new UnauthorizedException("Service Access request context denied");
44
+ }
45
+ }
@@ -0,0 +1,142 @@
1
+ export const SERVICE_ACCESS_REQUEST_CONTEXT_MODES = [
2
+ "disabled",
3
+ "shadow",
4
+ "enforce",
5
+ ] as const;
6
+
7
+ export type ServiceAccessRequestContextMode =
8
+ (typeof SERVICE_ACCESS_REQUEST_CONTEXT_MODES)[number];
9
+ export type ActiveServiceAccessRequestContextMode = Exclude<
10
+ ServiceAccessRequestContextMode,
11
+ "disabled"
12
+ >;
13
+
14
+ export interface DisabledServiceAccessRequestContextConfig {
15
+ mode: "disabled";
16
+ }
17
+
18
+ export interface ActiveServiceAccessRequestContextConfig {
19
+ mode: ActiveServiceAccessRequestContextMode;
20
+ serviceAccessUrl: string;
21
+ allowInsecureHttp: boolean;
22
+ internalApiKey: string;
23
+ serviceAccessCredential: string;
24
+ expectedAudience: string;
25
+ timeoutMs: number;
26
+ }
27
+
28
+ export type ServiceAccessRequestContextConfig =
29
+ | DisabledServiceAccessRequestContextConfig
30
+ | ActiveServiceAccessRequestContextConfig;
31
+
32
+ export function serviceAccessRequestContextModeFromEnv(): ServiceAccessRequestContextMode {
33
+ const mode = clean(process.env["SERVICE_ACCESS_REQUEST_CONTEXT_MODE"]) ?? "enforce";
34
+ if (!SERVICE_ACCESS_REQUEST_CONTEXT_MODES.includes(mode as ServiceAccessRequestContextMode)) {
35
+ throw new Error("SERVICE_ACCESS_REQUEST_CONTEXT_MODE must be disabled, shadow, or enforce");
36
+ }
37
+ return mode as ServiceAccessRequestContextMode;
38
+ }
39
+
40
+ export function serviceAccessRequestContextConfigFromEnv(
41
+ mode: "disabled",
42
+ ): DisabledServiceAccessRequestContextConfig;
43
+ export function serviceAccessRequestContextConfigFromEnv(
44
+ mode: ActiveServiceAccessRequestContextMode,
45
+ ): ActiveServiceAccessRequestContextConfig;
46
+ export function serviceAccessRequestContextConfigFromEnv(
47
+ mode?: ServiceAccessRequestContextMode,
48
+ ): ServiceAccessRequestContextConfig;
49
+ export function serviceAccessRequestContextConfigFromEnv(
50
+ mode = serviceAccessRequestContextModeFromEnv(),
51
+ ): ServiceAccessRequestContextConfig {
52
+ if (mode === "disabled") {
53
+ return { mode };
54
+ }
55
+
56
+ const allowInsecureHttp = booleanEnv(
57
+ "SERVICE_ACCESS_ALLOW_INSECURE_HTTP",
58
+ false,
59
+ );
60
+
61
+ return {
62
+ mode,
63
+ serviceAccessUrl: requiredServiceAccessUrl(allowInsecureHttp),
64
+ allowInsecureHttp,
65
+ internalApiKey: requiredEnv("SERVICE_ACCESS_INTERNAL_API_KEY"),
66
+ serviceAccessCredential: requiredEnv("SERVICE_ACCESS_CREDENTIAL"),
67
+ expectedAudience: requiredEnv("SERVICE_ACCESS_EXPECTED_AUDIENCE"),
68
+ timeoutMs: positiveIntegerEnv("SERVICE_ACCESS_TIMEOUT_MS", 3000),
69
+ };
70
+ }
71
+
72
+ function requiredServiceAccessUrl(allowInsecureHttp: boolean): string {
73
+ const value = requiredEnv("SERVICE_ACCESS_URL").replace(/\/+$/, "");
74
+ let parsed: URL;
75
+ try {
76
+ parsed = new URL(value);
77
+ } catch {
78
+ throw new Error("SERVICE_ACCESS_URL must be an absolute HTTP(S) URL");
79
+ }
80
+ if (parsed.protocol === "https:") {
81
+ return value;
82
+ }
83
+ if (
84
+ parsed.protocol === "http:" &&
85
+ allowInsecureHttp &&
86
+ isApprovedInsecureHostname(parsed.hostname)
87
+ ) {
88
+ return value;
89
+ }
90
+ throw new Error(
91
+ "SERVICE_ACCESS_URL must use HTTPS unless insecure HTTP is explicitly enabled for loopback or cluster service DNS",
92
+ );
93
+ }
94
+
95
+ function requiredEnv(name: string): string {
96
+ const value = clean(process.env[name]);
97
+ if (!value) {
98
+ throw new Error(name + " is required for Service Access request context");
99
+ }
100
+ return value;
101
+ }
102
+
103
+ function positiveIntegerEnv(name: string, fallback: number): number {
104
+ const value = clean(process.env[name]);
105
+ if (!value) {
106
+ return fallback;
107
+ }
108
+ const parsed = Number(value);
109
+ if (!Number.isInteger(parsed) || parsed <= 0) {
110
+ throw new Error(name + " must be a positive integer");
111
+ }
112
+ return parsed;
113
+ }
114
+
115
+ function booleanEnv(name: string, fallback: boolean): boolean {
116
+ const value = clean(process.env[name]);
117
+ if (!value) {
118
+ return fallback;
119
+ }
120
+ if (value === "true") {
121
+ return true;
122
+ }
123
+ if (value === "false") {
124
+ return false;
125
+ }
126
+ throw new Error(name + " must be true or false");
127
+ }
128
+
129
+ function isApprovedInsecureHostname(hostname: string): boolean {
130
+ return (
131
+ hostname === "localhost" ||
132
+ hostname === "127.0.0.1" ||
133
+ hostname === "::1" ||
134
+ hostname.endsWith(".svc") ||
135
+ hostname.endsWith(".svc.cluster.local")
136
+ );
137
+ }
138
+
139
+ function clean(value: string | undefined): string | undefined {
140
+ const cleaned = value?.trim();
141
+ return cleaned || undefined;
142
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ CanActivate,
3
+ ExecutionContext,
4
+ Injectable,
5
+ Logger,
6
+ UnauthorizedException,
7
+ } from "@nestjs/common";
8
+ import { createPlatformRequestContext } from "../../platform-context";
9
+ import type { PlatformRequestContext } from "../../platform-context";
10
+ import {
11
+ serviceAccessRequestContextConfigFromEnv,
12
+ serviceAccessRequestContextModeFromEnv,
13
+ } from "./service-access-request-context.config";
14
+ import {
15
+ extractOpaqueRequestContextLease,
16
+ platformRequestMetadataFromHeaders,
17
+ } from "./request-context-headers";
18
+ import type { RequestHeaders } from "./request-context-headers";
19
+ import { ServiceAccessRequestContextClient } from "./service-access-request-context.client";
20
+
21
+ export interface RequestWithServiceAccessRequestContext {
22
+ headers: RequestHeaders;
23
+ platformContext?: PlatformRequestContext;
24
+ }
25
+
26
+ @Injectable()
27
+ export class ServiceAccessRequestContextGuard implements CanActivate {
28
+ private readonly logger = new Logger(ServiceAccessRequestContextGuard.name);
29
+
30
+ constructor(private readonly client: ServiceAccessRequestContextClient) {}
31
+
32
+ async canActivate(context: ExecutionContext): Promise<boolean> {
33
+ const request = context
34
+ .switchToHttp()
35
+ .getRequest<RequestWithServiceAccessRequestContext>();
36
+
37
+ delete request.platformContext;
38
+
39
+ let mode;
40
+ try {
41
+ mode = serviceAccessRequestContextModeFromEnv();
42
+ } catch {
43
+ throw this.denied();
44
+ }
45
+
46
+ if (mode === "disabled") {
47
+ return true;
48
+ }
49
+
50
+ try {
51
+ const config = serviceAccessRequestContextConfigFromEnv(mode);
52
+ const token = extractOpaqueRequestContextLease(request.headers);
53
+ const introspection = await this.client.introspect(token, config);
54
+ const metadata = platformRequestMetadataFromHeaders(request.headers);
55
+
56
+ request.platformContext = createPlatformRequestContext({
57
+ principal: introspection.principal,
58
+ requestId: metadata.requestId,
59
+ correlationId: metadata.correlationId,
60
+ idempotencyKey: metadata.idempotencyKey,
61
+ });
62
+ return true;
63
+ } catch {
64
+ if (mode === "shadow") {
65
+ this.logger.warn(
66
+ "Service Access request-context shadow verification failed",
67
+ );
68
+ return true;
69
+ }
70
+ throw this.denied();
71
+ }
72
+ }
73
+
74
+ private denied(): UnauthorizedException {
75
+ return new UnauthorizedException("Service Access request context denied");
76
+ }
77
+ }
@@ -0,0 +1,17 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { PlatformContextModule } from "../../platform-context";
3
+ import { ServiceAccessRequestContextClient } from "./service-access-request-context.client";
4
+ import { ServiceAccessRequestContextGuard } from "./service-access-request-context.guard";
5
+
6
+ @Module({
7
+ imports: [PlatformContextModule],
8
+ providers: [
9
+ ServiceAccessRequestContextClient,
10
+ ServiceAccessRequestContextGuard,
11
+ ],
12
+ exports: [
13
+ ServiceAccessRequestContextClient,
14
+ ServiceAccessRequestContextGuard,
15
+ ],
16
+ })
17
+ export class ServiceAccessRequestContextModule {}