@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.686.821f8a3.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 (188) 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 +121 -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 +408 -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-errors.js +20 -0
  105. package/modules/proxy/proxy-path.js +179 -0
  106. package/modules/proxy/proxy-policy.js +248 -0
  107. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  108. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  109. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  110. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  111. package/modules/repositories/entity-not-found.js +18 -0
  112. package/modules/repositories/module-repository-documentdb.js +51 -6
  113. package/modules/repositories/module-repository-interface.js +19 -2
  114. package/modules/repositories/module-repository-mongo.js +67 -74
  115. package/modules/repositories/module-repository-postgres.js +66 -74
  116. package/modules/repositories/module-repository.js +2 -1
  117. package/modules/requester/proxy-request.js +244 -0
  118. package/modules/requester/requester.js +27 -2
  119. package/modules/scope-config.js +192 -0
  120. package/modules/scope-policy.js +112 -0
  121. package/modules/scope-resolution.js +288 -0
  122. package/modules/scope-visibility.js +247 -0
  123. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  124. package/modules/tests/doubles/ownership-matching.js +18 -0
  125. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  126. package/modules/use-cases/create-entity-from-credential.js +113 -0
  127. package/modules/use-cases/entity-operations-for-user.js +162 -0
  128. package/modules/use-cases/entity-types.js +139 -0
  129. package/modules/use-cases/execute-entity-proxy-request.js +286 -0
  130. package/modules/use-cases/get-authorization-requirements.js +100 -0
  131. package/modules/use-cases/get-entities-for-user.js +26 -13
  132. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  133. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  134. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  135. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  136. package/modules/use-cases/get-module.js +10 -4
  137. package/modules/use-cases/manage-access.js +21 -0
  138. package/modules/use-cases/multi-step-authorization.js +329 -0
  139. package/modules/use-cases/process-authorization-callback.js +53 -19
  140. package/modules/use-cases/refresh-entity-options.js +10 -4
  141. package/modules/use-cases/test-module-auth.js +10 -4
  142. package/package.json +6 -5
  143. package/prisma-mongodb/schema.prisma +92 -0
  144. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  145. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  146. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  147. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  148. package/prisma-postgresql/schema.prisma +87 -0
  149. package/providers/index.js +23 -0
  150. package/providers/resolve-provider.js +93 -0
  151. package/providers/runtime-detection.js +124 -0
  152. package/queues/index.js +25 -0
  153. package/queues/providers/index.js +11 -0
  154. package/queues/providers/netlify-background-provider.js +150 -0
  155. package/queues/providers/qstash-queue-provider.js +219 -0
  156. package/queues/providers/sqs-queue-provider.js +70 -0
  157. package/queues/queue-client-interface.js +55 -0
  158. package/queues/queue-message-auth.js +193 -0
  159. package/queues/queue-provider-factory.js +101 -0
  160. package/queues/queue-provider.js +65 -0
  161. package/queues/sqs-queue-client.js +81 -0
  162. package/types/index.d.ts +2 -0
  163. package/types/modules/index.d.ts +197 -0
  164. package/types/user/index.d.ts +134 -0
  165. package/user/adopter-jwt-config.js +177 -0
  166. package/user/adopter-jwt-errors.js +59 -0
  167. package/user/adopter-token-verifier.js +122 -0
  168. package/user/app-user-helpers.js +97 -0
  169. package/user/create-user-authentication.js +64 -0
  170. package/user/membership.js +51 -0
  171. package/user/repositories/organization-membership-record.js +19 -0
  172. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  173. package/user/repositories/organization-membership-repository-factory.js +42 -0
  174. package/user/repositories/organization-membership-repository-interface.js +57 -0
  175. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  176. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  177. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  178. package/user/repositories/user-repository-documentdb.js +27 -2
  179. package/user/repositories/user-repository-interface.js +18 -0
  180. package/user/repositories/user-repository-mongo.js +24 -0
  181. package/user/repositories/user-repository-postgres.js +18 -0
  182. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  183. package/user/use-cases/authenticate-user.js +7 -1
  184. package/user/use-cases/ensure-organization-membership.js +92 -0
  185. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  186. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  187. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  188. package/user/user.js +64 -1
