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,411 @@
1
+ import {
2
+ BoundedJsonHttpClient,
3
+ BoundedJsonHttpOptions,
4
+ BoundedJsonHttpUnavailableError,
5
+ } from './bounded-json-http.client';
6
+ describe('Bounded provider JSON HTTP', () => {
7
+ const create = (options: Partial<BoundedJsonHttpOptions> = {}) =>
8
+ new BoundedJsonHttpClient({
9
+ origin: 'https://provider.example.test',
10
+ transport: () =>
11
+ Promise.resolve(new Response(JSON.stringify({ active: true }))),
12
+ ...options,
13
+ });
14
+ it('GETs encoded segments on the configured origin with no bearer/body and explicit query', async () => {
15
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
16
+ void _url;
17
+ void _init;
18
+ return Promise.resolve(
19
+ new Response(
20
+ JSON.stringify({
21
+ id: 'uuid',
22
+ active: true,
23
+ expires_at: 'date',
24
+ identity: { id: 'identity-uuid' },
25
+ }),
26
+ ),
27
+ );
28
+ });
29
+ const client = create({ transport });
30
+ expect(
31
+ await client.get(
32
+ ['admin', 'sessions', 'uuid?redirect=https://evil.example/'],
33
+ {
34
+ expand: 'identity',
35
+ },
36
+ ),
37
+ ).toMatchObject({ id: 'uuid', active: true });
38
+ const requestCall = transport.mock.calls[0];
39
+ if (!requestCall) throw new Error('Missing request');
40
+ const [url, request] = requestCall;
41
+ expect(url).toBe(
42
+ 'https://provider.example.test/admin/sessions/uuid%3Fredirect%3Dhttps%3A%2F%2Fevil.example%2F?expand=identity',
43
+ );
44
+ expect(request).toMatchObject({
45
+ method: 'GET',
46
+ redirect: 'error',
47
+ cache: 'no-store',
48
+ });
49
+ expect(request.body).toBeUndefined();
50
+ expect(request.headers).not.toHaveProperty('authorization');
51
+ });
52
+ it('snapshots trusted origin/headers and performs each GET again', async () => {
53
+ let calls = 0;
54
+ const requestHeaders = { authorization: 'PRIVATE_PROVIDER_HEADER' };
55
+ const options = {
56
+ origin: 'https://provider.example.test',
57
+ requestHeaders,
58
+ transport: jest.fn((_url: string, _init: RequestInit) => {
59
+ void _url;
60
+ void _init;
61
+ return Promise.resolve(
62
+ new Response(JSON.stringify({ calls: ++calls })),
63
+ );
64
+ }),
65
+ };
66
+ const client = create(options);
67
+ options.origin = 'https://evil.example';
68
+ requestHeaders.authorization = 'changed';
69
+ const first = await client.get(['admin', 'identities', 'uuid']);
70
+ const second = await client.get(['admin', 'identities', 'uuid']);
71
+ expect(first).toEqual({ calls: 1 });
72
+ expect(second).toEqual({ calls: 2 });
73
+ expect(options.transport.mock.calls).toHaveLength(2);
74
+ expect(options.transport.mock.calls[0]?.[0]).toBe(
75
+ 'https://provider.example.test/admin/identities/uuid',
76
+ );
77
+ expect(options.transport.mock.calls[0]?.[1].headers).toMatchObject({
78
+ authorization: 'PRIVATE_PROVIDER_HEADER',
79
+ });
80
+ });
81
+ it.each([
82
+ 'http://provider.example.test',
83
+ 'https://user:SECRET@provider.example.test',
84
+ 'https://provider.example.test/admin',
85
+ 'https://provider.example.test?SECRET',
86
+ 'https://provider.example.test/#SECRET',
87
+ ])('rejects unsafe origin %s without echoing config secrets', (origin) => {
88
+ expect(() => create({ origin })).toThrow(
89
+ 'Invalid provider HTTP configuration',
90
+ );
91
+ });
92
+ it('permits only explicit trusted private HTTP and still encodes all dynamic segments', async () => {
93
+ await expect(
94
+ create({
95
+ origin: 'http://kratos-admin.ory-staging.svc.cluster.local:4434',
96
+ trustedInternalHttp: true,
97
+ }).get(['admin', 'sessions', 'uuid']),
98
+ ).resolves.toEqual({ active: true });
99
+ });
100
+ it.each([
101
+ { segments: ['..'] },
102
+ { segments: ['.'] },
103
+ { segments: ['admin', '\nSECRET'] },
104
+ { segments: Array.from({ length: 33 }, () => 'x') },
105
+ ])('rejects invalid paths before transport', async ({ segments }) => {
106
+ const transport = jest.fn(() => Promise.resolve(new Response('{}')));
107
+ await expect(create({ transport }).get(segments)).rejects.toThrow(
108
+ 'Invalid provider request configuration',
109
+ );
110
+ expect(transport).not.toHaveBeenCalled();
111
+ });
112
+ it('preserves only safe HTTP status for consumer-owned missing/session/error policy', async () => {
113
+ for (const status of [401, 404, 503]) {
114
+ const error = await create({
115
+ transport: () =>
116
+ Promise.resolve(new Response('PRIVATE_PROVIDER_BODY', { status })),
117
+ })
118
+ .get(['admin', 'sessions', 'uuid'])
119
+ .catch((error: unknown) => error);
120
+ expect(error).toBeInstanceOf(BoundedJsonHttpUnavailableError);
121
+ expect(error).toMatchObject({
122
+ reason: 'http',
123
+ status,
124
+ message: 'Provider validation unavailable',
125
+ });
126
+ expect(String(error)).not.toContain('PRIVATE');
127
+ }
128
+ });
129
+ it('bounds GET bytes, UTF8/JSON decoding and hanging response streams', async () => {
130
+ for (const transport of [
131
+ () => Promise.resolve(new Response('x'.repeat(129))),
132
+ () => Promise.resolve(new Response('PRIVATE_NOT_JSON')),
133
+ () => Promise.resolve(new Response(new Uint8Array([0xff]))),
134
+ ]) {
135
+ await expect(
136
+ create({ maxResponseBytes: 128, transport }).get(['admin']),
137
+ ).rejects.toMatchObject({ reason: 'invalid_response' });
138
+ }
139
+ await expect(
140
+ create({
141
+ timeoutMs: 10,
142
+ transport: () =>
143
+ Promise.resolve(new Response(new ReadableStream<Uint8Array>())),
144
+ }).get(['admin']),
145
+ ).rejects.toMatchObject({ reason: 'timeout' });
146
+ });
147
+ it('does not turn arbitrary JSON into a session or permission grant', async () => {
148
+ const result = await create({
149
+ transport: () =>
150
+ Promise.resolve(
151
+ new Response(JSON.stringify({ provider_extra: { editable: true } })),
152
+ ),
153
+ }).get(['resource']);
154
+ expect(result).toEqual({ provider_extra: { editable: true } });
155
+ expect(Object.isFrozen(result)).toBe(true);
156
+ });
157
+
158
+ it('POSTs bounded snapshotted JSON without changing form semantics or exposing headers', async () => {
159
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
160
+ void _url;
161
+ void _init;
162
+ return Promise.resolve(new Response('{}'));
163
+ });
164
+ const client = create({
165
+ requestHeaders: { authorization: 'PRIVATE' },
166
+ transport,
167
+ });
168
+ const payload = { nested: { message: 'original' } };
169
+ const request = client.postJson(['messages'], payload);
170
+ payload.nested.message = 'changed';
171
+ await request;
172
+ await client.postForm(['token'], { grant_type: 'client_credentials' });
173
+ expect(transport.mock.calls[0]?.[1]).toMatchObject({
174
+ body: '{"nested":{"message":"original"}}',
175
+ headers: { 'content-type': 'application/json' },
176
+ });
177
+ expect(transport.mock.calls[1]?.[1]).toMatchObject({
178
+ body: 'grant_type=client_credentials',
179
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
180
+ });
181
+ expect(JSON.stringify(client)).not.toContain('PRIVATE');
182
+ });
183
+
184
+ it('rejects invalid, cyclic, deep and oversized UTF8 JSON before any send', () => {
185
+ const transport = jest.fn(() => Promise.resolve(new Response('{}')));
186
+ const client = create({ transport });
187
+ const cyclic: { child?: unknown } = {};
188
+ cyclic.child = cyclic;
189
+ let deep: unknown = 'value';
190
+ for (let i = 0; i < 34; i++) deep = { child: deep };
191
+ const accessor = Object.defineProperty({}, 'secret', {
192
+ enumerable: true,
193
+ get: () => {
194
+ throw new Error('PRIVATE');
195
+ },
196
+ });
197
+ for (const payload of [
198
+ undefined,
199
+ NaN,
200
+ Infinity,
201
+ new Date(),
202
+ cyclic,
203
+ deep,
204
+ accessor,
205
+ { value: 'é'.repeat(140000) },
206
+ Array.from({ length: 10001 }, () => 1),
207
+ { toJSON: () => 'PRIVATE' },
208
+ ]) {
209
+ expect(() => client.postJson(['messages'], payload)).toThrow(
210
+ 'Invalid provider request configuration',
211
+ );
212
+ }
213
+ expect(transport).not.toHaveBeenCalled();
214
+ });
215
+ it('PATCHes only one bounded JSON snapshot using the configured origin and headers', async () => {
216
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
217
+ void _url;
218
+ void _init;
219
+ return Promise.resolve(new Response('{}'));
220
+ });
221
+ const client = create({
222
+ requestHeaders: { authorization: 'PRIVATE' },
223
+ transport,
224
+ });
225
+ const payload = { preferredName: 'original' };
226
+ const request = client.patchJson(['sessions', 'me', 'profile'], payload);
227
+ payload.preferredName = 'changed';
228
+ await request;
229
+ expect(transport).toHaveBeenCalledTimes(1);
230
+ expect(transport.mock.calls[0]?.[1]).toMatchObject({
231
+ method: 'PATCH',
232
+ body: '{"preferredName":"original"}',
233
+ redirect: 'error',
234
+ headers: { authorization: 'PRIVATE', 'content-type': 'application/json' },
235
+ });
236
+ expect(JSON.stringify(client)).not.toContain('PRIVATE');
237
+ expect(() => client.patchJson(['sessions'], undefined)).toThrow(
238
+ 'Invalid provider request configuration',
239
+ );
240
+ expect(transport).toHaveBeenCalledTimes(1);
241
+ });
242
+ it('holds a failed PATCH without retry or raw provider response disclosure', async () => {
243
+ const transport = jest.fn(() =>
244
+ Promise.resolve(new Response('PRIVATE-PAYLOAD', { status: 503 })),
245
+ );
246
+ const client = create({ transport });
247
+ await expect(
248
+ client.patchJson(['sessions', 'me', 'profile'], {
249
+ preferredName: 'safe',
250
+ }),
251
+ ).rejects.toMatchObject({ reason: 'http', status: 503 });
252
+ expect(transport).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it('PUTs one immutable JSON decision with an encoded bounded challenge query', async () => {
256
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
257
+ void _url;
258
+ void _init;
259
+ return Promise.resolve(
260
+ new Response('{"redirect_to":"https://provider.example.test/oauth2/auth"}'),
261
+ );
262
+ });
263
+ const requestHeaders = { authorization: 'PRIVATE' };
264
+ const options = { origin: 'https://provider.example.test', requestHeaders, transport };
265
+ const client = create(options);
266
+ options.origin = 'https://evil.example';
267
+ requestHeaders.authorization = 'changed';
268
+ const payload = { subject: 'identity', context: { session: 'original' } };
269
+ const query = { login_challenge: 'challenge&redirect=https://evil.example/#fragment' };
270
+ const request = client.putJson(
271
+ ['admin', 'oauth2', 'auth', 'requests', 'login', 'accept'],
272
+ payload,
273
+ query,
274
+ );
275
+ payload.context.session = 'changed';
276
+ query.login_challenge = 'changed';
277
+ const response = await request;
278
+ expect(response).toEqual({ redirect_to: 'https://provider.example.test/oauth2/auth' });
279
+ expect(Object.isFrozen(response)).toBe(true);
280
+ expect(transport).toHaveBeenCalledTimes(1);
281
+ expect(transport.mock.calls[0]?.[0]).toBe(
282
+ 'https://provider.example.test/admin/oauth2/auth/requests/login/accept?login_challenge=challenge%26redirect%3Dhttps%3A%2F%2Fevil.example%2F%23fragment',
283
+ );
284
+ expect(transport.mock.calls[0]?.[1]).toMatchObject({
285
+ method: 'PUT',
286
+ body: '{"subject":"identity","context":{"session":"original"}}',
287
+ redirect: 'error',
288
+ cache: 'no-store',
289
+ headers: {
290
+ accept: 'application/json',
291
+ authorization: 'PRIVATE',
292
+ 'content-type': 'application/json',
293
+ },
294
+ });
295
+ expect(transport.mock.calls[0]?.[1].signal?.aborted).toBe(true);
296
+ expect(JSON.stringify(client)).not.toContain('PRIVATE');
297
+ await client.putJson(['decisions'], { accepted: false });
298
+ expect(transport.mock.calls[1]?.[0]).toBe('https://provider.example.test/decisions');
299
+ });
300
+
301
+ it('rejects unsafe PUT paths and challenge queries before dispatch', async () => {
302
+ const transport = jest.fn(() => Promise.resolve(new Response('{}')));
303
+ const client = create({ transport });
304
+ const invalidRequests = [
305
+ { segments: ['..'], query: {} },
306
+ { segments: ['\nPRIVATE'], query: {} },
307
+ { segments: ['decision'], query: { '': 'value' } },
308
+ { segments: ['decision'], query: { challenge: '\nPRIVATE' } },
309
+ { segments: ['decision'], query: { challenge: 'x'.repeat(2049) } },
310
+ {
311
+ segments: ['decision'],
312
+ query: Object.fromEntries(
313
+ Array.from({ length: 33 }, (_, index) => [`key${index}`, 'value']),
314
+ ),
315
+ },
316
+ ];
317
+ for (const { segments, query } of invalidRequests) {
318
+ await expect(client.putJson(segments, { safe: true }, query)).rejects.toThrow(
319
+ 'Invalid provider request configuration',
320
+ );
321
+ }
322
+ expect(transport).not.toHaveBeenCalled();
323
+ });
324
+
325
+ it('rejects unsafe PUT JSON without invoking accessors or sending a partial decision', () => {
326
+ const transport = jest.fn(() => Promise.resolve(new Response('{}')));
327
+ const client = create({ transport });
328
+ const getter = jest.fn(() => 'PRIVATE');
329
+ const accessor = Object.defineProperty({}, 'secret', { enumerable: true, get: getter });
330
+ const cyclic: { child?: unknown } = {};
331
+ cyclic.child = cyclic;
332
+ let deep: unknown = 'value';
333
+ for (let i = 0; i < 34; i++) deep = { child: deep };
334
+ for (const payload of [
335
+ undefined,
336
+ NaN,
337
+ new Date(),
338
+ cyclic,
339
+ deep,
340
+ accessor,
341
+ { message: 'é'.repeat(140000) },
342
+ ]) {
343
+ expect(() => client.putJson(['decisions'], payload, { challenge: 'safe' })).toThrow(
344
+ 'Invalid provider request configuration',
345
+ );
346
+ }
347
+ expect(getter).not.toHaveBeenCalled();
348
+ expect(transport).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it.each([401, 404, 410, 503])(
352
+ 'holds failed PUT status %s without retry or provider disclosure',
353
+ async (status) => {
354
+ const transport = jest.fn(() => Promise.resolve(new Response('PRIVATE-BODY', { status })));
355
+ const error: unknown = await create({ transport })
356
+ .putJson(['decision'], { accepted: true })
357
+ .catch((error: unknown) => error);
358
+ expect(error).toMatchObject({
359
+ reason: 'http',
360
+ status,
361
+ message: 'Provider validation unavailable',
362
+ });
363
+ expect(String(error)).not.toContain('PRIVATE');
364
+ expect(transport).toHaveBeenCalledTimes(1);
365
+ },
366
+ );
367
+
368
+ it('rejects PUT redirects and malformed or oversized response data without retries', async () => {
369
+ for (const response of [
370
+ new Response('PRIVATE', { status: 302, headers: { location: 'https://evil.example' } }),
371
+ new Response('PRIVATE-NOT-JSON'),
372
+ new Response(new Uint8Array([0xff])),
373
+ new Response('x'.repeat(129)),
374
+ new Response('{}', { headers: { 'content-length': '129' } }),
375
+ ]) {
376
+ const transport = jest.fn(() => Promise.resolve(response));
377
+ await expect(
378
+ create({ transport, maxResponseBytes: 128 }).putJson(['decision'], {}),
379
+ ).rejects.toBeInstanceOf(BoundedJsonHttpUnavailableError);
380
+ expect(transport).toHaveBeenCalledTimes(1);
381
+ }
382
+ });
383
+
384
+ it('bounds the whole PUT transport and stream deadline even when the provider ignores abort', async () => {
385
+ for (const result of [
386
+ () => new Promise<Response>(() => undefined),
387
+ () => Promise.resolve(new Response(new ReadableStream<Uint8Array>())),
388
+ ]) {
389
+ const transport = jest.fn((_url: string, _init: RequestInit) => {
390
+ void _url;
391
+ void _init;
392
+ return result();
393
+ });
394
+ await expect(
395
+ create({ transport, timeoutMs: 10 }).putJson(['decision'], {}),
396
+ ).rejects.toMatchObject({ reason: 'timeout' });
397
+ expect(transport).toHaveBeenCalledTimes(1);
398
+ expect(transport.mock.calls[0]?.[1].signal?.aborted).toBe(true);
399
+ }
400
+ });
401
+
402
+ it('sanitizes a PUT network failure and never retries the uncertain decision', async () => {
403
+ const transport = jest.fn(() => Promise.reject(new Error('PRIVATE-HEADER-BODY')));
404
+ const error: unknown = await create({ transport })
405
+ .putJson(['decision'], {})
406
+ .catch((error: unknown) => error);
407
+ expect(error).toMatchObject({ reason: 'network', message: 'Provider validation unavailable' });
408
+ expect(String(error)).not.toContain('PRIVATE');
409
+ expect(transport).toHaveBeenCalledTimes(1);
410
+ });
411
+ });