@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.690.3fe2b27.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 (189) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-utils.js +6 -1
  18. package/database/encryption/README.md +2 -0
  19. package/database/encryption/encryption-config.js +10 -31
  20. package/database/encryption/encryption-schema-registry.js +121 -24
  21. package/database/encryption/field-encryption-service.js +69 -5
  22. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  23. package/database/organization-where.js +18 -0
  24. package/database/prisma.js +4 -2
  25. package/database/unique-violation.js +25 -0
  26. package/extensions/bootstrap-runner.js +43 -0
  27. package/extensions/extension-loader.js +187 -0
  28. package/extensions/index.js +41 -0
  29. package/extensions/initialize-app.js +123 -0
  30. package/extensions/route-mounter.js +103 -0
  31. package/extensions/schema-composer.js +188 -0
  32. package/generated/prisma-mongodb/edge.js +43 -4
  33. package/generated/prisma-mongodb/index-browser.js +40 -1
  34. package/generated/prisma-mongodb/index.d.ts +4761 -133
  35. package/generated/prisma-mongodb/index.js +43 -4
  36. package/generated/prisma-mongodb/package.json +1 -1
  37. package/generated/prisma-mongodb/schema.prisma +92 -0
  38. package/generated/prisma-mongodb/wasm.js +43 -4
  39. package/generated/prisma-postgresql/edge.js +43 -4
  40. package/generated/prisma-postgresql/index-browser.js +40 -1
  41. package/generated/prisma-postgresql/index.d.ts +4961 -28
  42. package/generated/prisma-postgresql/index.js +43 -4
  43. package/generated/prisma-postgresql/package.json +1 -1
  44. package/generated/prisma-postgresql/schema.prisma +87 -0
  45. package/generated/prisma-postgresql/wasm.js +43 -4
  46. package/handlers/app-definition-loader.js +128 -19
  47. package/handlers/app-handler-helpers.js +25 -3
  48. package/handlers/backend-utils.js +31 -25
  49. package/handlers/middleware/admin-auth.js +14 -1
  50. package/handlers/routers/auth.js +46 -10
  51. package/handlers/routers/health.js +13 -4
  52. package/handlers/routers/integration-defined-routers.js +144 -92
  53. package/handlers/routers/integration-webhook-routers.js +102 -64
  54. package/handlers/workers/integration-defined-workers.js +5 -2
  55. package/index.js +65 -1
  56. package/infrastructure/scheduler/index.js +19 -6
  57. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  58. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  59. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  60. package/integrations/index.js +1 -0
  61. package/integrations/integration-router.js +131 -96
  62. package/integrations/repositories/integration-not-found.js +14 -0
  63. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  64. package/integrations/repositories/integration-repository-interface.js +17 -1
  65. package/integrations/repositories/integration-repository-mongo.js +59 -76
  66. package/integrations/repositories/integration-repository-postgres.js +62 -76
  67. package/integrations/schedule-config.js +80 -0
  68. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  69. package/integrations/use-cases/create-integration.js +55 -20
  70. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  71. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  72. package/integrations/use-cases/index.js +7 -1
  73. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  74. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  75. package/integrations/utils/map-integration-dto.js +43 -14
  76. package/management-api/create-management-api-router.js +49 -0
  77. package/management-api/error-response.js +116 -0
  78. package/management-api/management-api-config.js +151 -0
  79. package/management-api/meta-router.js +76 -0
  80. package/management-api/openapi/build-openapi.js +144 -0
  81. package/management-api/openapi/components.js +415 -0
  82. package/management-api/openapi/v1-operations.js +83 -0
  83. package/management-api/openapi/v2-operations.js +264 -0
  84. package/management-api/route-registry.js +411 -0
  85. package/management-api/test-support/http-client.js +39 -0
  86. package/management-api/use-cases/get-management-api-meta.js +57 -0
  87. package/management-api/v1-deprecation.js +120 -0
  88. package/management-api/v2/create-v2-router.js +85 -0
  89. package/management-api/v2/dependencies.js +281 -0
  90. package/management-api/v2/dto.js +143 -0
  91. package/management-api/v2/handlers/authorization.js +94 -0
  92. package/management-api/v2/handlers/credentials.js +33 -0
  93. package/management-api/v2/handlers/entities.js +77 -0
  94. package/management-api/v2/handlers/integrations.js +128 -0
  95. package/management-api/v2/handlers/proxy.js +34 -0
  96. package/management-api/v2/request.js +85 -0
  97. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  98. package/modules/domain/authorization-session.js +144 -0
  99. package/modules/entity-scope.js +15 -0
  100. package/modules/entity-type-registry.js +311 -0
  101. package/modules/index.js +14 -0
  102. package/modules/module-factory.js +12 -0
  103. package/modules/module.js +53 -2
  104. package/modules/proxy/proxy-bases.js +169 -0
  105. package/modules/proxy/proxy-errors.js +31 -0
  106. package/modules/proxy/proxy-path.js +183 -0
  107. package/modules/proxy/proxy-policy.js +277 -0
  108. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  109. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  110. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  111. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  112. package/modules/repositories/entity-not-found.js +18 -0
  113. package/modules/repositories/module-repository-documentdb.js +51 -6
  114. package/modules/repositories/module-repository-interface.js +19 -2
  115. package/modules/repositories/module-repository-mongo.js +67 -74
  116. package/modules/repositories/module-repository-postgres.js +66 -74
  117. package/modules/repositories/module-repository.js +2 -1
  118. package/modules/requester/proxy-request.js +245 -0
  119. package/modules/requester/requester.js +35 -5
  120. package/modules/scope-config.js +192 -0
  121. package/modules/scope-policy.js +112 -0
  122. package/modules/scope-resolution.js +288 -0
  123. package/modules/scope-visibility.js +247 -0
  124. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  125. package/modules/tests/doubles/ownership-matching.js +18 -0
  126. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  127. package/modules/use-cases/create-entity-from-credential.js +113 -0
  128. package/modules/use-cases/entity-operations-for-user.js +162 -0
  129. package/modules/use-cases/entity-types.js +139 -0
  130. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  131. package/modules/use-cases/get-authorization-requirements.js +100 -0
  132. package/modules/use-cases/get-entities-for-user.js +26 -13
  133. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  134. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  135. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  136. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  137. package/modules/use-cases/get-module.js +10 -4
  138. package/modules/use-cases/manage-access.js +21 -0
  139. package/modules/use-cases/multi-step-authorization.js +329 -0
  140. package/modules/use-cases/process-authorization-callback.js +53 -19
  141. package/modules/use-cases/refresh-entity-options.js +10 -4
  142. package/modules/use-cases/test-module-auth.js +10 -4
  143. package/package.json +6 -5
  144. package/prisma-mongodb/schema.prisma +92 -0
  145. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  146. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  147. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  148. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  149. package/prisma-postgresql/schema.prisma +87 -0
  150. package/providers/index.js +23 -0
  151. package/providers/resolve-provider.js +93 -0
  152. package/providers/runtime-detection.js +124 -0
  153. package/queues/index.js +25 -0
  154. package/queues/providers/index.js +11 -0
  155. package/queues/providers/netlify-background-provider.js +150 -0
  156. package/queues/providers/qstash-queue-provider.js +219 -0
  157. package/queues/providers/sqs-queue-provider.js +70 -0
  158. package/queues/queue-client-interface.js +55 -0
  159. package/queues/queue-message-auth.js +193 -0
  160. package/queues/queue-provider-factory.js +101 -0
  161. package/queues/queue-provider.js +65 -0
  162. package/queues/sqs-queue-client.js +81 -0
  163. package/types/index.d.ts +2 -0
  164. package/types/modules/index.d.ts +214 -0
  165. package/types/user/index.d.ts +134 -0
  166. package/user/adopter-jwt-config.js +177 -0
  167. package/user/adopter-jwt-errors.js +59 -0
  168. package/user/adopter-token-verifier.js +122 -0
  169. package/user/app-user-helpers.js +97 -0
  170. package/user/create-user-authentication.js +64 -0
  171. package/user/membership.js +51 -0
  172. package/user/repositories/organization-membership-record.js +19 -0
  173. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  174. package/user/repositories/organization-membership-repository-factory.js +42 -0
  175. package/user/repositories/organization-membership-repository-interface.js +57 -0
  176. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  177. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  178. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  179. package/user/repositories/user-repository-documentdb.js +27 -2
  180. package/user/repositories/user-repository-interface.js +18 -0
  181. package/user/repositories/user-repository-mongo.js +24 -0
  182. package/user/repositories/user-repository-postgres.js +18 -0
  183. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  184. package/user/use-cases/authenticate-user.js +7 -1
  185. package/user/use-cases/ensure-organization-membership.js +92 -0
  186. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  187. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  188. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  189. package/user/user.js +64 -1
