@friggframework/core 2.0.0--canary.680.daae24e.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 (156) hide show
  1. package/CHANGELOG.md +0 -11
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +2 -2
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/core/Worker.js +31 -7
  8. package/core/create-handler.js +9 -0
  9. package/credential/use-cases/credential-operations-for-user.js +110 -25
  10. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  11. package/database/config.js +48 -24
  12. package/database/documentdb-utils.js +6 -1
  13. package/database/encryption/README.md +0 -2
  14. package/database/encryption/encryption-config.js +10 -31
  15. package/database/encryption/encryption-schema-registry.js +99 -47
  16. package/database/encryption/field-encryption-service.js +5 -69
  17. package/database/organization-where.js +18 -0
  18. package/database/prisma.js +4 -2
  19. package/database/unique-violation.js +25 -0
  20. package/extensions/bootstrap-runner.js +43 -0
  21. package/extensions/extension-loader.js +187 -0
  22. package/extensions/index.js +41 -0
  23. package/extensions/initialize-app.js +123 -0
  24. package/extensions/route-mounter.js +103 -0
  25. package/extensions/schema-composer.js +188 -0
  26. package/generated/prisma-mongodb/edge.js +27 -4
  27. package/generated/prisma-mongodb/index-browser.js +24 -1
  28. package/generated/prisma-mongodb/index.d.ts +3227 -47
  29. package/generated/prisma-mongodb/index.js +27 -4
  30. package/generated/prisma-mongodb/package.json +1 -1
  31. package/generated/prisma-mongodb/schema.prisma +65 -0
  32. package/generated/prisma-mongodb/wasm.js +27 -4
  33. package/generated/prisma-postgresql/edge.js +27 -4
  34. package/generated/prisma-postgresql/index-browser.js +24 -1
  35. package/generated/prisma-postgresql/index.d.ts +3519 -67
  36. package/generated/prisma-postgresql/index.js +27 -4
  37. package/generated/prisma-postgresql/package.json +1 -1
  38. package/generated/prisma-postgresql/schema.prisma +61 -0
  39. package/generated/prisma-postgresql/wasm.js +27 -4
  40. package/handlers/app-definition-loader.js +103 -12
  41. package/handlers/routers/auth.js +40 -12
  42. package/handlers/routers/health.js +4 -2
  43. package/handlers/routers/integration-defined-routers.js +144 -92
  44. package/handlers/routers/integration-webhook-routers.js +102 -64
  45. package/index.js +63 -1
  46. package/infrastructure/scheduler/index.js +19 -6
  47. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  48. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  49. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  50. package/integrations/index.js +5 -0
  51. package/integrations/integration-router.js +115 -73
  52. package/integrations/repositories/integration-not-found.js +14 -0
  53. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  54. package/integrations/repositories/integration-repository-interface.js +17 -1
  55. package/integrations/repositories/integration-repository-mongo.js +59 -76
  56. package/integrations/repositories/integration-repository-postgres.js +62 -76
  57. package/integrations/schedule-config.js +80 -0
  58. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  59. package/integrations/use-cases/create-integration.js +55 -20
  60. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  61. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  62. package/integrations/use-cases/index.js +7 -1
  63. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  64. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  65. package/integrations/utils/map-integration-dto.js +44 -1
  66. package/management-api/management-api-config.js +71 -2
  67. package/management-api/meta-router.js +21 -2
  68. package/management-api/openapi/components.js +40 -0
  69. package/management-api/openapi/v2-operations.js +16 -4
  70. package/management-api/route-registry.js +123 -6
  71. package/management-api/v2/dependencies.js +40 -15
  72. package/management-api/v2/dto.js +20 -0
  73. package/management-api/v2/handlers/authorization.js +1 -0
  74. package/management-api/v2/handlers/credentials.js +6 -2
  75. package/management-api/v2/handlers/entities.js +10 -7
  76. package/management-api/v2/handlers/integrations.js +6 -3
  77. package/management-api/v2/request.js +32 -1
  78. package/modules/entity-scope.js +15 -0
  79. package/modules/index.js +8 -0
  80. package/modules/module-factory.js +12 -0
  81. package/modules/module.js +53 -2
  82. package/modules/repositories/entity-not-found.js +18 -0
  83. package/modules/repositories/module-repository-documentdb.js +51 -6
  84. package/modules/repositories/module-repository-interface.js +19 -2
  85. package/modules/repositories/module-repository-mongo.js +67 -74
  86. package/modules/repositories/module-repository-postgres.js +66 -74
  87. package/modules/repositories/module-repository.js +2 -1
  88. package/modules/scope-config.js +192 -0
  89. package/modules/scope-policy.js +112 -0
  90. package/modules/scope-resolution.js +288 -0
  91. package/modules/scope-visibility.js +247 -0
  92. package/modules/tests/doubles/ownership-matching.js +18 -0
  93. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  94. package/modules/use-cases/create-entity-from-credential.js +113 -0
  95. package/modules/use-cases/entity-operations-for-user.js +50 -13
  96. package/modules/use-cases/entity-types.js +31 -7
  97. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  98. package/modules/use-cases/get-authorization-requirements.js +2 -2
  99. package/modules/use-cases/get-entities-for-user.js +26 -13
  100. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  101. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  102. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  103. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  104. package/modules/use-cases/get-module.js +10 -4
  105. package/modules/use-cases/manage-access.js +21 -0
  106. package/modules/use-cases/multi-step-authorization.js +40 -13
  107. package/modules/use-cases/process-authorization-callback.js +53 -19
  108. package/modules/use-cases/refresh-entity-options.js +10 -4
  109. package/modules/use-cases/test-module-auth.js +10 -4
  110. package/package.json +6 -5
  111. package/prisma-mongodb/schema.prisma +65 -0
  112. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  113. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  114. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  115. package/prisma-postgresql/schema.prisma +61 -0
  116. package/providers/index.js +23 -0
  117. package/providers/resolve-provider.js +93 -0
  118. package/providers/runtime-detection.js +124 -0
  119. package/queues/index.js +25 -0
  120. package/queues/providers/index.js +11 -0
  121. package/queues/providers/netlify-background-provider.js +150 -0
  122. package/queues/providers/qstash-queue-provider.js +219 -0
  123. package/queues/providers/sqs-queue-provider.js +70 -0
  124. package/queues/queue-client-interface.js +55 -0
  125. package/queues/queue-message-auth.js +193 -0
  126. package/queues/queue-provider-factory.js +101 -0
  127. package/queues/queue-provider.js +65 -0
  128. package/queues/sqs-queue-client.js +81 -0
  129. package/types/index.d.ts +1 -0
  130. package/types/modules/index.d.ts +139 -0
  131. package/types/user/index.d.ts +134 -0
  132. package/user/adopter-jwt-config.js +177 -0
  133. package/user/adopter-jwt-errors.js +59 -0
  134. package/user/adopter-token-verifier.js +122 -0
  135. package/user/app-user-helpers.js +97 -0
  136. package/user/create-user-authentication.js +64 -0
  137. package/user/membership.js +51 -0
  138. package/user/repositories/organization-membership-record.js +19 -0
  139. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  140. package/user/repositories/organization-membership-repository-factory.js +42 -0
  141. package/user/repositories/organization-membership-repository-interface.js +57 -0
  142. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  143. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  144. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  145. package/user/repositories/user-repository-documentdb.js +27 -2
  146. package/user/repositories/user-repository-interface.js +18 -0
  147. package/user/repositories/user-repository-mongo.js +24 -0
  148. package/user/repositories/user-repository-postgres.js +18 -0
  149. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  150. package/user/use-cases/authenticate-user.js +7 -1
  151. package/user/use-cases/ensure-organization-membership.js +92 -0
  152. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  153. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  154. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  155. package/user/user.js +64 -1
  156. package/database/encryption/test-support/fake-ciphertext.js +0 -15
