@friggframework/core 2.0.0--canary.704.79d9a63.0 → 2.0.0--canary.686.e94de4d.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 (207) hide show
  1. package/CLAUDE.md +28 -40
  2. package/README.md +105 -0
  3. package/application/commands/entity-commands.js +60 -18
  4. package/application/commands/integration-commands.js +20 -73
  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-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -0
  19. package/database/encryption/README.md +76 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +93 -12
  23. package/database/encryption/integration-mapping-encryption.js +1 -1
  24. package/database/organization-where.js +18 -0
  25. package/database/prisma.js +15 -38
  26. package/database/unique-violation.js +25 -0
  27. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  28. package/errors/index.js +0 -4
  29. package/extensions/bootstrap-runner.js +43 -0
  30. package/extensions/extension-loader.js +187 -0
  31. package/extensions/index.js +41 -0
  32. package/extensions/initialize-app.js +123 -0
  33. package/extensions/route-mounter.js +103 -0
  34. package/extensions/schema-composer.js +188 -0
  35. package/generated/prisma-mongodb/edge.js +43 -4
  36. package/generated/prisma-mongodb/index-browser.js +40 -1
  37. package/generated/prisma-mongodb/index.d.ts +4761 -133
  38. package/generated/prisma-mongodb/index.js +43 -4
  39. package/generated/prisma-mongodb/package.json +1 -1
  40. package/generated/prisma-mongodb/schema.prisma +92 -0
  41. package/generated/prisma-mongodb/wasm.js +43 -4
  42. package/generated/prisma-postgresql/edge.js +43 -4
  43. package/generated/prisma-postgresql/index-browser.js +40 -1
  44. package/generated/prisma-postgresql/index.d.ts +4961 -28
  45. package/generated/prisma-postgresql/index.js +43 -4
  46. package/generated/prisma-postgresql/package.json +1 -1
  47. package/generated/prisma-postgresql/schema.prisma +87 -0
  48. package/generated/prisma-postgresql/wasm.js +43 -4
  49. package/handlers/WEBHOOKS.md +0 -29
  50. package/handlers/app-definition-loader.js +121 -19
  51. package/handlers/app-handler-helpers.js +25 -3
  52. package/handlers/backend-utils.js +31 -25
  53. package/handlers/middleware/admin-auth.js +14 -1
  54. package/handlers/routers/auth.js +46 -10
  55. package/handlers/routers/health.js +13 -4
  56. package/handlers/routers/integration-defined-routers.js +144 -92
  57. package/handlers/routers/integration-webhook-routers.js +102 -64
  58. package/handlers/workers/integration-defined-workers.js +5 -2
  59. package/index.js +65 -3
  60. package/infrastructure/scheduler/index.js +19 -6
  61. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  62. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  63. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  64. package/integrations/index.js +1 -0
  65. package/integrations/integration-base.js +0 -29
  66. package/integrations/integration-router.js +131 -96
  67. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  68. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  69. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  70. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  71. package/integrations/repositories/integration-mapping-repository.js +0 -29
  72. package/integrations/repositories/integration-not-found.js +14 -0
  73. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  74. package/integrations/repositories/integration-repository-interface.js +17 -31
  75. package/integrations/repositories/integration-repository-mongo.js +60 -129
  76. package/integrations/repositories/integration-repository-postgres.js +62 -134
  77. package/integrations/repositories/process-repository-mongo.js +1 -1
  78. package/integrations/schedule-config.js +80 -0
  79. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  80. package/integrations/use-cases/create-integration.js +72 -75
  81. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  82. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  83. package/integrations/use-cases/index.js +7 -1
  84. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  85. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  86. package/integrations/utils/map-integration-dto.js +43 -14
  87. package/management-api/create-management-api-router.js +49 -0
  88. package/management-api/error-response.js +116 -0
  89. package/management-api/management-api-config.js +151 -0
  90. package/management-api/meta-router.js +76 -0
  91. package/management-api/openapi/build-openapi.js +144 -0
  92. package/management-api/openapi/components.js +408 -0
  93. package/management-api/openapi/v1-operations.js +83 -0
  94. package/management-api/openapi/v2-operations.js +264 -0
  95. package/management-api/route-registry.js +411 -0
  96. package/management-api/test-support/http-client.js +39 -0
  97. package/management-api/use-cases/get-management-api-meta.js +57 -0
  98. package/management-api/v1-deprecation.js +120 -0
  99. package/management-api/v2/create-v2-router.js +85 -0
  100. package/management-api/v2/dependencies.js +281 -0
  101. package/management-api/v2/dto.js +143 -0
  102. package/management-api/v2/handlers/authorization.js +94 -0
  103. package/management-api/v2/handlers/credentials.js +33 -0
  104. package/management-api/v2/handlers/entities.js +77 -0
  105. package/management-api/v2/handlers/integrations.js +128 -0
  106. package/management-api/v2/handlers/proxy.js +34 -0
  107. package/management-api/v2/request.js +85 -0
  108. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  109. package/modules/domain/authorization-session.js +144 -0
  110. package/modules/entity-scope.js +15 -0
  111. package/modules/entity-type-registry.js +311 -0
  112. package/modules/index.js +14 -0
  113. package/modules/module-factory.js +12 -0
  114. package/modules/module.js +53 -2
  115. package/modules/proxy/proxy-errors.js +20 -0
  116. package/modules/proxy/proxy-path.js +179 -0
  117. package/modules/proxy/proxy-policy.js +248 -0
  118. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  119. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  120. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  121. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  122. package/modules/repositories/entity-not-found.js +18 -0
  123. package/modules/repositories/module-repository-documentdb.js +51 -6
  124. package/modules/repositories/module-repository-interface.js +19 -2
  125. package/modules/repositories/module-repository-mongo.js +67 -74
  126. package/modules/repositories/module-repository-postgres.js +66 -74
  127. package/modules/repositories/module-repository.js +2 -1
  128. package/modules/requester/proxy-request.js +244 -0
  129. package/modules/requester/requester.js +27 -2
  130. package/modules/scope-config.js +192 -0
  131. package/modules/scope-policy.js +112 -0
  132. package/modules/scope-resolution.js +288 -0
  133. package/modules/scope-visibility.js +247 -0
  134. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  135. package/modules/tests/doubles/ownership-matching.js +18 -0
  136. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  137. package/modules/use-cases/create-entity-from-credential.js +113 -0
  138. package/modules/use-cases/entity-operations-for-user.js +162 -0
  139. package/modules/use-cases/entity-types.js +139 -0
  140. package/modules/use-cases/execute-entity-proxy-request.js +286 -0
  141. package/modules/use-cases/get-authorization-requirements.js +100 -0
  142. package/modules/use-cases/get-entities-for-user.js +26 -13
  143. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  144. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  145. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  146. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  147. package/modules/use-cases/get-module.js +10 -4
  148. package/modules/use-cases/manage-access.js +21 -0
  149. package/modules/use-cases/multi-step-authorization.js +329 -0
  150. package/modules/use-cases/process-authorization-callback.js +53 -19
  151. package/modules/use-cases/refresh-entity-options.js +10 -4
  152. package/modules/use-cases/test-module-auth.js +10 -4
  153. package/modules/utils/map-module-dto.js +3 -5
  154. package/package.json +6 -5
  155. package/prisma-mongodb/schema.prisma +92 -0
  156. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  157. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  158. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  159. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  160. package/prisma-postgresql/schema.prisma +87 -0
  161. package/providers/index.js +23 -0
  162. package/providers/resolve-provider.js +93 -0
  163. package/providers/runtime-detection.js +124 -0
  164. package/queues/index.js +25 -0
  165. package/queues/providers/index.js +11 -0
  166. package/queues/providers/netlify-background-provider.js +150 -0
  167. package/queues/providers/qstash-queue-provider.js +219 -0
  168. package/queues/providers/sqs-queue-provider.js +70 -0
  169. package/queues/queue-client-interface.js +55 -0
  170. package/queues/queue-message-auth.js +193 -0
  171. package/queues/queue-provider-factory.js +101 -0
  172. package/queues/queue-provider.js +65 -0
  173. package/queues/sqs-queue-client.js +81 -0
  174. package/types/index.d.ts +2 -0
  175. package/types/modules/index.d.ts +197 -0
  176. package/types/user/index.d.ts +134 -0
  177. package/user/adopter-jwt-config.js +177 -0
  178. package/user/adopter-jwt-errors.js +59 -0
  179. package/user/adopter-token-verifier.js +122 -0
  180. package/user/app-user-helpers.js +97 -0
  181. package/user/create-user-authentication.js +64 -0
  182. package/user/membership.js +51 -0
  183. package/user/repositories/organization-membership-record.js +19 -0
  184. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  185. package/user/repositories/organization-membership-repository-factory.js +42 -0
  186. package/user/repositories/organization-membership-repository-interface.js +57 -0
  187. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  188. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  189. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  190. package/user/repositories/user-repository-documentdb.js +27 -2
  191. package/user/repositories/user-repository-interface.js +18 -0
  192. package/user/repositories/user-repository-mongo.js +24 -0
  193. package/user/repositories/user-repository-postgres.js +18 -0
  194. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  195. package/user/tests/doubles/test-user-repository.js +1 -1
  196. package/user/use-cases/authenticate-user.js +7 -1
  197. package/user/use-cases/ensure-organization-membership.js +92 -0
  198. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  199. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  200. package/user/use-cases/get-user-from-bearer-token.js +2 -7
  201. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  202. package/user/user.js +64 -1
  203. package/errors/mapping-already-exists-error.js +0 -14
  204. package/integrations/use-cases/add-entities-to-integration.js +0 -92
  205. package/integrations/use-cases/remove-entities-from-integration.js +0 -70
  206. package/integrations/utils/assert-entity-ids-input.js +0 -42
  207. package/modules/utils/to-credential-id.js +0 -14