@@ -0,0 +1,103 @@
1
+ const Boom = require('@hapi/boom');
2
+
3
+ /**
4
+ * In-memory doubles for every Management API v2 dependency. Each use case is
5
+ * a jest.fn() so a test can assert on calls or override one behaviour.
6
+ */
7
+ function fakeV2Dependencies(overrides = {}) {
8
+ const user = { getId: () => 'user-1' };
9
+ const notFound = (code) => async () => {
10
+ throw Boom.notFound('not found', { code });
11
+ };
12
+ const fn = (impl) => ({ execute: jest.fn(impl) });
13
+
14
+ return {
15
+ user,
16
+ config: { v1: true, proxy: { enable: false } },
17
+ authenticateUser: {
18
+ execute: jest.fn(async (req) => {
19
+ if (!req.headers.authorization) {
20
+ throw Boom.unauthorized('No valid authentication provided');
21
+ }
22
+ return user;
23
+ }),
24
+ },
25
+ getIntegrationsForUser: fn(async () => [
26
+ { id: 'i1', entities: ['e1'], status: 'ENABLED', config: { type: 'sync' }, userActions: {} },
27
+ ]),
28
+ getPossibleIntegrations: fn(async () => [{ type: 'sync', display: {} }]),
29
+ createIntegrationForUser: fn(async () => ({
30
+ id: 'i2', entities: ['e1'], status: 'ENABLED', config: { type: 'sync' },
31
+ })),
32
+ getIntegrationInstanceForUser: fn(async (id) => {
33
+ if (id !== 'i1') return notFound('INTEGRATION_NOT_FOUND')();
34
+ return { id: 'i1', entities: ['e1'], status: 'ENABLED', config: { type: 'sync' } };
35
+ }),
36
+ updateIntegrationForUser: fn(async (id, _user, config) => ({
37
+ id, entities: ['e1'], status: 'ENABLED', config,
38
+ })),
39
+ removeIntegrationForUser: fn(async () => undefined),
40
+ sendIntegrationEventForUser: fn(async (_id, _user, event, payload) => ({ event, payload })),
41
+ runIntegrationActionForUser: fn(async (_id, _user, actionId, payload) => ({ ran: actionId, payload })),
42
+ testIntegrationAuthForUser: fn(async () => ({ ok: true, errors: [] })),
43
+
44
+ listEntitiesForUser: fn(async () => [
45
+ { id: 'e1', userId: 'user-1', moduleName: 'acme', name: 'Acme', externalId: 'x1', credential: { id: 'c1', authIsValid: true, access_token: 'secret' } },
46
+ ]),
47
+ getEntityForUser: fn(async (id) => {
48
+ if (id !== 'e1') return notFound('ENTITY_NOT_FOUND')();
49
+ return { id: 'e1', userId: 'user-1', moduleName: 'acme', name: 'Acme', externalId: 'x1', credential: { id: 'c1', authIsValid: true, access_token: 'secret' } };
50
+ }),
51
+ deleteEntityForUser: fn(async () => undefined),
52
+ testEntityAuthForUser: fn(async () => true),
53
+ getEntityOptionsForUser: fn(async () => ({ jsonSchema: {} })),
54
+ refreshEntityOptionsForUser: fn(async (_id, _user, body) => ({ refreshed: body })),
55
+ listEntityTypes: fn(async () => [{ type: 'acme', name: 'Acme' }]),
56
+ getEntityType: fn(async (type) => {
57
+ if (type !== 'acme') return notFound('ENTITY_TYPE_NOT_FOUND')();
58
+ return { type: 'acme', name: 'Acme' };
59
+ }),
60
+ getAuthorizationRequirements: fn(async ({ step }) => ({
61
+ type: 'oauth2', data: { url: 'https://provider.example/authorize' }, step, totalSteps: 1, isMultiStep: false,
62
+ })),
63
+ listCredentialsForUser: fn(async () => [
64
+ { id: 'c1', userId: 'user-1', type: 'acme', entityIds: ['e1'], authIsValid: true, access_token: 'ya29.secret-token-value-1234' },
65
+ ]),
66
+ getCredentialForUser: fn(async (id) => {
67
+ if (id !== 'c1') return notFound('CREDENTIAL_NOT_FOUND')();
68
+ return { id: 'c1', userId: 'user-1', type: 'acme', entityIds: ['e1'], authIsValid: true, api_key: 'sk_live_abcdefghijkl9876' };
69
+ }),
70
+ deleteCredentialForUser: fn(async () => ({ id: 'c1', entityIds: ['e1'] })),
71
+ getAuthorizationStep: fn(async ({ entityType, step }) => {
72
+ if (entityType === 'nope') return notFound('ENTITY_TYPE_NOT_FOUND')();
73
+ return { type: 'email', data: {}, step, totalSteps: 2, isMultiStep: true, sessionId: 'sess-1' };
74
+ }),
75
+ submitAuthorizationStep: fn(async ({ step }) =>
76
+ step === 1
77
+ ? { status: 'pending', step: 2, totalSteps: 2, sessionId: 'sess-1', requirements: { type: 'otp' } }
78
+ : {
79
+ status: 'complete',
80
+ entity: { id: 'e1', userId: 'user-1', moduleName: 'acme', credential: { id: 'c1', authIsValid: true } },
81
+ credential: { id: 'c1', userId: 'user-1', type: 'acme', entityIds: ['e1'], authIsValid: true, access_token: 'ya29.brand-new-token-0001' },
82
+ }
83
+ ),
84
+ getCredentialReauthorization: fn(async (id, _user, { step }) => ({ type: 'oauth2', data: {}, step, totalSteps: 1 })),
85
+ reauthorizeCredentialForUser: fn(async () => ({
86
+ status: 'complete',
87
+ entity: { id: 'e1', userId: 'user-1', moduleName: 'acme' },
88
+ credential: { id: 'c2', userId: 'user-1', type: 'acme', entityIds: ['e1'], authIsValid: true },
89
+ previousCredentialId: 'c1',
90
+ })),
91
+ executeEntityProxyRequest: fn(async (entityId, _user, request) => {
92
+ if (request?.path === '/limited') {
93
+ throw Boom.tooManyRequests('The upstream API is rate limiting this account', {
94
+ code: 'RATE_LIMITED', proxy: true, expose: true, retryAfter: '30', details: { upstreamStatus: 429 },
95
+ });
96
+ }
97
+ return { status: 201, body: { success: true, status: 201, headers: {}, data: { id: 'new' } } };
98
+ }),
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ module.exports = { fakeV2Dependencies };
@@ -0,0 +1,144 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ const DEFAULT_TTL_MINUTES = 15;
4
+
5
+ function sessionTtlMs() {
6
+ const minutes = parseInt(process.env.AUTH_SESSION_EXPIRY_MINUTES || '', 10);
7
+ return (Number.isInteger(minutes) && minutes > 0 ? minutes : DEFAULT_TTL_MINUTES) * 60 * 1000;
8
+ }
9
+
10
+ /**
11
+ * State of one multi-step authorization (Management API v2
12
+ * `/api/v2/authorize` and `/api/v2/credentials/:id/reauthorize`).
13
+ *
14
+ * `stepData` holds what earlier steps collected (for example the email an OTP
15
+ * was sent to) and is encrypted at rest (encryption-schema-registry). A
16
+ * session is bound to the user who started it, the entity type, and, for a
17
+ * re-authorization, the credential being renewed.
18
+ */
19
+ class AuthorizationSession {
20
+ /**
21
+ * @param {Object} params
22
+ * @param {string} params.sessionId - unguessable id (UUID v4)
23
+ * @param {string} params.userId
24
+ * @param {string} params.entityType - module name
25
+ * @param {string|null} [params.credentialId] - set for re-authorization
26
+ * @param {number} [params.currentStep=1] - the step the caller submits next
27
+ * @param {number} params.maxSteps
28
+ * @param {Object} [params.stepData={}]
29
+ * @param {Date} params.expiresAt
30
+ * @param {boolean} [params.completed=false]
31
+ * @param {Date} [params.createdAt]
32
+ * @param {Date} [params.updatedAt]
33
+ */
34
+ constructor({
35
+ sessionId,
36
+ userId,
37
+ entityType,
38
+ credentialId = null,
39
+ currentStep = 1,
40
+ maxSteps,
41
+ stepData = {},
42
+ expiresAt,
43
+ completed = false,
44
+ createdAt = new Date(),
45
+ updatedAt = new Date(),
46
+ }) {
47
+ this.sessionId = sessionId;
48
+ this.userId = userId === undefined || userId === null ? userId : String(userId);
49
+ this.entityType = entityType;
50
+ this.credentialId =
51
+ credentialId === undefined || credentialId === null ? null : String(credentialId);
52
+ this.currentStep = currentStep;
53
+ this.maxSteps = maxSteps;
54
+ this.stepData = stepData && typeof stepData === 'object' ? stepData : {};
55
+ this.expiresAt = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
56
+ this.completed = completed;
57
+ this.createdAt = createdAt;
58
+ this.updatedAt = updatedAt;
59
+ this.validate();
60
+ }
61
+
62
+ /**
63
+ * A new session for step 1, expiring after AUTH_SESSION_EXPIRY_MINUTES
64
+ * (default 15).
65
+ */
66
+ static start({ userId, entityType, maxSteps, credentialId = null, now = new Date() }) {
67
+ return new AuthorizationSession({
68
+ sessionId: crypto.randomUUID(),
69
+ userId,
70
+ entityType,
71
+ credentialId,
72
+ currentStep: 1,
73
+ maxSteps,
74
+ stepData: {},
75
+ expiresAt: new Date(now.getTime() + sessionTtlMs()),
76
+ completed: false,
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ });
80
+ }
81
+
82
+ validate() {
83
+ if (!this.sessionId) throw new Error('sessionId is required');
84
+ if (!this.userId) throw new Error('userId is required');
85
+ if (!this.entityType) throw new Error('entityType is required');
86
+ if (!Number.isInteger(this.maxSteps) || this.maxSteps < 1) {
87
+ throw new Error('maxSteps must be a positive integer');
88
+ }
89
+ if (
90
+ !Number.isInteger(this.currentStep) ||
91
+ this.currentStep < 1 ||
92
+ this.currentStep > this.maxSteps
93
+ ) {
94
+ throw new Error(`currentStep must be between 1 and ${this.maxSteps}`);
95
+ }
96
+ if (Number.isNaN(this.expiresAt.getTime())) {
97
+ throw new Error('expiresAt must be a valid date');
98
+ }
99
+ }
100
+
101
+ isExpired(now = new Date()) {
102
+ return this.expiresAt.getTime() <= now.getTime();
103
+ }
104
+
105
+ /** True when this session may be used by `userId` for `entityType`/`credentialId`. */
106
+ belongsTo({ userId, entityType, credentialId = null }) {
107
+ return (
108
+ this.userId === String(userId) &&
109
+ this.entityType === entityType &&
110
+ this.credentialId === (credentialId === null || credentialId === undefined ? null : String(credentialId))
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Records a finished step and moves to `nextStep`, merging what the step
116
+ * collected into stepData.
117
+ */
118
+ advanceTo(nextStep, collected = {}) {
119
+ if (this.completed) throw new Error('Cannot advance a completed session');
120
+ if (nextStep !== this.currentStep + 1 || nextStep > this.maxSteps) {
121
+ throw new Error(
122
+ `Cannot move from step ${this.currentStep} to step ${nextStep} of ${this.maxSteps}`
123
+ );
124
+ }
125
+ this.currentStep = nextStep;
126
+ this.stepData = { ...this.stepData, ...(collected || {}) };
127
+ this.updatedAt = new Date();
128
+ }
129
+
130
+ /** Starts the flow over at step 1, forgetting collected data. */
131
+ restart() {
132
+ this.currentStep = 1;
133
+ this.stepData = {};
134
+ this.completed = false;
135
+ this.updatedAt = new Date();
136
+ }
137
+
138
+ markComplete() {
139
+ this.completed = true;
140
+ this.updatedAt = new Date();
141
+ }
142
+ }
143
+
144
+ module.exports = { AuthorizationSession, DEFAULT_TTL_MINUTES };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
3
+ * is split into:
4
+ *
5
+ * scope-resolution constants, scope resolution, ownership on create
6
+ * scope-config reading and validating app-definition settings
7
+ * scope-visibility who may see which stored row
8
+ * scope-policy the object a use case asks
9
+ */
10
+ module.exports = {
11
+ ...require('./scope-resolution'),
12
+ ...require('./scope-config'),
13
+ ...require('./scope-visibility'),
14
+ ...require('./scope-policy'),
15
+ };
@@ -0,0 +1,311 @@
1
+ /**
2
+ * App-level entity type registry (ADR-059).
3
+ *
4
+ * Pure: reads an app definition, does no I/O. It answers "which entity types
5
+ * (API modules) can this app connect?" from two sources:
6
+ *
7
+ * 1. `appDefinition.entities`, an object keyed by entity type name whose
8
+ * values are `{ definition, scope?, proxy?, display? }`.
9
+ * 2. Inference: every module bound by `appDefinition.integrations[*]
10
+ * .Definition.modules` (skipped when `entities.$infer === false`).
11
+ *
12
+ * `loadAppDefinition()` builds the registry once, at load, and returns it as
13
+ * `entityTypeRegistry` / `moduleDefinitions`; every consumer that holds a
14
+ * loaded app definition reads those instead of rebuilding.
15
+ */
16
+
17
+ const { BaseError } = require('../errors/base-error');
18
+ const { getLogger } = require('../logs');
19
+
20
+ const log = getLogger('frigg.modules.entity_registry');
21
+
22
+ const INFER_KEY = '$infer';
23
+ const UNNAMED = '(unnamed definition)';
24
+
25
+ /**
26
+ * Raised for an invalid `entities` registry. `code` is always
27
+ * `ENTITY_REGISTRY_INVALID`, so callers can tell it from other load errors.
28
+ */
29
+ class EntityRegistryError extends BaseError {
30
+ constructor(message, options) {
31
+ super(message, options);
32
+ this.code = 'ENTITY_REGISTRY_INVALID';
33
+ }
34
+ }
35
+
36
+ const isPlainObject = (value) =>
37
+ value !== null && typeof value === 'object' && !Array.isArray(value);
38
+
39
+ const appSource = () => ({ kind: 'app' });
40
+ const integrationSource = (integrationClass) => ({
41
+ kind: 'integration',
42
+ name: integrationClass?.Definition?.name || 'unnamed',
43
+ });
44
+ const describeSource = (source) =>
45
+ source.kind === 'app' ? 'app' : `integration:${source.name}`;
46
+
47
+ const safeGetName = (definition) => {
48
+ if (typeof definition.getName !== 'function') return undefined;
49
+ try {
50
+ const name = definition.getName();
51
+ return typeof name === 'string' && name ? name : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ };
56
+
57
+ const moduleNameOf = (definition) =>
58
+ typeof definition?.moduleName === 'string' && definition.moduleName
59
+ ? definition.moduleName
60
+ : undefined;
61
+
62
+ /** The name an entity type is known by: moduleName, else getName(). */
63
+ function entityTypeNameOf(definition) {
64
+ if (!definition) return undefined;
65
+ return moduleNameOf(definition) ?? safeGetName(definition);
66
+ }
67
+
68
+ /** Same object, or the same package name and version when both can be read. */
69
+ function isSameDefinition(a, b) {
70
+ if (a === b) return true;
71
+ const packageId = (definition) => {
72
+ const { packageName, version } = definition || {};
73
+ return typeof packageName === 'string' &&
74
+ packageName &&
75
+ typeof version === 'string' &&
76
+ version
77
+ ? `${packageName}@${version}`
78
+ : null;
79
+ };
80
+ const left = packageId(a);
81
+ return left !== null && left === packageId(b);
82
+ }
83
+
84
+ function conflictError(name, existing, source) {
85
+ return new EntityRegistryError(
86
+ `Entity type '${name}' is supplied by two different definitions: ` +
87
+ `${existing.sources
88
+ .map(describeSource)
89
+ .join(', ')} and ${describeSource(source)}. ` +
90
+ 'They must be the same definition object (or the same package name and version).'
91
+ );
92
+ }
93
+
94
+ /**
95
+ * A registry key must equal the definition's moduleName and, when it has one,
96
+ * its getName(). Case matters. This leaves one name per entity type.
97
+ */
98
+ function assertKeyMatchesDefinition(key, definition) {
99
+ const moduleName = moduleNameOf(definition);
100
+ const getName = safeGetName(definition);
101
+ const matches =
102
+ (moduleName !== undefined || getName !== undefined) &&
103
+ (moduleName === undefined || key === moduleName) &&
104
+ (getName === undefined || key === getName);
105
+ if (!matches) {
106
+ throw new EntityRegistryError(
107
+ `appDefinition.entities key '${key}' does not match its definition's name ` +
108
+ `(moduleName '${moduleName ?? '(none)'}', getName() '${
109
+ getName ?? '(none)'
110
+ }'). ` +
111
+ 'The key must equal both, so each entity type has one name.'
112
+ );
113
+ }
114
+ }
115
+
116
+ const settingsOf = (entry) => {
117
+ const settings = {};
118
+ for (const field of ['scope', 'proxy', 'display']) {
119
+ if (entry[field] !== undefined) settings[field] = entry[field];
120
+ }
121
+ return settings;
122
+ };
123
+
124
+ /**
125
+ * `entities` absent keeps today's behaviour: inference on, and a name clash
126
+ * between integrations is not fatal. `entities` present (even `{}`) opts in to
127
+ * the strict rules.
128
+ */
129
+ function readEntities(appDefinition) {
130
+ const entities = appDefinition?.entities;
131
+ if (entities === undefined || entities === null) {
132
+ return { strict: false, infer: true, entries: [] };
133
+ }
134
+ if (!isPlainObject(entities)) {
135
+ throw new EntityRegistryError(
136
+ 'appDefinition.entities must be an object keyed by entity type name'
137
+ );
138
+ }
139
+ const { [INFER_KEY]: inferFlag, ...rest } = entities;
140
+ if (inferFlag !== undefined && typeof inferFlag !== 'boolean') {
141
+ throw new EntityRegistryError(
142
+ `appDefinition.entities.${INFER_KEY} must be a boolean`
143
+ );
144
+ }
145
+ return {
146
+ strict: true,
147
+ infer: inferFlag !== false,
148
+ entries: Object.entries(rest),
149
+ };
150
+ }
151
+
152
+ function registerAppEntries(registry, entries) {
153
+ for (const [key, entry] of entries) {
154
+ if (
155
+ !isPlainObject(entry) ||
156
+ !entry.definition ||
157
+ typeof entry.definition !== 'object'
158
+ ) {
159
+ throw new EntityRegistryError(
160
+ `appDefinition.entities.${key} must be an object with a 'definition'`
161
+ );
162
+ }
163
+ assertKeyMatchesDefinition(key, entry.definition);
164
+ registry.set(key, {
165
+ definition: entry.definition,
166
+ sources: [appSource()],
167
+ settings: settingsOf(entry),
168
+ });
169
+ }
170
+ }
171
+
172
+ function* boundDefinitions(integrationClass) {
173
+ const modules = integrationClass?.Definition?.modules;
174
+ if (!isPlainObject(modules)) return;
175
+ for (const bound of Object.values(modules)) {
176
+ if (bound?.definition) yield bound.definition;
177
+ }
178
+ }
179
+
180
+ const addSource = (entry, source) => {
181
+ const known = entry.sources.some(
182
+ (s) => s.kind === source.kind && s.name === source.name
183
+ );
184
+ if (!known) entry.sources.push(source);
185
+ };
186
+
187
+ /**
188
+ * Folds one integration-bound definition into the registry. Returns the name
189
+ * it could not register (only possible when inference is off), else undefined.
190
+ */
191
+ function mergeBoundDefinition(registry, definition, source, { strict, infer }) {
192
+ const name = entityTypeNameOf(definition);
193
+
194
+ if (!name) {
195
+ // No moduleName / getName(): it cannot be keyed by name. Offering it
196
+ // as before, de-duplicated by identity, keeps apps that never named
197
+ // one working. It cannot be registered, so `$infer: false` rejects it.
198
+ if (!infer) return UNNAMED;
199
+ const known = [...registry.values()].find(
200
+ (entry) => entry.definition === definition
201
+ );
202
+ if (known) addSource(known, source);
203
+ else {
204
+ registry.set(`${UNNAMED} #${registry.size}`, {
205
+ definition,
206
+ sources: [source],
207
+ settings: {},
208
+ });
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ const existing = registry.get(name);
214
+ if (existing) {
215
+ if (isSameDefinition(existing.definition, definition)) {
216
+ addSource(existing, source);
217
+ } else if (strict) {
218
+ throw conflictError(name, existing, source);
219
+ } else {
220
+ // Pre-ADR-059 apps keep working: the first definition wins, as
221
+ // ModuleFactory would have picked it.
222
+ log.warn('Two different definitions share one entity type name', {
223
+ eventName: `${log.name}.duplicate_definition`,
224
+ entityType: name,
225
+ kept: existing.sources.map(describeSource).join(', '),
226
+ ignored: describeSource(source),
227
+ });
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ if (!infer) return name;
233
+ registry.set(name, { definition, sources: [source], settings: {} });
234
+ return undefined;
235
+ }
236
+
237
+ function assertAllRegistered(unregistered) {
238
+ if (unregistered.size === 0) return;
239
+ const detail = [...unregistered]
240
+ .sort(([a], [b]) => a.localeCompare(b))
241
+ .map(
242
+ ([name, sources]) => `${name} (bound by ${[...sources].join(', ')})`
243
+ );
244
+ throw new EntityRegistryError(
245
+ `appDefinition.entities.${INFER_KEY} is false, so every module an integration binds must be registered. Missing: ${detail.join(
246
+ '; '
247
+ )}`
248
+ );
249
+ }
250
+
251
+ const freezeEntries = (registry) => {
252
+ for (const entry of registry.values()) {
253
+ entry.sources.forEach(Object.freeze);
254
+ Object.freeze(entry.sources);
255
+ Object.freeze(entry.settings);
256
+ Object.freeze(entry);
257
+ }
258
+ };
259
+
260
+ /**
261
+ * Builds the registry for an app definition.
262
+ *
263
+ * @param {object} [appDefinition] - `{ entities?, integrations? }`
264
+ * @returns {Map<string, {definition: object, sources: Array<{kind: 'app'} | {kind: 'integration', name: string}>, settings: {scope?: string|object, proxy?: object, display?: object}}>}
265
+ * @throws {EntityRegistryError} on a key/name mismatch, two different
266
+ * definitions under one name (when `entities` is present), or, with
267
+ * `$infer: false`, a bound module that is not registered.
268
+ */
269
+ function getEntityTypeRegistry(appDefinition = {}) {
270
+ const mode = readEntities(appDefinition);
271
+ const registry = new Map();
272
+ registerAppEntries(registry, mode.entries);
273
+
274
+ const unregistered = new Map();
275
+ for (const integrationClass of appDefinition?.integrations || []) {
276
+ const source = integrationSource(integrationClass);
277
+ for (const definition of boundDefinitions(integrationClass)) {
278
+ const missing = mergeBoundDefinition(
279
+ registry,
280
+ definition,
281
+ source,
282
+ mode
283
+ );
284
+ if (missing !== undefined) {
285
+ if (!unregistered.has(missing))
286
+ unregistered.set(missing, new Set());
287
+ unregistered.get(missing).add(describeSource(source));
288
+ }
289
+ }
290
+ }
291
+ assertAllRegistered(unregistered);
292
+
293
+ freezeEntries(registry);
294
+ return registry;
295
+ }
296
+
297
+ /** The definitions of a registry, in registry order. */
298
+ const getEntityTypeDefinitions = (registry) =>
299
+ [...registry.values()].map((entry) => entry.definition);
300
+
301
+ /** Names of the integrations that bind an entity type; empty for registry-only. */
302
+ const usedByOf = (entry) =>
303
+ entry.sources.filter((s) => s.kind === 'integration').map((s) => s.name);
304
+
305
+ module.exports = {
306
+ getEntityTypeRegistry,
307
+ getEntityTypeDefinitions,
308
+ usedByOf,
309
+ entityTypeNameOf,
310
+ EntityRegistryError,
311
+ };
package/modules/index.js CHANGED
@@ -4,6 +4,15 @@ const { OAuth2Requester } = require('./requester/oauth-2');
4
4
  const { Requester } = require('./requester/requester');
5
5
  const { ModuleConstants } = require('./ModuleConstants');
6
6
  const { ModuleFactory } = require('./module-factory');
7
+ const {
8
+ getEntityTypeRegistry,
9
+ EntityRegistryError,
10
+ } = require('./entity-type-registry');
11
+ const {
12
+ resolveScope,
13
+ listBindingScopes,
14
+ OwnershipConfigError,
15
+ } = require('./entity-scope');
7
16
 
8
17
  module.exports = {
9
18
  ApiKeyRequester,
@@ -12,4 +21,9 @@ module.exports = {
12
21
  Requester,
13
22
  ModuleConstants,
14
23
  ModuleFactory,
24
+ getEntityTypeRegistry,
25
+ EntityRegistryError,
26
+ resolveScope,
27
+ listBindingScopes,
28
+ OwnershipConfigError,
15
29
  };
@@ -45,6 +45,18 @@ class ModuleFactory {
45
45
  );
46
46
  }
47
47
 
48
+ // definition.env may be an async function (e.g. the db-credentials
49
+ // extension's bootstrap loads OAuth app credentials from the
50
+ // database); resolve it first. A static env keeps the plain
51
+ // constructor, exactly as before.
52
+ if (typeof moduleDefinition.env === 'function') {
53
+ return await Module.create({
54
+ userId,
55
+ entity,
56
+ definition: moduleDefinition,
57
+ });
58
+ }
59
+
48
60
  return new Module({
49
61
  userId,
50
62
  entity,