@@ -0,0 +1,177 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { SCOPES, resolveScope } = require('../../modules/scope-resolution');
3
+ const {
4
+ integrationOwnerKind,
5
+ resolveIntegrationOwner,
6
+ } = require('../../modules/scope-resolution');
7
+ const {
8
+ describeRecord,
9
+ unscopedRowsBelongHere,
10
+ } = require('../../modules/scope-visibility');
11
+ const {
12
+ bindingScopeSetting,
13
+ readIntegrationOwnership,
14
+ } = require('../../modules/scope-config');
15
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
16
+ const { entityTypeNameOf } = require('../../modules/entity-type-registry');
17
+
18
+ const notFound = (entityId) =>
19
+ Boom.notFound(`Entity ${entityId} not found`, { code: 'ENTITY_NOT_FOUND' });
20
+
21
+ /**
22
+ * Who owns an integration about to be created, and in which organization
23
+ * (ADR-056 §3, §3.3, §3.6). Shared by the v1 and v2 create paths. Nothing is
24
+ * persisted before this has answered, so a refusal leaves no row behind.
25
+ *
26
+ * Every entity must be one the caller may use in the active organization. A
27
+ * missing one, another organization's and a `global` one are the same 404, so
28
+ * ids cannot be probed. When the integration declares a scope (a binding's
29
+ * `scope` or `Definition.entities[key].scope`, or `Definition.ownership.scope`)
30
+ * the entity's scope must be in it. Levels below the integration (entity type,
31
+ * app, default) decide where a connection is CREATED and do not restrict what
32
+ * an integration may bind, so an app that adds a registry scope later does not
33
+ * break integrations that already use older connections.
34
+ *
35
+ * The owner follows the entities (all organization: the organization;
36
+ * otherwise the person) unless `Definition.ownership.owner` says. Every entity
37
+ * must then be of the owner's own scope: an integration is loaded by its owner,
38
+ * who must own what it uses, so an integration that mixes an organization
39
+ * entity with a person's own, or that names an owner that does not match, is
40
+ * refused with a 400 here, before anything is written.
41
+ */
42
+ class ResolveIntegrationOwnership {
43
+ /**
44
+ * @param {Object} params
45
+ * @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
46
+ * @param {Array<Function>} params.integrationClasses
47
+ * @param {Object} [params.scopePolicy] - from createScopePolicy
48
+ */
49
+ constructor({ moduleRepository, integrationClasses, scopePolicy }) {
50
+ this.moduleRepository = moduleRepository;
51
+ this.integrationClasses = integrationClasses || [];
52
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
53
+ }
54
+
55
+ /**
56
+ * @param {Object} params
57
+ * @param {Array<string|number>} params.entityIds
58
+ * @param {Object} params.config - `{ type }`
59
+ * @param {Object} params.user - authenticated User
60
+ * @returns {Promise<{ownerId: string, organizationId: (string|null), ownerKind: string, adoptUnscoped: boolean}>}
61
+ */
62
+ async execute({ entityIds, config, user }) {
63
+ const ctx = this.scopePolicy.accessContext(user);
64
+ const integrationClass = this.integrationClasses.find(
65
+ (IntegrationClass) => IntegrationClass.Definition?.name === config?.type
66
+ );
67
+ const ownership = readIntegrationOwnership(integrationClass);
68
+
69
+ const entities = await this.loadEntities(entityIds);
70
+ const scoped = entities.map((entity) => ({
71
+ entity,
72
+ scope: this.acceptedScope({ entity, ctx, integrationClass, ownership }),
73
+ }));
74
+
75
+ const ownerKind = integrationOwnerKind(
76
+ scoped.map(({ scope }) => scope),
77
+ ownership.owner
78
+ );
79
+ this.assertOneScope(scoped, ownerKind, config?.type);
80
+ return {
81
+ ...resolveIntegrationOwner(ownerKind, ctx),
82
+ ownerKind,
83
+ // Whether an existing integration with no organization (written
84
+ // before scoping) is this caller's to reuse. An organization-owned
85
+ // one is unambiguous: its owner is the organization, whoever asks
86
+ // and whatever their own legacy pointer says. A person's is theirs
87
+ // only where scope-visibility says (their legacy organization).
88
+ adoptUnscoped:
89
+ ownerKind === SCOPES.ORGANIZATION ||
90
+ unscopedRowsBelongHere(ctx, 'integration'),
91
+ };
92
+ }
93
+
94
+ /** Every entity must be of the integration owner's own scope (see the class comment). */
95
+ assertOneScope(scoped, ownerKind, type) {
96
+ for (const { entity, scope } of scoped) {
97
+ if (scope === ownerKind) continue;
98
+ // The load-time check (resolveOwnershipConfig) only sees declared
99
+ // scopes; this one sees the entities actually named.
100
+ if (ownerKind === SCOPES.ORGANIZATION) {
101
+ throw Boom.badRequest(
102
+ "An organization-owned integration cannot use an 'individual' entity: a shared integration must not run on one person's credential",
103
+ {
104
+ code: 'ORG_INTEGRATION_INDIVIDUAL_ENTITY',
105
+ details: { entityId: String(entity.id) },
106
+ }
107
+ );
108
+ }
109
+ throw Boom.badRequest(
110
+ `Integration '${type}' would be owned by the person but uses the organization entity ${entity.id}. An integration must use entities of one scope: all of the person's, or all of the organization's.`,
111
+ {
112
+ code: 'MIXED_ENTITY_SCOPES',
113
+ details: { entityId: String(entity.id), scope },
114
+ }
115
+ );
116
+ }
117
+ }
118
+
119
+ async loadEntities(entityIds) {
120
+ const ids = (entityIds || []).map(String);
121
+ if (ids.length === 0) return [];
122
+ let found;
123
+ try {
124
+ found = await this.moduleRepository.findEntitiesByIds(ids);
125
+ } catch (error) {
126
+ // A malformed id (e.g. not an integer on PostgreSQL) is "not found".
127
+ if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
128
+ throw notFound(ids[0]);
129
+ }
130
+ throw error;
131
+ }
132
+ const byId = new Map((found || []).filter(Boolean).map((e) => [String(e.id), e]));
133
+ return ids.map((id) => {
134
+ if (!byId.has(id)) throw notFound(id);
135
+ return byId.get(id);
136
+ });
137
+ }
138
+
139
+ /** The entity's scope for this caller, or a refusal. */
140
+ acceptedScope({ entity, ctx, integrationClass, ownership }) {
141
+ const described = describeRecord(entity, ctx, 'entity');
142
+ if (!described) throw notFound(entity.id);
143
+ const { scope } = described;
144
+
145
+ const declared = this.declaredScope({ entity, integrationClass, ownership });
146
+ if (declared && !declared.allow.includes(scope)) {
147
+ throw Boom.badRequest(
148
+ `Entity ${entity.id} is ${scope}-scoped, but this integration binds ${entity.moduleName} at ${declared.allow.join(' or ')} scope`,
149
+ {
150
+ code: 'ENTITY_SCOPE_MISMATCH',
151
+ details: { entityId: String(entity.id), scope, allowed: declared.allow },
152
+ }
153
+ );
154
+ }
155
+ return scope;
156
+ }
157
+
158
+ /**
159
+ * What the integration itself declares for this entity's binding (levels 2
160
+ * and 3 only), or null when it declares nothing.
161
+ */
162
+ declaredScope({ entity, integrationClass, ownership }) {
163
+ const Definition = integrationClass?.Definition;
164
+ const key = Object.keys(Definition?.modules || {}).find(
165
+ (candidate) =>
166
+ entityTypeNameOf(Definition.modules[candidate]?.definition) ===
167
+ entity.moduleName
168
+ );
169
+ const resolved = resolveScope({
170
+ binding: key === undefined ? undefined : bindingScopeSetting(Definition, key),
171
+ integration: ownership.scope,
172
+ });
173
+ return resolved.decidedBy === 'default' ? null : resolved;
174
+ }
175
+ }
176
+
177
+ module.exports = { ResolveIntegrationOwnership };
@@ -19,5 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) {
19
19
  };