@@ -0,0 +1,264 @@
1
+ const { ref } = require('./components');
2
+
3
+ /**
4
+ * Per-operation documentation for Management API v2, keyed by the route
5
+ * registry's operationId. Paths and methods come from the registry, never
6
+ * from here, so the document cannot list a route the router does not serve.
7
+ */
8
+
9
+ const json = (schema) => ({ content: { 'application/json': { schema } } });
10
+ const ok = (description, schema) => ({ description, ...(schema && json(schema)) });
11
+ const body = (schema, required = true) => ({ required, ...json(schema) });
12
+ const errors = (...names) =>
13
+ Object.fromEntries(
14
+ names.map((name) => {
15
+ const status = {
16
+ BadRequest: '400',
17
+ Unauthorized: '401',
18
+ Forbidden: '403',
19
+ NotFound: '404',
20
+ Conflict: '409',
21
+ BadGateway: '502',
22
+ }[name];
23
+ return [status, { $ref: `#/components/responses/${name}` }];
24
+ })
25
+ );
26
+ const query = (name, description, schema = { type: 'string' }, required = false) => ({
27
+ name,
28
+ in: 'query',
29
+ required,
30
+ description,
31
+ schema,
32
+ });
33
+ const stepQuery = [
34
+ query('step', 'Step number, 1-based (default 1)', { type: 'integer', minimum: 1 }),
35
+ query('sessionId', 'Session from step 1; required for later steps'),
36
+ ];
37
+ // ADR-056 §3.4: the same two filters on every list that mixes ownership.
38
+ const listFilters = [
39
+ query('scope', 'Only records of this scope', { type: 'string', enum: ['individual', 'organization'] }),
40
+ query('owner', 'Only the caller\'s own (`me`) or the active organization\'s (`org`)', { type: 'string', enum: ['me', 'org'] }),
41
+ ];
42
+ const anyJson = { description: 'Defined by the integration or module' };
43
+
44
+ const V2_OPERATIONS = {
45
+ // Integrations
46
+ listIntegrations: {
47
+ summary: "List the caller's integrations",
48
+ description:
49
+ 'Returns only `{ integrations }`: the caller\'s and the active organization\'s, created in that organization (ADR-056 §3.6). Each carries `scope`, `owner`, `organizationId` and `access`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
50
+ parameters: listFilters,
51
+ responses: { 200: ok('Integrations', ref('ListIntegrationsResponse')), ...errors('Unauthorized') },
52
+ },
53
+ listIntegrationOptions: {
54
+ summary: 'List the integration types this app offers',
55
+ responses: { 200: ok('Integration types', ref('ListIntegrationOptionsResponse')), ...errors('Unauthorized') },
56
+ },
57
+ createIntegration: {
58
+ summary: 'Create an integration between entities the caller owns',
59
+ description: 'An identical existing integration (same user, type and entities) is reused.',
60
+ requestBody: body(ref('CreateIntegrationRequest')),
61
+ responses: {
62
+ 201: ok('Created (or reused) integration', ref('Integration')),
63
+ ...errors('BadRequest', 'Unauthorized', 'NotFound'),
64
+ },
65
+ },
66
+ getIntegration: {
67
+ summary: 'Get an integration',
68
+ responses: { 200: ok('Integration', ref('Integration')), ...errors('Unauthorized', 'NotFound') },
69
+ },
70
+ updateIntegration: {
71
+ summary: "Replace an integration's config",
72
+ requestBody: body(ref('UpdateIntegrationRequest')),
73
+ responses: { 200: ok('Updated integration', ref('Integration')), ...errors('BadRequest', 'Unauthorized', 'NotFound') },
74
+ },
75
+ deleteIntegration: {
76
+ summary: 'Delete an integration',
77
+ responses: { 204: { description: 'Deleted' }, ...errors('Unauthorized', 'NotFound') },
78
+ },
79
+ getIntegrationConfigOptions: {
80
+ summary: 'Get config options (form schema) for an integration',
81
+ responses: { 200: ok('Options', anyJson), ...errors('Unauthorized', 'NotFound') },
82
+ },
83
+ refreshIntegrationConfigOptions: {
84
+ summary: 'Refresh config options with the current form values',
85
+ requestBody: body({ type: 'object', additionalProperties: true }, false),
86
+ responses: { 200: ok('Options', anyJson), ...errors('BadRequest', 'Unauthorized', 'NotFound') },
87
+ },
88
+ listIntegrationActions: {
89
+ summary: 'List user actions an integration offers',
90
+ parameters: [query('actionType', 'Only actions of this userActionType')],
91
+ responses: { 200: ok('User actions', anyJson), ...errors('Unauthorized', 'NotFound') },
92
+ },
93
+ getIntegrationActionOptions: {
94
+ summary: 'Get the options form for a user action',
95
+ requestBody: body({ type: 'object', additionalProperties: true }, false),
96
+ responses: { 200: ok('Options', anyJson), ...errors('Unauthorized', 'NotFound') },
97
+ },
98
+ refreshIntegrationActionOptions: {
99
+ summary: 'Refresh a user action options form',
100
+ requestBody: body({ type: 'object', additionalProperties: true }, false),
101
+ responses: { 200: ok('Options', anyJson), ...errors('Unauthorized', 'NotFound') },
102
+ },
103
+ runIntegrationAction: {
104
+ summary: 'Run a user action',
105
+ description: 'Only actions the integration lists as user actions can run; anything else is 404 ACTION_NOT_FOUND.',
106
+ requestBody: body({ type: 'object', additionalProperties: true }, false),
107
+ responses: { 200: ok('Action result', anyJson), ...errors('BadRequest', 'Unauthorized', 'NotFound') },
108
+ },
109
+ testIntegrationAuth: {
110
+ summary: "Test an integration's connections",
111
+ responses: { 200: ok('Result', ref('AuthTestResult')), ...errors('Unauthorized', 'NotFound') },
112
+ },
113
+
114
+ // Entities
115
+ listEntities: {
116
+ summary: "List the caller's connected entities",
117
+ description:
118
+ "The active organization's entities and the caller's individual ones (ADR-056 §3.6): an individual entity belongs to the organization it was connected in, unless `ownership.individualAcross` is `all`. Each carries `scope`, `owner`, `organizationId` and `access`.",
119
+ parameters: listFilters,
120
+ responses: { 200: ok('Entities', ref('ListEntitiesResponse')), ...errors('Unauthorized') },
121
+ },
122
+ listEntityTypes: {
123
+ summary: 'List the entity types (API modules) this app can connect',
124
+ description:
125
+ 'Every type registered in appDefinition.entities plus every module a declared integration binds. `usedBy` lists the integrations that bind each type; it is empty for a type registered without any integration.',
126
+ responses: { 200: ok('Entity types', ref('ListEntityTypesResponse')), ...errors('Unauthorized') },
127
+ },
128
+ getEntityType: {
129
+ summary: 'Describe an entity type',
130
+ description:
131
+ 'Includes `usedBy`, the integrations that bind the type (empty for a registry-only type).',
132
+ responses: { 200: ok('Entity type', ref('EntityType')), ...errors('Unauthorized', 'NotFound') },
133
+ },
134
+ getEntityTypeRequirements: {
135
+ summary: 'Describe one authorization step of an entity type',
136
+ description: 'Read-only: does not start a session. Use GET /api/v2/authorize to start a flow.',
137
+ parameters: [stepQuery[0]],
138
+ responses: {
139
+ 200: ok('Requirements', ref('AuthorizationRequirements')),
140
+ ...errors('BadRequest', 'Unauthorized', 'NotFound'),
141
+ },
142
+ },
143
+ getEntity: {
144
+ summary: 'Get an entity',
145
+ responses: { 200: ok('Entity', ref('Entity')), ...errors('Unauthorized', 'NotFound') },
146
+ },
147
+ deleteEntity: {
148
+ summary: 'Delete an entity',
149
+ description: 'Refused with 409 ENTITY_IN_USE while integrations use it. The credential stays.',
150
+ responses: { 204: { description: 'Deleted' }, ...errors('Unauthorized', 'NotFound', 'Conflict') },
151
+ },
152
+ testEntityAuth: {
153
+ summary: "Test an entity's stored credential",
154
+ responses: { 200: ok('Result', ref('AuthTestResult')), ...errors('Unauthorized', 'NotFound') },
155
+ },
156
+ getEntityOptions: {
157
+ summary: "Get an entity's options",
158
+ responses: { 200: ok('Options', anyJson), ...errors('Unauthorized', 'NotFound') },
159
+ },
160
+ refreshEntityOptions: {
161
+ summary: "Refresh an entity's options",
162
+ requestBody: body({ type: 'object', additionalProperties: true }, false),
163
+ responses: { 200: ok('Options', anyJson), ...errors('Unauthorized', 'NotFound') },
164
+ },
165
+ proxyEntityRequest: {
166
+ summary: "Call the entity's API through Frigg (beta)",
167
+ description:
168
+ "ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
169
+ requestBody: body(ref('ProxyRequest')),
170
+ responses: {
171
+ 200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
172
+ 400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
173
+ 401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
174
+ 403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
175
+ 404: ok('Entity not found, or not visible in the active organization', ref('Error')),
176
+ 413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
177
+ 415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
178
+ 429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
179
+ 502: ok('UPSTREAM_ERROR, NETWORK_ERROR or RESPONSE_TOO_LARGE', ref('ProxyError')),
180
+ 504: ok('TIMEOUT', ref('ProxyError')),
181
+ },
182
+ },
183
+
184
+ // Credentials
185
+ listCredentials: {
186
+ summary: "List the caller's credentials (masked)",
187
+ description:
188
+ 'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
189
+ parameters: listFilters,
190
+ responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
191
+ },
192
+ getCredential: {
193
+ summary: 'Get a credential (masked)',
194
+ responses: { 200: ok('Credential', ref('Credential')), ...errors('Unauthorized', 'NotFound') },
195
+ },
196
+ deleteCredential: {
197
+ summary: 'Delete a credential',
198
+ description: 'Entities that used it stay, with no credential, until re-authorized.',
199
+ responses: { 204: { description: 'Deleted' }, ...errors('Unauthorized', 'NotFound') },
200
+ },
201
+ getCredentialReauthorizeRequirements: {
202
+ summary: 'Start or continue re-authorizing a credential',
203
+ parameters: stepQuery,
204
+ responses: {
205
+ 200: ok('Requirements', ref('AuthorizationRequirements')),
206
+ ...errors('BadRequest', 'Unauthorized', 'NotFound', 'Conflict'),
207
+ },
208
+ },
209
+ reauthorizeCredential: {
210
+ summary: 'Submit a re-authorization step',
211
+ requestBody: body(ref('ReauthorizeRequest')),
212
+ responses: {
213
+ 200: ok('Complete or pending', ref('AuthorizeResponse')),
214
+ ...errors('BadRequest', 'Unauthorized', 'NotFound', 'Conflict', 'BadGateway'),
215
+ },
216
+ },
217
+
218
+ // Authorization
219
+ getAuthorizationRequirements: {
220
+ summary: 'Start or continue an authorization',
221
+ description:
222
+ 'Single-step modules return their requirements. Multi-step modules start a session on step 1 (returned as sessionId); later steps need it and must be the current step.',
223
+ parameters: [
224
+ query('entityType', 'Module name', { type: 'string' }, true),
225
+ ...stepQuery,
226
+ query('state', 'OAuth state forwarded to the provider'),
227
+ ],
228
+ responses: {
229
+ 200: ok('Requirements', ref('AuthorizationRequirements')),
230
+ ...errors('BadRequest', 'Unauthorized', 'NotFound', 'Conflict'),
231
+ },
232
+ },
233
+ authorize: {
234
+ summary: 'Submit an authorization step',
235
+ description:
236
+ 'Answers { status: "complete", entity, credential } or { status: "pending", step, totalSteps, sessionId, requirements }.',
237
+ requestBody: body(ref('AuthorizeRequest')),
238
+ responses: {
239
+ 200: ok('Complete or pending', ref('AuthorizeResponse')),
240
+ ...errors('BadRequest', 'Unauthorized', 'NotFound', 'Conflict', 'BadGateway'),
241
+ },
242
+ },
243
+ };
244
+
245
+ const META_OPERATIONS = {
246
+ getApiMeta: {
247
+ summary: 'Supported API versions and capabilities',
248
+ description: 'No authentication, no database. The core version is added for admin callers only.',
249
+ security: [],
250
+ responses: { 200: ok('Discovery document', ref('ApiMeta')) },
251
+ },
252
+ getOpenApiV1: {
253
+ summary: 'OpenAPI document for Management API v1 (deprecated)',
254
+ security: [],
255
+ responses: { 200: ok('OpenAPI 3 document', { type: 'object' }) },
256
+ },
257
+ getOpenApiV2: {
258
+ summary: 'OpenAPI document for Management API v2',
259
+ security: [],
260
+ responses: { 200: ok('OpenAPI 3 document', { type: 'object' }) },
261
+ },
262
+ };
263
+
264
+ module.exports = { V2_OPERATIONS, META_OPERATIONS };
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Management API route registry (ADR-053).
3
+ *
4
+ * The single source of truth for the Management API surface. Three consumers
5
+ * read it, so they cannot drift apart:
6
+ *
7
+ * - the Express routers (v2 mounts its handlers by `operationId`; v1 gets its
8
+ * deprecation headers from the v1 list),
9
+ * - the OpenAPI documents served at /api/meta/openapi/v{n}.json,
10
+ * - the API Gateway HTTP API routes that devtools generates
11
+ * (`getGatewayRoutes`, used by base-definition-factory).
12
+ *
13
+ * Rules (ADR-053):
14
+ * - v2 lives under `/api/v2/<resource>`. Gateway routes are declared per
15
+ * resource, never `ANY /api/v2/{proxy+}`: `/api/v2/reports` (ADR-010) is
16
+ * served by the admin-scripts Lambda and must not be shadowed.
17
+ * - v1 is the frozen unprefixed surface. Its handlers live in
18
+ * integrations/integration-router.js and do not change; this list mirrors
19
+ * them (a contract test keeps the two equal).
20
+ * - `/api/meta` is version-neutral and served by the DB-free `health` Lambda.
21
+ *
22
+ * This module must stay free of database, AWS and app-definition imports:
23
+ * devtools loads it at build time.
24
+ */
25
+
26
+ /**
27
+ * Supported Management API majors. `deprecatedAt` feeds the RFC 9745
28
+ * `Deprecation` header; `sunset` (RFC 8594) stays null until a removal release
29
+ * is scheduled.
30
+ */
31
+ const MANAGEMENT_API_VERSIONS = Object.freeze({
32
+ 1: Object.freeze({
33
+ status: 'deprecated',
34
+ deprecatedAt: '2026-11-01',
35
+ sunset: null,
36
+ }),
37
+ 2: Object.freeze({ status: 'stable' }),
38
+ });
39
+
40
+ const PREFERRED_API_VERSION = '2';
41
+
42
+ /**
43
+ * `/api/v2/<segment>` values no v2 route may take. `reports` belongs to the
44
+ * admin-scripts Lambda (ADR-010); where it lives long term is open (ADR-053
45
+ * §7), so it is reserved, kept out of the v2 OpenAPI document and out of the
46
+ * /api/meta capabilities.
47
+ */
48
+ const RESERVED_V2_SEGMENTS = Object.freeze(['reports']);
49
+
50
+ const V1_MIGRATION_DOCS_URL =
51
+ 'https://docs.friggframework.org/api/migrate-v1-v2';
52
+
53
+ /**
54
+ * Functions (devtools names) that serve registered routes. `queueWorker` is
55
+ * the HTTP queue worker of hosts that deliver queue messages over HTTP
56
+ * (provider-netlify's worker-background); AWS delivers through SQS.
57
+ */
58
+ const FUNCTIONS = Object.freeze({
59
+ AUTH: 'auth',
60
+ HEALTH: 'health',
61
+ QUEUE_WORKER: 'queueWorker',
62
+ });
63
+
64
+ const route = (version, fn) => (definition) =>
65
+ Object.freeze({ version, function: fn, ...definition });
66
+
67
+ /**
68
+ * The v1 routes registered by createIntegrationRouter(). `ANY` marks routes
69
+ * registered with `router.all()`. Frozen: no behaviour change except the
70
+ * deprecation headers.
71
+ */
72
+ const V1_ROUTES = Object.freeze(
73
+ [
74
+ { method: 'GET', path: '/api/integrations' },
75
+ { method: 'POST', path: '/api/integrations' },
76
+ { method: 'PATCH', path: '/api/integrations/:integrationId' },
77
+ { method: 'DELETE', path: '/api/integrations/:integrationId' },
78
+ {
79
+ method: 'GET',
80
+ path: '/api/integrations/:integrationId/config/options',
81
+ },
82
+ {
83
+ method: 'POST',
84
+ path: '/api/integrations/:integrationId/config/options/refresh',
85
+ },
86
+ { method: 'ANY', path: '/api/integrations/:integrationId/actions' },
87
+ {
88
+ method: 'ANY',
89
+ path: '/api/integrations/:integrationId/actions/:actionId/options',
90
+ },
91
+ {
92
+ method: 'POST',
93
+ path: '/api/integrations/:integrationId/actions/:actionId/options/refresh',
94
+ },
95
+ {
96
+ method: 'POST',
97
+ path: '/api/integrations/:integrationId/actions/:actionId',
98
+ },
99
+ { method: 'GET', path: '/api/integrations/:integrationId' },
100
+ { method: 'GET', path: '/api/integrations/:integrationId/test-auth' },
101
+ { method: 'GET', path: '/api/authorize' },
102
+ { method: 'POST', path: '/api/authorize' },
103
+ { method: 'POST', path: '/api/entity' },
104
+ { method: 'GET', path: '/api/entity/options/:credentialId' },
105
+ { method: 'GET', path: '/api/entities/:entityId/test-auth' },
106
+ { method: 'GET', path: '/api/entities/:entityId' },
107
+ { method: 'POST', path: '/api/entities/:entityId/options' },
108
+ { method: 'POST', path: '/api/entities/:entityId/options/refresh' },
109
+ ].map(route('1', FUNCTIONS.AUTH))
110
+ );
111
+
112
+ /**
113
+ * The v2 routes. Order matters: Express matches in this order, so literal
114
+ * segments (`/options`, `/types`) come before `/:id` siblings.
115
+ *
116
+ * - `operationId` keys the handler in management-api/v2 and the OpenAPI
117
+ * operation.
118
+ * - `stability: 'beta'` marks routes that may still change in a core minor
119
+ * (ADR-053 §2); it is published as `x-frigg-stability`.
120
+ * - `capability` names the /api/meta capability a route belongs to.
121
+ */
122
+ const V2_ROUTES = Object.freeze(
123
+ [
124
+ // Integrations
125
+ { operationId: 'listIntegrations', method: 'GET', path: '/api/v2/integrations', tag: 'Integrations' },
126
+ { operationId: 'listIntegrationOptions', method: 'GET', path: '/api/v2/integrations/options', tag: 'Integrations' },
127
+ { operationId: 'createIntegration', method: 'POST', path: '/api/v2/integrations', tag: 'Integrations' },
128
+ { operationId: 'getIntegration', method: 'GET', path: '/api/v2/integrations/:integrationId', tag: 'Integrations' },
129
+ { operationId: 'updateIntegration', method: 'PATCH', path: '/api/v2/integrations/:integrationId', tag: 'Integrations' },
130
+ { operationId: 'deleteIntegration', method: 'DELETE', path: '/api/v2/integrations/:integrationId', tag: 'Integrations' },
131
+ { operationId: 'getIntegrationConfigOptions', method: 'GET', path: '/api/v2/integrations/:integrationId/config/options', tag: 'Integrations' },
132
+ { operationId: 'refreshIntegrationConfigOptions', method: 'POST', path: '/api/v2/integrations/:integrationId/config/options/refresh', tag: 'Integrations' },
133
+ { operationId: 'listIntegrationActions', method: 'GET', path: '/api/v2/integrations/:integrationId/actions', tag: 'Integrations' },
134
+ { operationId: 'getIntegrationActionOptions', method: 'POST', path: '/api/v2/integrations/:integrationId/actions/:actionId/options', tag: 'Integrations' },
135
+ { operationId: 'refreshIntegrationActionOptions', method: 'POST', path: '/api/v2/integrations/:integrationId/actions/:actionId/options/refresh', tag: 'Integrations' },
136
+ { operationId: 'runIntegrationAction', method: 'POST', path: '/api/v2/integrations/:integrationId/actions/:actionId', tag: 'Integrations' },
137
+ { operationId: 'testIntegrationAuth', method: 'GET', path: '/api/v2/integrations/:integrationId/test-auth', tag: 'Integrations' },
138
+
139
+ // Entities
140
+ { operationId: 'listEntities', method: 'GET', path: '/api/v2/entities', tag: 'Entities' },
141
+ { operationId: 'listEntityTypes', method: 'GET', path: '/api/v2/entities/types', tag: 'Entities' },
142
+ { operationId: 'getEntityType', method: 'GET', path: '/api/v2/entities/types/:entityType', tag: 'Entities' },
143
+ { operationId: 'getEntityTypeRequirements', method: 'GET', path: '/api/v2/entities/types/:entityType/requirements', tag: 'Entities' },
144
+ { operationId: 'getEntity', method: 'GET', path: '/api/v2/entities/:entityId', tag: 'Entities' },
145
+ { operationId: 'deleteEntity', method: 'DELETE', path: '/api/v2/entities/:entityId', tag: 'Entities' },
146
+ { operationId: 'testEntityAuth', method: 'GET', path: '/api/v2/entities/:entityId/test-auth', tag: 'Entities' },
147
+ { operationId: 'getEntityOptions', method: 'GET', path: '/api/v2/entities/:entityId/options', tag: 'Entities' },
148
+ { operationId: 'refreshEntityOptions', method: 'POST', path: '/api/v2/entities/:entityId/options/refresh', tag: 'Entities' },
149
+ { operationId: 'proxyEntityRequest', method: 'POST', path: '/api/v2/entities/:entityId/proxy', tag: 'Entities', stability: 'beta', capability: 'entityProxy' },
150
+
151
+ // Credentials
152
+ { operationId: 'listCredentials', method: 'GET', path: '/api/v2/credentials', tag: 'Credentials', capability: 'credentials' },
153
+ { operationId: 'getCredential', method: 'GET', path: '/api/v2/credentials/:credentialId', tag: 'Credentials', capability: 'credentials' },
154
+ { operationId: 'deleteCredential', method: 'DELETE', path: '/api/v2/credentials/:credentialId', tag: 'Credentials', capability: 'credentials' },
155
+ { operationId: 'getCredentialReauthorizeRequirements', method: 'GET', path: '/api/v2/credentials/:credentialId/reauthorize', tag: 'Credentials', capability: 'credentials' },
156
+ { operationId: 'reauthorizeCredential', method: 'POST', path: '/api/v2/credentials/:credentialId/reauthorize', tag: 'Credentials', capability: 'credentials' },
157
+
158
+ // Authorization (multi-step)
159
+ { operationId: 'getAuthorizationRequirements', method: 'GET', path: '/api/v2/authorize', tag: 'Authorization', capability: 'multiStepAuthorize' },
160
+ { operationId: 'authorize', method: 'POST', path: '/api/v2/authorize', tag: 'Authorization', capability: 'multiStepAuthorize' },
161
+ ].map(route('2', FUNCTIONS.AUTH))
162
+ );
163
+
164
+ /** Version-neutral discovery routes, served without auth or database. */
165
+ const META_ROUTES = Object.freeze(
166
+ [
167
+ { operationId: 'getApiMeta', method: 'GET', path: '/api/meta' },
168
+ ...Object.keys(MANAGEMENT_API_VERSIONS).map((major) => ({
169
+ operationId: `getOpenApiV${major}`,
170
+ method: 'GET',
171
+ path: `/api/meta/openapi/v${major}.json`,
172
+ })),
173
+ ].map(route(null, FUNCTIONS.HEALTH))
174
+ );
175
+
176
+ /**
177
+ * Unversioned routes the auth Lambda serves next to the Management API
178
+ * (core/handlers/routers/auth.js). The OAuth redirect is the provider's
179
+ * callback target, so it is neither deprecated nor disabled with v1.
180
+ */
181
+ const AUTH_EXTRA_ROUTES = Object.freeze(
182
+ [
183
+ {
184
+ operationId: 'oauthRedirect',
185
+ method: 'GET',
186
+ path: '/api/integrations/redirect/:appId',
187
+ },
188
+ ].map(route(null, FUNCTIONS.AUTH))
189
+ );
190
+
191
+ /**
192
+ * Unversioned routes the DB-free health Lambda serves next to /api/meta.
193
+ * GET /api/docs redirects to the current OpenAPI document; the docs
194
+ * themselves live under /api/meta (ADR-053).
195
+ */
196
+ const DOCS_ROUTES = Object.freeze(
197
+ [
198
+ {
199
+ operationId: 'redirectToOpenApi',
200
+ method: 'GET',
201
+ path: '/api/docs',
202
+ },
203
+ ].map(route(null, FUNCTIONS.HEALTH))
204
+ );
205
+
206
+ /**
207
+ * Admin routes live under /admin (as core's /admin/db-migrate and the
208
+ * admin-scripts /admin/scripts do), never under the public /api prefix. A
209
+ * core extension (appDefinition.coreExtensions[].routes.path) mounts its admin
210
+ * API at /admin/<name>, served by the auth function behind the admin API key;
211
+ * these segments belong to other functions and cannot be taken.
212
+ */
213
+ const ADMIN_ROUTE_PREFIX = '/admin/';
214
+ const RESERVED_ADMIN_SEGMENTS = Object.freeze(['scripts', 'db-migrate', 'queue']);
215
+
216
+ /**
217
+ * The queue delivery endpoint of hosts that deliver queue messages over HTTP
218
+ * (QStash posts here on Netlify). Every message must be signed: the worker
219
+ * verifies it before parsing anything. Not part of the public /api surface.
220
+ */
221
+ const HTTP_QUEUE_ROUTES = Object.freeze(
222
+ [
223
+ {
224
+ operationId: 'deliverQueueMessage',
225
+ method: 'POST',
226
+ path: '/admin/queue',
227
+ },
228
+ ].map(route(null, FUNCTIONS.QUEUE_WORKER))
229
+ );
230
+
231
+ /**
232
+ * Why a core extension route path is not acceptable, or null when it is:
233
+ * `/admin/<segment>` (letters, digits, `-` and `_`, one or more segments), not
234
+ * a reserved segment.
235
+ *
236
+ * @param {*} path
237
+ * @returns {string|null}
238
+ */
239
+ function coreExtensionRoutePathError(path) {
240
+ if (typeof path !== 'string' || !path.startsWith(ADMIN_ROUTE_PREFIX)) {
241
+ return `must start with ${ADMIN_ROUTE_PREFIX} (admin routes are not served under /api)`;
242
+ }
243
+ if (!/^\/admin(\/[A-Za-z0-9_-]+)+$/.test(path)) {
244
+ return 'must be /admin/<segment>[/<segment>...] using letters, digits, - and _';
245
+ }
246
+ const segment = path.split('/')[2];
247
+ if (RESERVED_ADMIN_SEGMENTS.includes(segment)) {
248
+ return `/admin/${segment} is reserved (${RESERVED_ADMIN_SEGMENTS.map((s) => `/admin/${s}`).join(', ')})`;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Route records for the core extensions' admin routes: each path and
255
+ * everything under it, on the auth function (core's auth router mounts the
256
+ * extension routes behind the admin API key).
257
+ *
258
+ * @param {Array<Object>} [coreExtensions] - appDefinition.coreExtensions (plain data;
259
+ * only `routes.path` is read)
260
+ * @returns {Array<Object>}
261
+ */
262
+ function listCoreExtensionRoutes(coreExtensions = []) {
263
+ if (!Array.isArray(coreExtensions)) return [];
264
+ const out = [];
265
+ for (const ext of coreExtensions) {
266
+ const path = ext?.routes?.path;
267
+ if (typeof path !== 'string' || coreExtensionRoutePathError(path)) continue;
268
+ out.push(
269
+ Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path, extension: ext.name }),
270
+ Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path: `${path}/{proxy+}`, extension: ext.name })
271
+ );
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /**
277
+ * The v2 routes an app serves. Routes tied to an opt-in capability (the entity
278
+ * proxy, ADR-052) are left out unless the app enables it.
279
+ *
280
+ * @param {Object} [options]
281
+ * @param {boolean} [options.proxy=false] - `managementApi.proxy.enable`
282
+ */
283
+ function listV2Routes({ proxy = false } = {}) {
284
+ return V2_ROUTES.filter(
285
+ (record) => proxy || record.capability !== 'entityProxy'
286
+ );
287
+ }
288
+
289
+ /**
290
+ * @param {Object} [options]
291
+ * @param {boolean} [options.v1=true] - false drops the v1 routes
292
+ * (`managementApi.v1: false` in the app definition).
293
+ * @param {boolean} [options.proxy=false] - true keeps the entity proxy route.
294
+ * @returns {Array<Object>} Every registered route record.
295
+ */
296
+ function listRoutes({ v1 = true, proxy = false } = {}) {
297
+ return [
298
+ ...(v1 ? V1_ROUTES : []),
299
+ ...listV2Routes({ proxy }),
300
+ ...META_ROUTES,
301
+ ...AUTH_EXTRA_ROUTES,
302
+ ...DOCS_ROUTES,
303
+ ];
304
+ }
305
+
306
+ /** `/api/v2/entities/:entityId` -> `/api/v2/entities/{entityId}` */
307
+ function toGatewayPath(expressPath) {
308
+ return expressPath.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
309
+ }
310
+
311
+ /**
312
+ * The resource root a route is grouped under for API Gateway:
313
+ * `/api/v2/<resource>` for v2, `/api/<resource>` for v1, `/api/meta` for meta.
314
+ */
315
+ function resourceRoot(record) {
316
+ const segments = record.path.split('/').filter(Boolean);
317
+ const depth = record.version === '2' ? 3 : 2;
318
+ return `/${segments.slice(0, depth).join('/')}`;
319
+ }
320
+
321
+ /**
322
+ * Gateway routes derived from the registry: the Management API, its
323
+ * discovery and docs routes, the core extensions' admin routes and, for
324
+ * hosts that deliver queue messages over HTTP, the queue endpoint. Each
325
+ * Management API resource root gets an exact route when the root itself is
326
+ * registered and a greedy `{proxy+}` route when deeper paths are. The auth
327
+ * function uses `ANY` (as it always has, so CORS preflights reach Express);
328
+ * the health function only serves GET.
329
+ *
330
+ * devtools turns these into API Gateway HTTP API events; provider-netlify
331
+ * into netlify.toml redirects.
332
+ *
333
+ * @param {Object} [options]
334
+ * @param {boolean} [options.v1=true]
335
+ * @param {boolean} [options.proxy=false]
336
+ * @param {Array<Object>} [options.coreExtensions=[]] - appDefinition.coreExtensions
337
+ * @param {boolean} [options.httpQueue=false] - include the HTTP queue route
338
+ * @returns {Array<{function: string, method: string, path: string}>}
339
+ */
340
+ function getGatewayRoutes({ v1 = true, proxy = false, coreExtensions = [], httpQueue = false } = {}) {
341
+ const out = [];
342
+ const seen = new Set();
343
+ const add = (fn, method, path) => {
344
+ const key = `${fn} ${method} ${path}`;
345
+ if (seen.has(key)) return;
346
+ seen.add(key);
347
+ out.push({ function: fn, method, path });
348
+ };
349
+
350
+ const grouped = [
351
+ ...(v1 ? V1_ROUTES : []),
352
+ ...listV2Routes({ proxy }),
353
+ ...META_ROUTES,
354
+ ];
355
+ const greedyRoots = new Set();
356
+ for (const record of grouped) {
357
+ const root = resourceRoot(record);
358
+ const method = record.function === FUNCTIONS.AUTH ? 'ANY' : 'GET';
359
+ if (record.path === root) {
360
+ add(record.function, method, root);
361
+ } else {
362
+ add(record.function, method, `${root}/{proxy+}`);
363
+ greedyRoots.add(`${record.function} ${root}`);
364
+ }
365
+ }
366
+
367
+ // Extra routes get an exact route unless a greedy route on the same
368
+ // function already covers them.
369
+ for (const record of [...AUTH_EXTRA_ROUTES, ...DOCS_ROUTES]) {
370
+ const covered = greedyRoots.has(
371
+ `${record.function} ${resourceRoot(record)}`
372
+ );
373
+ if (!covered) {
374
+ add(record.function, record.method, toGatewayPath(record.path));
375
+ }
376
+ }
377
+
378
+ for (const record of listCoreExtensionRoutes(coreExtensions)) {
379
+ add(record.function, record.method, record.path);
380
+ }
381
+
382
+ if (httpQueue) {
383
+ for (const record of HTTP_QUEUE_ROUTES) {
384
+ add(record.function, record.method, record.path);
385
+ }
386
+ }
387
+
388
+ return out;
389
+ }
390
+
391
+ module.exports = {
392
+ MANAGEMENT_API_VERSIONS,
393
+ PREFERRED_API_VERSION,
394
+ RESERVED_V2_SEGMENTS,
395
+ V1_MIGRATION_DOCS_URL,
396
+ FUNCTIONS,
397
+ V1_ROUTES,
398
+ V2_ROUTES,
399
+ META_ROUTES,
400
+ AUTH_EXTRA_ROUTES,
401
+ DOCS_ROUTES,
402
+ HTTP_QUEUE_ROUTES,
403
+ ADMIN_ROUTE_PREFIX,
404
+ RESERVED_ADMIN_SEGMENTS,
405
+ coreExtensionRoutePathError,
406
+ listCoreExtensionRoutes,
407
+ listRoutes,
408
+ listV2Routes,
409
+ getGatewayRoutes,
410
+ toGatewayPath,
411
+ };