@@ -0,0 +1,277 @@
1
+ const { ProxyRequestError } = require('./proxy-errors');
2
+ const { isValidBaseName } = require('./proxy-bases');
3
+
4
+ /**
5
+ * What the entity proxy accepts and returns (ADR-052 §5-§7). Pure functions:
6
+ * no I/O, so every rule is unit-tested on its own.
7
+ */
8
+
9
+ const PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
10
+ const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
11
+
12
+ /** Request headers a caller may always send (lower case). */
13
+ const ALLOWED_REQUEST_HEADERS = [
14
+ 'accept',
15
+ 'content-type',
16
+ 'accept-language',
17
+ 'if-match',
18
+ 'if-none-match',
19
+ ];
20
+
21
+ /** Request headers that are refused outright, even if a module lists them. */
22
+ const REJECTED_REQUEST_HEADERS = [
23
+ 'authorization',
24
+ 'proxy-authorization',
25
+ 'cookie',
26
+ 'host',
27
+ 'forwarded',
28
+ 'x-real-ip',
29
+ 'x-api-key',
30
+ ];
31
+ const REJECTED_REQUEST_HEADER_PREFIXES = ['x-forwarded-'];
32
+
33
+ /** Response headers never passed back (credentials and hop-by-hop). */
34
+ const REMOVED_RESPONSE_HEADERS = [
35
+ 'authorization',
36
+ 'proxy-authorization',
37
+ 'proxy-authenticate',
38
+ 'set-cookie',
39
+ 'set-cookie2',
40
+ 'www-authenticate',
41
+ 'x-api-key',
42
+ 'connection',
43
+ 'keep-alive',
44
+ 'te',
45
+ 'trailer',
46
+ 'transfer-encoding',
47
+ 'upgrade',
48
+ // The body is re-encoded as JSON, so these no longer describe it.
49
+ 'content-length',
50
+ 'content-encoding',
51
+ ];
52
+
53
+ const invalid = (message, details) =>
54
+ new ProxyRequestError('INVALID_PROXY_REQUEST', message, details);
55
+
56
+ /**
57
+ * A query key ending in `[]` is sent as one `key=value` pair per array
58
+ * element; the requester applies the same rule (see
59
+ * `_rawRequest` in requester.js).
60
+ */
61
+ const isRepeatedQueryKey = (key) => key.endsWith('[]');
62
+
63
+ const isPlainObject = (value) =>
64
+ value !== null && typeof value === 'object' && !Array.isArray(value);
65
+
66
+ /**
67
+ * Validates the request body of POST /api/v2/entities/:id/proxy and returns
68
+ * it with the method upper-cased and the body serialised.
69
+ *
70
+ * @param {unknown} request
71
+ * @param {{ maxRequestBytes: number }} limits
72
+ * @returns {{ method: string, path: string, base?: string, query?: Object, headers: Object, body?: string, requestBytes: number, bodyIsJson: boolean }}
73
+ */
74
+ function validateProxyRequest(request, { maxRequestBytes }) {
75
+ if (!isPlainObject(request)) throw invalid('The proxy request must be a JSON object');
76
+ const method = typeof request.method === 'string' ? request.method.toUpperCase() : '';
77
+ if (!PROXY_METHODS.includes(method)) {
78
+ throw invalid(`method must be one of ${PROXY_METHODS.join(', ')}`, { field: 'method' });
79
+ }
80
+ if (typeof request.path !== 'string') {
81
+ throw invalid('path is required', { field: 'path' });
82
+ }
83
+
84
+ // `base` is a name, never a URL or host; anything else is refused before
85
+ // a module is looked up. Omitted means `default` (the caller applies it).
86
+ let base;
87
+ if (request.base !== undefined && request.base !== null) {
88
+ if (typeof request.base !== 'string' || !isValidBaseName(request.base)) {
89
+ throw invalid(
90
+ 'base must be the name of one of the module\'s base URLs (lower case letters, digits and -), not a URL or host',
91
+ { field: 'base' }
92
+ );
93
+ }
94
+ base = request.base;
95
+ }
96
+
97
+ let query;
98
+ if (request.query !== undefined && request.query !== null) {
99
+ if (!isPlainObject(request.query)) throw invalid('query must be an object', { field: 'query' });
100
+ query = {};
101
+ for (const [key, value] of Object.entries(request.query)) {
102
+ const scalar = ['string', 'number', 'boolean'].includes(typeof value);
103
+ const list = Array.isArray(value) && value.every((v) => typeof v === 'string');
104
+ if (!scalar && !list) {
105
+ throw invalid(`query.${key} must be a string, number, boolean or array of strings`, {
106
+ field: `query.${key}`,
107
+ });
108
+ }
109
+ // A key ending in `[]` (`fields[]`) is sent once per value, as
110
+ // Airtable and Fathom expect; every other array goes comma-separated.
111
+ if (list) {
112
+ query[key] = isRepeatedQueryKey(key) ? [...value] : value.join(',');
113
+ } else {
114
+ query[key] = value;
115
+ }
116
+ }
117
+ }
118
+
119
+ const headers = {};
120
+ if (request.headers !== undefined && request.headers !== null) {
121
+ if (!isPlainObject(request.headers)) throw invalid('headers must be an object', { field: 'headers' });
122
+ for (const [name, value] of Object.entries(request.headers)) {
123
+ if (typeof value !== 'string') {
124
+ throw invalid(`header ${name} must be a string`, { field: `headers.${name}` });
125
+ }
126
+ headers[name.toLowerCase()] = value;
127
+ }
128
+ }
129
+
130
+ let body;
131
+ let bodyIsJson = false;
132
+ if (request.body !== undefined && request.body !== null) {
133
+ if (!METHODS_WITH_BODY.has(method)) {
134
+ throw invalid(`${method} requests cannot have a body`, { field: 'body' });
135
+ }
136
+ if (typeof request.body === 'string') {
137
+ body = request.body;
138
+ } else {
139
+ body = JSON.stringify(request.body);
140
+ bodyIsJson = true;
141
+ }
142
+ }
143
+ const requestBytes = body === undefined ? 0 : Buffer.byteLength(body);
144
+ if (requestBytes > maxRequestBytes) {
145
+ throw new ProxyRequestError(
146
+ 'PAYLOAD_TOO_LARGE',
147
+ `The proxied request body is larger than ${maxRequestBytes} bytes`
148
+ );
149
+ }
150
+ return { method, path: request.path, ...(base !== undefined && { base }), query, headers, body, bodyIsJson, requestBytes };
151
+ }
152
+
153
+ const isWildcardRule = (rule) => rule?.method === '*' || rule?.path === '/**';
154
+
155
+ // A rule's `base` is checked against the module's declared bases at load, by
156
+ // assertProxyConfig (proxy-bases.js); here only its shape is.
157
+ const isValidRule = (rule) =>
158
+ isPlainObject(rule) &&
159
+ typeof rule.method === 'string' &&
160
+ typeof rule.path === 'string' &&
161
+ rule.path.startsWith('/') &&
162
+ (rule.base === undefined || isValidBaseName(rule.base));
163
+
164
+ /**
165
+ * The allow-list in force for a module: the app definition's list for it when
166
+ * one is given (`managementApi.proxy.modules[name].allow`), otherwise the
167
+ * module's own `proxy.allow`. A module cannot grant itself a wildcard; only
168
+ * the app definition can. No list means nothing may be proxied.
169
+ *
170
+ * @returns {{ rules: Array<{method: string, path: string}>, source: 'app'|'module'|'none', ignored: number }}
171
+ */
172
+ function resolveAllowRules(definition, moduleOverrides = {}) {
173
+ const override = moduleOverrides?.[definition.moduleName]?.allow;
174
+ if (Array.isArray(override)) {
175
+ return { rules: override.filter(isValidRule), source: 'app', ignored: 0 };
176
+ }
177
+ const declared = Array.isArray(definition.proxy?.allow) ? definition.proxy.allow : [];
178
+ const rules = declared.filter((rule) => isValidRule(rule) && !isWildcardRule(rule));
179
+ return {
180
+ rules,
181
+ source: rules.length ? 'module' : 'none',
182
+ ignored: declared.length - rules.length,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Keeps only the request headers the caller may set. A header from the
188
+ * rejected list (credentials, Host, forwarding headers, or one the module's
189
+ * own auth sets) fails the request instead of being dropped quietly.
190
+ *
191
+ * @param {Object} headers - lower-cased names
192
+ * @param {Object} options
193
+ * @param {string[]} [options.moduleHeaders] - from the module's `proxy.headers`
194
+ * @param {string[]} [options.authHeaderNames] - headers the module's auth sets
195
+ */
196
+ function filterRequestHeaders(headers, { moduleHeaders = [], authHeaderNames = [] } = {}) {
197
+ const rejected = new Set([
198
+ ...REJECTED_REQUEST_HEADERS,
199
+ ...authHeaderNames.filter(Boolean).map((name) => String(name).toLowerCase()),
200
+ ]);
201
+ const allowed = new Set([
202
+ ...ALLOWED_REQUEST_HEADERS,
203
+ ...moduleHeaders.map((name) => String(name).toLowerCase()),
204
+ ]);
205
+ const kept = {};
206
+ for (const [name, value] of Object.entries(headers)) {
207
+ if (
208
+ rejected.has(name) ||
209
+ REJECTED_REQUEST_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))
210
+ ) {
211
+ throw invalid(`The ${name} header cannot be sent through the proxy`, {
212
+ field: `headers.${name}`,
213
+ });
214
+ }
215
+ if (allowed.has(name)) kept[name] = value;
216
+ }
217
+ return kept;
218
+ }
219
+
220
+ /**
221
+ * Removes credential and hop-by-hop headers from an upstream response, and
222
+ * the Location of a redirect the proxy refused to follow.
223
+ */
224
+ function filterResponseHeaders(headers, { redirectBlocked = false, authHeaderNames = [] } = {}) {
225
+ const removed = new Set([
226
+ ...REMOVED_RESPONSE_HEADERS,
227
+ ...authHeaderNames.filter(Boolean).map((name) => String(name).toLowerCase()),
228
+ ]);
229
+ if (redirectBlocked) removed.add('location');
230
+ const kept = {};
231
+ for (const [name, value] of Object.entries(headers || {})) {
232
+ if (!removed.has(name.toLowerCase())) kept[name.toLowerCase()] = value;
233
+ }
234
+ return kept;
235
+ }
236
+
237
+ const JSON_TYPE = /^application\/([a-z0-9.+-]*\+)?json\b/i;
238
+ const TEXT_TYPE = /^(text\/|application\/(xml|x-www-form-urlencoded|javascript|graphql)\b|[a-z]+\/[a-z0-9.+-]*\+xml\b)/i;
239
+
240
+ /**
241
+ * Turns the upstream body into the `data` of the proxy response: parsed JSON,
242
+ * text, or (only if the module allows binary) base64.
243
+ *
244
+ * @returns {{ data: unknown, encoding?: 'base64' }}
245
+ */
246
+ function decodeResponseBody(body, contentType, { binary = false } = {}) {
247
+ if (!body || body.length === 0) return { data: null };
248
+ if (JSON_TYPE.test(contentType)) {
249
+ const text = body.toString('utf8');
250
+ try {
251
+ return { data: JSON.parse(text) };
252
+ } catch {
253
+ return { data: text };
254
+ }
255
+ }
256
+ if (!contentType || TEXT_TYPE.test(contentType)) {
257
+ return { data: body.toString('utf8') };
258
+ }
259
+ if (binary) return { data: body.toString('base64'), encoding: 'base64' };
260
+ throw new ProxyRequestError(
261
+ 'UNSUPPORTED_MEDIA_TYPE',
262
+ 'The upstream response is binary and this module does not allow binary proxying',
263
+ { contentType: contentType.split(';')[0] }
264
+ );
265
+ }
266
+
267
+ module.exports = {
268
+ PROXY_METHODS,
269
+ ALLOWED_REQUEST_HEADERS,
270
+ REJECTED_REQUEST_HEADERS,
271
+ REMOVED_RESPONSE_HEADERS,
272
+ validateProxyRequest,
273
+ resolveAllowRules,
274
+ filterRequestHeaders,
275
+ filterResponseHeaders,
276
+ decodeResponseBody,
277
+ };
@@ -0,0 +1,112 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const {
3
+ findOne,
4
+ insertOne,
5
+ updateOne,
6
+ deleteMany,
7
+ } = require('../../database/documentdb-utils');
8
+ const {
9
+ DocumentDBEncryptionService,
10
+ } = require('../../database/documentdb-encryption-service');
11
+ const { AuthorizationSession } = require('../domain/authorization-session');
12
+ const {
13
+ AuthorizationSessionRepositoryInterface,
14
+ } = require('./authorization-session-repository-interface');
15
+
16
+ const COLLECTION = 'AuthorizationSession';
17
+
18
+ /**
19
+ * AuthorizationSession persistence for DocumentDB through raw commands.
20
+ * `stepData` is encrypted with DocumentDBEncryptionService, as the Prisma
21
+ * extension does not run for raw commands.
22
+ */
23
+ class AuthorizationSessionRepositoryDocumentDB extends AuthorizationSessionRepositoryInterface {
24
+ constructor() {
25
+ super();
26
+ this.prisma = prisma;
27
+ this.encryptionService = new DocumentDBEncryptionService();
28
+ }
29
+
30
+ async create(session) {
31
+ const now = new Date();
32
+ const document = await this.encryptionService.encryptFields(COLLECTION, {
33
+ sessionId: session.sessionId,
34
+ userId: session.userId,
35
+ entityType: session.entityType,
36
+ credentialId: session.credentialId,
37
+ currentStep: session.currentStep,
38
+ maxSteps: session.maxSteps,
39
+ stepData: session.stepData,
40
+ expiresAt: session.expiresAt,
41
+ completed: session.completed,
42
+ createdAt: now,
43
+ updatedAt: now,
44
+ });
45
+ await insertOne(this.prisma, COLLECTION, document);
46
+ return this._toDomain(
47
+ await this.encryptionService.decryptFields(COLLECTION, document)
48
+ );
49
+ }
50
+
51
+ async findBySessionId(sessionId) {
52
+ if (typeof sessionId !== 'string' || !sessionId) return null;
53
+ const document = await findOne(this.prisma, COLLECTION, {
54
+ sessionId,
55
+ expiresAt: { $gt: new Date() },
56
+ });
57
+ if (!document) return null;
58
+ return this._toDomain(
59
+ await this.encryptionService.decryptFields(COLLECTION, document)
60
+ );
61
+ }
62
+
63
+ async update(session) {
64
+ const encrypted = await this.encryptionService.encryptFields(COLLECTION, {
65
+ stepData: session.stepData,
66
+ });
67
+ await updateOne(
68
+ this.prisma,
69
+ COLLECTION,
70
+ { sessionId: session.sessionId },
71
+ {
72
+ $set: {
73
+ currentStep: session.currentStep,
74
+ stepData: encrypted.stepData,
75
+ completed: session.completed,
76
+ updatedAt: new Date(),
77
+ },
78
+ }
79
+ );
80
+ return session;
81
+ }
82
+
83
+ async deleteBySessionId(sessionId) {
84
+ const result = await deleteMany(this.prisma, COLLECTION, { sessionId });
85
+ return result?.n ?? 0;
86
+ }
87
+
88
+ async deleteExpired(now = new Date()) {
89
+ const result = await deleteMany(this.prisma, COLLECTION, {
90
+ expiresAt: { $lte: now },
91
+ });
92
+ return result?.n ?? 0;
93
+ }
94
+
95
+ _toDomain(document) {
96
+ return new AuthorizationSession({
97
+ sessionId: document.sessionId,
98
+ userId: document.userId,
99
+ entityType: document.entityType,
100
+ credentialId: document.credentialId ?? null,
101
+ currentStep: document.currentStep,
102
+ maxSteps: document.maxSteps,
103
+ stepData: document.stepData || {},
104
+ expiresAt: new Date(document.expiresAt),
105
+ completed: Boolean(document.completed),
106
+ createdAt: document.createdAt,
107
+ updatedAt: document.updatedAt,
108
+ });
109
+ }
110
+ }
111
+
112
+ module.exports = { AuthorizationSessionRepositoryDocumentDB };
@@ -0,0 +1,31 @@
1
+ const config = require('../../database/config');
2
+ const {
3
+ AuthorizationSessionRepositoryPrisma,
4
+ } = require('./authorization-session-repository-prisma');
5
+ const {
6
+ AuthorizationSessionRepositoryDocumentDB,
7
+ } = require('./authorization-session-repository-documentdb');
8
+
9
+ /**
10
+ * AuthorizationSession repository for the configured database. PostgreSQL
11
+ * and MongoDB share the Prisma adapter; DocumentDB uses raw commands.
12
+ */
13
+ function createAuthorizationSessionRepository() {
14
+ switch (config.DB_TYPE) {
15
+ case 'mongodb':
16
+ case 'postgresql':
17
+ return new AuthorizationSessionRepositoryPrisma();
18
+ case 'documentdb':
19
+ return new AuthorizationSessionRepositoryDocumentDB();
20
+ default:
21
+ throw new Error(
22
+ `Unsupported database type: ${config.DB_TYPE}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
23
+ );
24
+ }
25
+ }
26
+
27
+ module.exports = {
28
+ createAuthorizationSessionRepository,
29
+ AuthorizationSessionRepositoryPrisma,
30
+ AuthorizationSessionRepositoryDocumentDB,
31
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Port for persisting AuthorizationSession records (Management API v2
3
+ * multi-step authorization). Adapters return AuthorizationSession domain
4
+ * objects and never expired sessions from lookups.
5
+ */
6
+ class AuthorizationSessionRepositoryInterface {
7
+ /**
8
+ * @param {import('../domain/authorization-session').AuthorizationSession} session
9
+ * @returns {Promise<import('../domain/authorization-session').AuthorizationSession>}
10
+ */
11
+ async create(session) {
12
+ throw new Error('Method create must be implemented by subclass');
13
+ }
14
+
15
+ /**
16
+ * @param {string} sessionId
17
+ * @returns {Promise<import('../domain/authorization-session').AuthorizationSession|null>}
18
+ * null when missing or expired.
19
+ */
20
+ async findBySessionId(sessionId) {
21
+ throw new Error('Method findBySessionId must be implemented by subclass');
22
+ }
23
+
24
+ /**
25
+ * Saves currentStep, stepData and completed.
26
+ * @param {import('../domain/authorization-session').AuthorizationSession} session
27
+ */
28
+ async update(session) {
29
+ throw new Error('Method update must be implemented by subclass');
30
+ }
31
+
32
+ /** @param {string} sessionId */
33
+ async deleteBySessionId(sessionId) {
34
+ throw new Error('Method deleteBySessionId must be implemented by subclass');
35
+ }
36
+
37
+ /**
38
+ * Removes sessions that expired before `now`.
39
+ * @param {Date} [now]
40
+ * @returns {Promise<number>} how many were removed
41
+ */
42
+ async deleteExpired(now) {
43
+ throw new Error('Method deleteExpired must be implemented by subclass');
44
+ }
45
+ }
46
+
47
+ module.exports = { AuthorizationSessionRepositoryInterface };
@@ -0,0 +1,87 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { AuthorizationSession } = require('../domain/authorization-session');
3
+ const {
4
+ AuthorizationSessionRepositoryInterface,
5
+ } = require('./authorization-session-repository-interface');
6
+
7
+ /**
8
+ * AuthorizationSession persistence through the Prisma client, for PostgreSQL
9
+ * and MongoDB (both schemas declare the same model; the session id, not the
10
+ * row id, is the key, so no id conversion is needed). `stepData` is
11
+ * encrypted by the Prisma encryption extension (encryption-schema-registry).
12
+ */
13
+ class AuthorizationSessionRepositoryPrisma extends AuthorizationSessionRepositoryInterface {
14
+ constructor(prismaClient = prisma) {
15
+ super();
16
+ this.prisma = prismaClient;
17
+ }
18
+
19
+ async create(session) {
20
+ const record = await this.prisma.authorizationSession.create({
21
+ data: {
22
+ sessionId: session.sessionId,
23
+ userId: session.userId,
24
+ entityType: session.entityType,
25
+ credentialId: session.credentialId,
26
+ currentStep: session.currentStep,
27
+ maxSteps: session.maxSteps,
28
+ stepData: session.stepData,
29
+ expiresAt: session.expiresAt,
30
+ completed: session.completed,
31
+ },
32
+ });
33
+ return toDomain(record);
34
+ }
35
+
36
+ async findBySessionId(sessionId) {
37
+ if (typeof sessionId !== 'string' || !sessionId) return null;
38
+ const record = await this.prisma.authorizationSession.findFirst({
39
+ where: { sessionId, expiresAt: { gt: new Date() } },
40
+ });
41
+ return record ? toDomain(record) : null;
42
+ }
43
+
44
+ async update(session) {
45
+ const record = await this.prisma.authorizationSession.update({
46
+ where: { sessionId: session.sessionId },
47
+ data: {
48
+ currentStep: session.currentStep,
49
+ stepData: session.stepData,
50
+ completed: session.completed,
51
+ },
52
+ });
53
+ return toDomain(record);
54
+ }
55
+
56
+ async deleteBySessionId(sessionId) {
57
+ const result = await this.prisma.authorizationSession.deleteMany({
58
+ where: { sessionId },
59
+ });
60
+ return result.count;
61
+ }
62
+
63
+ async deleteExpired(now = new Date()) {
64
+ const result = await this.prisma.authorizationSession.deleteMany({
65
+ where: { expiresAt: { lte: now } },
66
+ });
67
+ return result.count;
68
+ }
69
+ }
70
+
71
+ function toDomain(record) {
72
+ return new AuthorizationSession({
73
+ sessionId: record.sessionId,
74
+ userId: record.userId,
75
+ entityType: record.entityType,
76
+ credentialId: record.credentialId ?? null,
77
+ currentStep: record.currentStep,
78
+ maxSteps: record.maxSteps,
79
+ stepData: record.stepData || {},
80
+ expiresAt: record.expiresAt,
81
+ completed: record.completed,
82
+ createdAt: record.createdAt,
83
+ updatedAt: record.updatedAt,
84
+ });
85
+ }
86
+
87
+ module.exports = { AuthorizationSessionRepositoryPrisma, toDomain };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The error `findEntityById` throws for an id with no row, and the one
3
+ * predicate that recognises it (as integration-not-found.js does for
4
+ * integrations). The adapters build the error here and use cases match it
5
+ * here, so a missing entity can be told from any other failure without
6
+ * widening a regex.
7
+ *
8
+ * Why it matters: a use case that answers 404 for another tenant's entity must
9
+ * answer the same 404 for an id that does not exist. Otherwise the status code
10
+ * says which ids exist (ADR-052 §3, ADR-056 §3.6).
11
+ */
12
+ const MESSAGE = /^Entity .+ not found$/;
13
+
14
+ const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
15
+
16
+ const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
17
+
18
+ module.exports = { entityNotFoundError, isEntityNotFound };