20
20
  }
21
21
 
22
+ let deprecationLogged = false;
22
23
 
23
- module.exports = { mapIntegrationClassToIntegrationDTO };
24
+ /**
25
+ * @deprecated Since ADR-059. Read `entityTypeRegistry` / `moduleDefinitions`
26
+ * from `loadAppDefinition()`, or call `getEntityTypeRegistry(appDefinition)`
27
+ * and read each entry's `definition`.
28
+ *
29
+ * Kept as a wrapper over the entity type registry so callers that imported it
30
+ * keep working. It returns the registry's definitions, so it follows the
31
+ * registry's rules: one definition per entity type name (the first wins when
32
+ * two integrations bind different definitions under one name, as
33
+ * ModuleFactory already picked), and, when `entities` is passed, registry-only
34
+ * types are included and conflicts throw `EntityRegistryError`.
35
+ *
36
+ * @param {Array<Function>} integrationClasses - integration classes
37
+ * @param {Object} [entities] - `appDefinition.entities` (ADR-059)
38
+ * @returns {Array<Object>} API module definitions
39
+ */
40
+ function getModulesDefinitionFromIntegrationClasses(
41
+ integrationClasses,
42
+ entities
43
+ ) {
44
+ // Required here, not at the top: modules/ must not load at require time
45
+ // of every integration use case that only wants the DTO mapper.
46
+ const {
47
+ getEntityTypeRegistry,
48
+ getEntityTypeDefinitions,
49
+ } = require('../../modules/entity-type-registry');
50
+ if (!deprecationLogged) {
51
+ deprecationLogged = true;
52
+ const { getLogger } = require('../../logs');
53
+ getLogger('frigg.integrations').warn(
54
+ 'getModulesDefinitionFromIntegrationClasses is deprecated; use getEntityTypeRegistry (ADR-059)',
55
+ { eventName: 'frigg.integrations.deprecated_module_definitions' }
56
+ );
57
+ }
58
+ return getEntityTypeDefinitions(
59
+ getEntityTypeRegistry({ integrations: integrationClasses, entities })
60
+ );
61
+ }
62
+
63
+ module.exports = {
64
+ mapIntegrationClassToIntegrationDTO,
65
+ getModulesDefinitionFromIntegrationClasses,
66
+ };
@@ -9,6 +9,7 @@
9
9
  * timeoutMs: 25000, // whole upstream call, lower only
10
10
  * maxRequestBytes: 1048576,
11
11
  * maxResponseBytes: 5242880, // lower only
12
+ * scopes: ['individual'], // entity scopes the proxy accepts (ADR-056 §3.5)
12
13
  * modules: { // per-module allow-list override
13
14
  * hubspot: { allow: 'module' },
14
15
  * erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
@@ -16,7 +17,36 @@
16
17
  * },
17
18
  * }
18
19
  * ```
20
+ *
21
+ * Limits that are out of range fall back to their defaults. `proxy.scopes` is a
22
+ * security setting, so it does not fall back: a value that is not a list of
23
+ * known tenant scopes throws ManagementApiConfigError, and loadAppDefinition
24
+ * calls this so the app stops at load.
25
+ */
26
+
27
+ const { BaseError } = require('../errors/base-error');
28
+ const { SCOPES } = require('../modules/scope-resolution');
29
+
30
+ /** Raised for an invalid `appDefinition.managementApi` setting. */
31
+ class ManagementApiConfigError extends BaseError {
32
+ constructor(message, options) {
33
+ super(message, options);
34
+ this.code = 'MANAGEMENT_API_CONFIG_INVALID';
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The scopes `managementApi.proxy.scopes` may list. `global` is not one of
40
+ * them: a global entity is the app owner's and is never a tenant's to proxy.
19
41
  */
42
+ const PROXY_SCOPE_VALUES = Object.freeze([SCOPES.INDIVIDUAL, SCOPES.ORGANIZATION]);
43
+
44
+ /**
45
+ * Narrowest by default (ADR-056 §3.5): proxying through a shared credential
46
+ * lets any member act on the organization's account, so an app opts in to
47
+ * `organization` explicitly.
48
+ */
49
+ const DEFAULT_PROXY_SCOPES = Object.freeze([SCOPES.INDIVIDUAL]);
20
50
 
21
51
  // ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
22
52
  // at 6MB, so the timeout and response defaults are also ceilings: an app may
@@ -44,9 +74,41 @@ function positiveOr(value, fallback, ceiling) {
44
74
  return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
45
75
  }
46
76
 
77
+ const proxyScopesError = (message) =>
78
+ new ManagementApiConfigError(`managementApi.proxy.scopes: ${message}`);
79
+
80
+ /**
81
+ * `managementApi.proxy.scopes`: absent means the default; anything else must be
82
+ * a non-empty list of PROXY_SCOPE_VALUES, each once.
83
+ * @returns {ReadonlyArray<string>}
84
+ * @throws {ManagementApiConfigError}
85
+ */
86
+ function resolveProxyScopes(scopes) {
87
+ if (scopes === undefined) return DEFAULT_PROXY_SCOPES;
88
+ const allowed = PROXY_SCOPE_VALUES.join(', ');
89
+ if (!Array.isArray(scopes) || scopes.length === 0) {
90
+ throw proxyScopesError(`must be a non-empty list drawn from ${allowed}`);
91
+ }
92
+ for (const scope of scopes) {
93
+ if (scope === SCOPES.GLOBAL) {
94
+ throw proxyScopesError(
95
+ "'global' is not allowed: global entities belong to the app owner and are never proxied for a tenant"
96
+ );
97
+ }
98
+ if (!PROXY_SCOPE_VALUES.includes(scope)) {
99
+ throw proxyScopesError(`unknown scope ${JSON.stringify(scope)}; use ${allowed}`);
100
+ }
101
+ }
102
+ if (new Set(scopes).size !== scopes.length) {
103
+ throw proxyScopesError('lists a scope more than once');
104
+ }
105
+ return Object.freeze([...scopes]);
106
+ }
107
+
47
108
  /**
48
109
  * @param {Object} [managementApi] - `appDefinition.managementApi`
49
- * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
110
+ * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, scopes: ReadonlyArray<string>, modules: Object } }}
111
+ * @throws {ManagementApiConfigError} for an invalid `proxy.scopes`
50
112
  */
51
113
  function resolveManagementApiConfig(managementApi) {
52
114
  const config =
@@ -71,6 +133,7 @@ function resolveManagementApiConfig(managementApi) {
71
133
  proxy.maxResponseBytes,
72
134
  DEFAULT_PROXY_LIMITS.maxResponseBytes
73
135
  ),
136
+ scopes: resolveProxyScopes(proxy.scopes),
74
137
  modules:
75
138
  proxy.modules && typeof proxy.modules === 'object'
76
139
  ? proxy.modules
@@ -79,4 +142,10 @@ function resolveManagementApiConfig(managementApi) {
79
142
  };
80
143
  }
81
144
 
82
- module.exports = { resolveManagementApiConfig, DEFAULT_PROXY_LIMITS };
145
+ module.exports = {
146
+ resolveManagementApiConfig,
147
+ DEFAULT_PROXY_LIMITS,
148
+ DEFAULT_PROXY_SCOPES,
149
+ PROXY_SCOPE_VALUES,
150
+ ManagementApiConfigError,
151
+ };
@@ -6,7 +6,11 @@ const {
6
6
  } = require('./use-cases/get-management-api-meta');
7
7
  const { isValidAdminApiKey } = require('../handlers/middleware/admin-auth');
8
8
  const { version: coreVersion } = require('../package.json');
9
- const { MANAGEMENT_API_VERSIONS } = require('./route-registry');
9
+ const {
10
+ MANAGEMENT_API_VERSIONS,
11
+ PREFERRED_API_VERSION,
12
+ DOCS_ROUTES,
13
+ } = require('./route-registry');
10
14
  const { buildOpenApiDocument } = require('./openapi/build-openapi');
11
15
 
12
16
  /**
@@ -54,4 +58,19 @@ function createManagementApiMetaRouter({ appDefinition = {} } = {}) {
54
58
  return router;
55
59
  }
56
60
 
57
- module.exports = { createManagementApiMetaRouter };
61
+ /**
62
+ * GET /api/docs (DOCS_ROUTES): a redirect to the current OpenAPI document,
63
+ * mounted next to the meta router on the DB-free health Lambda. Public, like
64
+ * /api/meta.
65
+ */
66
+ function createDocsRedirectRouter() {
67
+ const router = Router();
68
+ for (const { path } of DOCS_ROUTES) {
69
+ router.get(path, (_req, res) =>
70
+ res.redirect(302, `/api/meta/openapi/v${PREFERRED_API_VERSION}.json`)
71
+ );
72
+ }
73
+ return router;
74
+ }
75
+
76
+ module.exports = { createManagementApiMetaRouter, createDocsRedirectRouter };
@@ -8,6 +8,27 @@ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
8
8
 
9
9
  const nullable = (schema) => ({ ...schema, nullable: true });
10
10
 
11
+ // Whose a record is and what the caller may do with it (ADR-056 §3.4). Added to
12
+ // entities, credentials and integrations by the list and get use cases.
13
+ const tenancyProperties = {
14
+ scope: { type: 'string', enum: ['individual', 'organization'], description: 'Whose it is. `global` records are not shown to tenants.' },
15
+ owner: {
16
+ type: 'object',
17
+ properties: { type: { type: 'string', enum: ['individual', 'organization'] }, id: { type: 'string' } },
18
+ description: "The user that owns it: the person, or the organization's user",
19
+ },
20
+ organizationId: nullable({
21
+ type: 'string',
22
+ description:
23
+ 'The organization it belongs to. Null for an individual entity connected outside an organization, or under ownership.individualAcross "all".',
24
+ }),
25
+ access: {
26
+ type: 'string',
27
+ enum: ['manage', 'use', 'view'],
28
+ description: 'manage (create, reauthorize, delete), use (bind it, proxy through it) or view (metadata only)',
29
+ },
30
+ };
31
+
11
32
  const errorBody = {
12
33
  type: 'object',
13
34
  required: ['error'],
@@ -69,6 +90,7 @@ const SCHEMAS = {
69
90
  config: { type: 'object', additionalProperties: true },
70
91
  version: nullable({ type: 'string' }),
71
92
  messages: { type: 'object', additionalProperties: true },
93
+ ...tenancyProperties,
72
94
  },
73
95
  },
74
96
  ListIntegrationsResponse: {
@@ -129,6 +151,7 @@ const SCHEMAS = {
129
151
  credentialId: nullable({ type: 'string' }),
130
152
  userId: nullable({ type: 'string' }),
131
153
  authIsValid: nullable({ type: 'boolean' }),
154
+ ...tenancyProperties,
132
155
  },
133
156
  },
134
157
  ListEntitiesResponse: {
@@ -153,6 +176,16 @@ const SCHEMAS = {
153
176
  isMultiStep: { type: 'boolean' },
154
177
  stepCount: { type: 'integer', minimum: 1 },
155
178
  capabilities: {},
179
+ scope: {
180
+ type: 'object',
181
+ description:
182
+ 'The scope a new connection of this type gets and the scopes a request may choose (ADR-056 §3.1). `global` is never offered.',
183
+ properties: {
184
+ default: { type: 'string', enum: ['individual', 'organization', 'global'] },
185
+ allow: { type: 'array', items: { type: 'string', enum: ['individual', 'organization'] } },
186
+ source: { type: 'string', enum: ['binding', 'integration', 'entityType', 'app', 'default'] },
187
+ },
188
+ },
156
189
  },
157
190
  },
158
191
  ListEntityTypesResponse: {
@@ -173,6 +206,7 @@ const SCHEMAS = {
173
206
  authIsValid: nullable({ type: 'boolean' }),
174
207
  entityIds: { type: 'array', items: { type: 'string' } },
175
208
  entityCount: { type: 'integer', minimum: 0 },
209
+ ...tenancyProperties,
176
210
  createdAt: nullable({ type: 'string', format: 'date-time' }),
177
211
  updatedAt: nullable({ type: 'string', format: 'date-time' }),
178
212
  data: {
@@ -197,6 +231,12 @@ const SCHEMAS = {
197
231
  data: { type: 'object', additionalProperties: true, description: 'OAuth callback params or step form data' },
198
232
  step: { type: 'integer', minimum: 1, default: 1 },
199
233
  sessionId: { type: 'string', description: 'Required for step 2 and later' },
234
+ scope: {
235
+ type: 'string',
236
+ enum: ['individual', 'organization'],
237
+ description:
238
+ "The scope to connect at, from the entity type's allow list (ADR-056 §3.1). Omit to take the type's default. `global` is refused (403).",
239
+ },
200
240
  },
201
241
  },
202
242
  ReauthorizeRequest: {
@@ -34,6 +34,11 @@ const stepQuery = [
34
34
  query('step', 'Step number, 1-based (default 1)', { type: 'integer', minimum: 1 }),
35
35
  query('sessionId', 'Session from step 1; required for later steps'),
36
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
+ ];
37
42
  const anyJson = { description: 'Defined by the integration or module' };
38
43
 
39
44
  const V2_OPERATIONS = {
@@ -41,7 +46,8 @@ const V2_OPERATIONS = {
41
46
  listIntegrations: {
42
47
  summary: "List the caller's integrations",
43
48
  description:
44
- 'Returns only `{ integrations }`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
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,
45
51
  responses: { 200: ok('Integrations', ref('ListIntegrationsResponse')), ...errors('Unauthorized') },
46
52
  },
47
53
  listIntegrationOptions: {
@@ -108,6 +114,9 @@ const V2_OPERATIONS = {
108
114
  // Entities
109
115
  listEntities: {
110
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,
111
120
  responses: { 200: ok('Entities', ref('ListEntitiesResponse')), ...errors('Unauthorized') },
112
121
  },
113
122
  listEntityTypes: {
@@ -156,14 +165,14 @@ const V2_OPERATIONS = {
156
165
  proxyEntityRequest: {
157
166
  summary: "Call the entity's API through Frigg (beta)",
158
167
  description:
159
- "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. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
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.",
160
169
  requestBody: body(ref('ProxyRequest')),
161
170
  responses: {
162
171
  200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
163
172
  400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
164
173
  401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
165
- 403: ok('PROXY_NOT_ALLOWED', ref('ProxyError')),
166
- 404: ok('Entity not found', ref('Error')),
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')),
167
176
  413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
168
177
  415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
169
178
  429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
@@ -175,6 +184,9 @@ const V2_OPERATIONS = {
175
184
  // Credentials
176
185
  listCredentials: {
177
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,
178
190
  responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
179
191
  },
180
192
  getCredential: {