@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.87e9875.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 (136) hide show
  1. package/CLAUDE.md +25 -0
  2. package/README.md +105 -0
  3. package/application/commands/integration-commands.js +2 -2
  4. package/application/commands/user-commands.js +17 -5
  5. package/core/Worker.js +31 -7
  6. package/credential/use-cases/credential-operations-for-user.js +110 -25
  7. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  8. package/database/config.js +48 -24
  9. package/database/documentdb-utils.js +6 -1
  10. package/database/encryption/encryption-schema-registry.js +80 -12
  11. package/database/organization-where.js +18 -0
  12. package/database/prisma.js +4 -2
  13. package/database/unique-violation.js +25 -0
  14. package/extensions/bootstrap-runner.js +43 -0
  15. package/extensions/extension-loader.js +167 -0
  16. package/extensions/index.js +41 -0
  17. package/extensions/initialize-app.js +123 -0
  18. package/extensions/route-mounter.js +103 -0
  19. package/extensions/schema-composer.js +188 -0
  20. package/generated/prisma-mongodb/edge.js +27 -4
  21. package/generated/prisma-mongodb/index-browser.js +24 -1
  22. package/generated/prisma-mongodb/index.d.ts +3227 -47
  23. package/generated/prisma-mongodb/index.js +27 -4
  24. package/generated/prisma-mongodb/package.json +1 -1
  25. package/generated/prisma-mongodb/schema.prisma +65 -0
  26. package/generated/prisma-mongodb/wasm.js +27 -4
  27. package/generated/prisma-postgresql/edge.js +27 -4
  28. package/generated/prisma-postgresql/index-browser.js +24 -1
  29. package/generated/prisma-postgresql/index.d.ts +3519 -67
  30. package/generated/prisma-postgresql/index.js +27 -4
  31. package/generated/prisma-postgresql/package.json +1 -1
  32. package/generated/prisma-postgresql/schema.prisma +61 -0
  33. package/generated/prisma-postgresql/wasm.js +27 -4
  34. package/handlers/app-definition-loader.js +93 -12
  35. package/handlers/routers/auth.js +40 -12
  36. package/handlers/routers/integration-defined-routers.js +144 -92
  37. package/handlers/routers/integration-webhook-routers.js +102 -64
  38. package/index.js +58 -1
  39. package/infrastructure/scheduler/index.js +19 -6
  40. package/infrastructure/scheduler/netlify-scheduler-adapter.js +185 -0
  41. package/infrastructure/scheduler/scheduler-service-factory.js +36 -7
  42. package/integrations/integration-router.js +115 -73
  43. package/integrations/repositories/integration-not-found.js +14 -0
  44. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  45. package/integrations/repositories/integration-repository-interface.js +17 -1
  46. package/integrations/repositories/integration-repository-mongo.js +59 -76
  47. package/integrations/repositories/integration-repository-postgres.js +62 -76
  48. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  49. package/integrations/use-cases/create-integration.js +55 -20
  50. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  51. package/integrations/use-cases/index.js +5 -1
  52. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  53. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  54. package/management-api/management-api-config.js +71 -2
  55. package/management-api/openapi/components.js +40 -0
  56. package/management-api/openapi/v2-operations.js +16 -4
  57. package/management-api/v2/dependencies.js +40 -15
  58. package/management-api/v2/dto.js +20 -0
  59. package/management-api/v2/handlers/authorization.js +1 -0
  60. package/management-api/v2/handlers/credentials.js +6 -2
  61. package/management-api/v2/handlers/entities.js +10 -7
  62. package/management-api/v2/handlers/integrations.js +6 -3
  63. package/management-api/v2/request.js +32 -1
  64. package/modules/entity-scope.js +15 -0
  65. package/modules/index.js +8 -0
  66. package/modules/module-factory.js +12 -0
  67. package/modules/module.js +35 -2
  68. package/modules/repositories/entity-not-found.js +18 -0
  69. package/modules/repositories/module-repository-documentdb.js +51 -6
  70. package/modules/repositories/module-repository-interface.js +19 -2
  71. package/modules/repositories/module-repository-mongo.js +67 -74
  72. package/modules/repositories/module-repository-postgres.js +66 -74
  73. package/modules/repositories/module-repository.js +2 -1
  74. package/modules/scope-config.js +192 -0
  75. package/modules/scope-policy.js +112 -0
  76. package/modules/scope-resolution.js +288 -0
  77. package/modules/scope-visibility.js +247 -0
  78. package/modules/tests/doubles/ownership-matching.js +18 -0
  79. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  80. package/modules/use-cases/create-entity-from-credential.js +113 -0
  81. package/modules/use-cases/entity-operations-for-user.js +50 -13
  82. package/modules/use-cases/entity-types.js +31 -7
  83. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  84. package/modules/use-cases/get-entities-for-user.js +16 -5
  85. package/modules/use-cases/get-entity-module-for-user.js +32 -5
  86. package/modules/use-cases/get-entity-options-by-id.js +9 -3
  87. package/modules/use-cases/get-module.js +9 -3
  88. package/modules/use-cases/manage-access.js +21 -0
  89. package/modules/use-cases/multi-step-authorization.js +39 -12
  90. package/modules/use-cases/process-authorization-callback.js +52 -18
  91. package/modules/use-cases/refresh-entity-options.js +9 -3
  92. package/modules/use-cases/test-module-auth.js +9 -3
  93. package/package.json +6 -5
  94. package/prisma-mongodb/schema.prisma +65 -0
  95. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  96. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  97. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  98. package/prisma-postgresql/schema.prisma +61 -0
  99. package/providers/index.js +13 -0
  100. package/providers/resolve-provider.js +93 -0
  101. package/queues/index.js +25 -0
  102. package/queues/providers/index.js +11 -0
  103. package/queues/providers/netlify-background-provider.js +76 -0
  104. package/queues/providers/qstash-queue-provider.js +122 -0
  105. package/queues/providers/sqs-queue-provider.js +61 -0
  106. package/queues/queue-client-interface.js +55 -0
  107. package/queues/queue-provider-factory.js +101 -0
  108. package/queues/queue-provider.js +46 -0
  109. package/queues/sqs-queue-client.js +81 -0
  110. package/types/index.d.ts +1 -0
  111. package/types/modules/index.d.ts +129 -0
  112. package/types/user/index.d.ts +134 -0
  113. package/user/adopter-jwt-config.js +177 -0
  114. package/user/adopter-jwt-errors.js +59 -0
  115. package/user/adopter-token-verifier.js +122 -0
  116. package/user/app-user-helpers.js +97 -0
  117. package/user/create-user-authentication.js +64 -0
  118. package/user/membership.js +51 -0
  119. package/user/repositories/organization-membership-record.js +19 -0
  120. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  121. package/user/repositories/organization-membership-repository-factory.js +42 -0
  122. package/user/repositories/organization-membership-repository-interface.js +57 -0
  123. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  124. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  125. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  126. package/user/repositories/user-repository-documentdb.js +27 -2
  127. package/user/repositories/user-repository-interface.js +18 -0
  128. package/user/repositories/user-repository-mongo.js +24 -0
  129. package/user/repositories/user-repository-postgres.js +18 -0
  130. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  131. package/user/use-cases/authenticate-user.js +7 -1
  132. package/user/use-cases/ensure-organization-membership.js +92 -0
  133. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  134. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  135. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  136. package/user/user.js +64 -1
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * PostgreSQL Module Repository Adapter
@@ -108,6 +109,26 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
108
109
  return credentialMap;
109
110
  }
110
111
 
112
+ /**
113
+ * The one place a stored entity row becomes the entity the application sees.
114
+ * The provider's `data` is spread FIRST and every column is pinned after it:
115
+ * a stored `id`, `userId` or `organizationId` in `data` must not decide who
116
+ * owns the entity or who sees it.
117
+ * @private
118
+ */
119
+ _mapEntity(row, credential) {
120
+ return {
121
+ ...(row.data || {}),
122
+ id: row.id.toString(),
123
+ credential,
124
+ userId: row.userId?.toString(),
125
+ name: row.name,
126
+ externalId: row.externalId,
127
+ moduleName: row.moduleName,
128
+ organizationId: row.organizationId?.toString() ?? null,
129
+ };
130
+ }
131
+
111
132
  /**
112
133
  * Find entity by ID with credential
113
134
  * Replaces: Entity.findById(entityId).populate('credential')
@@ -124,20 +145,12 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
124
145
  });
125
146
 
126
147
  if (!entity) {
127
- throw new Error(`Entity ${entityId} not found`);
148
+ throw entityNotFoundError(entityId);
128
149
  }
129
150
 
130
151
  const credential = await this._fetchCredential(entity.credentialId);
131
152
 
132
- return {
133
- id: entity.id.toString(),
134
- credential,
135
- userId: entity.userId?.toString(),
136
- name: entity.name,
137
- externalId: entity.externalId,
138
- moduleName: entity.moduleName,
139
- ...(entity.data || {}),
140
- };
153
+ return this._mapEntity(entity, credential);
141
154
  }
142
155
 
143
156
  /**
@@ -157,15 +170,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
157
170
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
158
171
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
159
172
 
160
- return entities.map((e) => ({
161
- id: e.id.toString(),
162
- credential: credentialMap.get(e.credentialId) || null,
163
- userId: e.userId?.toString(),
164
- name: e.name,
165
- externalId: e.externalId,
166
- moduleName: e.moduleName,
167
- ...(e.data || {}),
168
- }));
173
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
169
174
  }
170
175
 
171
176
  /**
@@ -185,15 +190,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
185
190
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
186
191
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
187
192
 
188
- return entities.map((e) => ({
189
- id: e.id.toString(),
190
- credential: credentialMap.get(e.credentialId) || null,
191
- userId: e.userId?.toString(),
192
- name: e.name,
193
- externalId: e.externalId,
194
- moduleName: e.moduleName,
195
- ...(e.data || {}),
196
- }));
193
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
197
194
  }
198
195
 
199
196
  /**
@@ -217,15 +214,32 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
217
214
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
218
215
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
219
216
 
220
- return entities.map((e) => ({
221
- id: e.id.toString(),
222
- credential: credentialMap.get(e.credentialId) || null,
223
- userId: e.userId?.toString(),
224
- name: e.name,
225
- externalId: e.externalId,
226
- moduleName: e.moduleName,
227
- ...(e.data || {}),
228
- }));
217
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
218
+ }
219
+
220
+ /**
221
+ * Find entities matching any of the owner/organization pairs, in one query.
222
+ * `organizationId: null` matches rows with no organization. The caller
223
+ * (a use case) decides which pairs; this only runs them.
224
+ *
225
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
226
+ * @returns {Promise<Array>} Array of entity objects with string IDs
227
+ */
228
+ async findEntitiesByOwnership(clauses) {
229
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
230
+ const entities = await this.prisma.entity.findMany({
231
+ where: {
232
+ OR: clauses.map(({ userId, organizationId }) => ({
233
+ userId: this._convertId(userId),
234
+ organizationId: this._convertId(organizationId),
235
+ })),
236
+ },
237
+ });
238
+
239
+ const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
240
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
241
+
242
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
229
243
  }
230
244
 
231
245
  /**
@@ -264,15 +278,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
264
278
 
265
279
  const credential = await this._fetchCredential(entity.credentialId);
266
280
 
267
- return {
268
- id: entity.id.toString(),
269
- credential,
270
- userId: entity.userId?.toString(),
271
- name: entity.name,
272
- externalId: entity.externalId,
273
- moduleName: entity.moduleName,
274
- ...(entity.data || {}),
275
- };
281
+ return this._mapEntity(entity, credential);
276
282
  }
277
283
 
278
284
  /**
@@ -292,15 +298,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
292
298
  .filter(Boolean);
293
299
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
294
300
 
295
- return entities.map((e) => ({
296
- id: e.id.toString(),
297
- credential: credentialMap.get(e.credentialId) || null,
298
- userId: e.userId?.toString(),
299
- name: e.name,
300
- externalId: e.externalId,
301
- moduleName: e.moduleName,
302
- ...(e.data || {}),
303
- }));
301
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
304
302
  }
305
303
 
306
304
  /**
@@ -308,9 +306,13 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
308
306
  * Replaces: Entity.create(entityData)
309
307
  *
310
308
  * @param {Object} entityData - Entity data (with string IDs from application layer)
309
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
310
+ * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
311
+ * because `entityData` carries provider fields and one named `organizationId`
312
+ * must not be taken for ours.
311
313
  * @returns {Promise<Object>} Created entity object with string IDs
312
314
  */
313
- async createEntity(entityData) {
315
+ async createEntity(entityData, tenancy = {}) {
314
316
  const {
315
317
  user,
316
318
  userId,
@@ -328,6 +330,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
328
330
  name,
329
331
  moduleName,
330
332
  externalId,
333
+ organizationId: this._convertId(tenancy.organizationId ?? null),
331
334
  data: dynamicData,
332
335
  };
333
336
 
@@ -337,15 +340,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
337
340
 
338
341
  const credentialObj = await this._fetchCredential(entity.credentialId);
339
342
 
340
- return {
341
- id: entity.id.toString(),
342
- credential: credentialObj,
343
- userId: entity.userId?.toString(),
344
- name: entity.name,
345
- externalId: entity.externalId,
346
- moduleName: entity.moduleName,
347
- ...(entity.data || {}),
348
- };
343
+ return this._mapEntity(entity, credentialObj);
349
344
  }
350
345
 
351
346
  /**
@@ -401,15 +396,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
401
396
 
402
397
  const credentialObj = await this._fetchCredential(entity.credentialId);
403
398
 
404
- return {
405
- id: entity.id.toString(),
406
- credential: credentialObj,
407
- userId: entity.userId?.toString(),
408
- name: entity.name,
409
- externalId: entity.externalId,
410
- moduleName: entity.moduleName,
411
- ...(entity.data || {}),
412
- };
399
+ return this._mapEntity(entity, credentialObj);
413
400
  } catch (error) {
414
401
  if (error.code === 'P2025') {
415
402
  return null;
@@ -474,6 +461,11 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
474
461
  if (filter.moduleName) where.moduleName = filter.moduleName;
475
462
  if (filter.externalId) where.externalId = this._toString(filter.externalId);
476
463
 
464
+ // `null` means "no organization"; absent means "any".
465
+ if (filter.organizationId !== undefined) {
466
+ where.organizationId = this._convertId(filter.organizationId);
467
+ }
468
+
477
469
  return where;
478
470
  }
479
471
  }
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * Prisma-based Module Repository
@@ -37,7 +38,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
37
38
  });
38
39
 
39
40
  if (!entity) {
40
- throw new Error(`Entity ${entityId} not found`);
41
+ throw entityNotFoundError(entityId);
41
42
  }
42
43
 
43
44
  return {
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Reading and validating ownership settings in an app definition (ADR-056
3
+ * §3.1, §3.3). Called once by loadAppDefinition, so a bad setting stops the app
4
+ * at load, not on the first request. No I/O.
5
+ */
6
+
7
+ const {
8
+ SCOPES,
9
+ INDIVIDUAL_ACROSS_VALUES,
10
+ OWNER_RULES,
11
+ DEFAULT_ORG_ADMIN_ROLES,
12
+ configError,
13
+ isPlainObject,
14
+ parseScopeSetting,
15
+ resolveScope,
16
+ } = require('./scope-resolution');
17
+ const { entityTypeNameOf } = require('./entity-type-registry');
18
+
19
+ /** The binding scope an integration declares for `modules[key]`, if any. */
20
+ function bindingScopeSetting(Definition, key) {
21
+ const binding = Definition?.modules?.[key];
22
+ if (binding?.scope !== undefined) return binding.scope;
23
+ const entityEntry = Definition?.entities?.[key];
24
+ if (entityEntry?.scope !== undefined) return entityEntry.scope;
25
+ // Deprecated alias from the global-entities branch: `global: true`.
26
+ if (entityEntry?.global === true) return SCOPES.GLOBAL;
27
+ return undefined;
28
+ }
29
+
30
+ function readAppOwnership(appDefinition) {
31
+ const raw = appDefinition?.ownership;
32
+ if (raw === undefined || raw === null) {
33
+ return { scope: null, individualAcross: 'organization' };
34
+ }
35
+ if (!isPlainObject(raw)) {
36
+ throw configError('appDefinition.ownership', 'must be an object');
37
+ }
38
+ const unknown = Object.keys(raw).filter(
39
+ (key) => key !== 'scope' && key !== 'individualAcross'
40
+ );
41
+ if (unknown.length) {
42
+ throw configError(
43
+ 'appDefinition.ownership',
44
+ `unknown setting(s): ${unknown.join(', ')}`
45
+ );
46
+ }
47
+ const individualAcross = raw.individualAcross ?? 'organization';
48
+ if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
49
+ throw configError(
50
+ 'appDefinition.ownership.individualAcross',
51
+ `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
52
+ );
53
+ }
54
+ return {
55
+ scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'),
56
+ individualAcross,
57
+ };
58
+ }
59
+
60
+ function readIntegrationOwnership(integrationClass) {
61
+ const name = integrationClass?.Definition?.name || 'unnamed';
62
+ const raw = integrationClass?.Definition?.ownership;
63
+ if (raw === undefined || raw === null) return { scope: null, owner: 'derive' };
64
+ const where = `integration '${name}' ownership`;
65
+ if (!isPlainObject(raw)) throw configError(where, 'must be an object');
66
+ const unknown = Object.keys(raw).filter(
67
+ (key) => key !== 'scope' && key !== 'owner'
68
+ );
69
+ if (unknown.length) {
70
+ throw configError(where, `unknown setting(s): ${unknown.join(', ')}`);
71
+ }
72
+ const owner = raw.owner ?? 'derive';
73
+ if (!OWNER_RULES.includes(owner)) {
74
+ throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`);
75
+ }
76
+ return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
77
+ }
78
+
79
+ /**
80
+ * `user.orgAdminRoles`: the token roles that may manage organization-level
81
+ * things. Default `['ADMIN']`. A shared-secret caller needs no role.
82
+ */
83
+ function readOrgAdminRoles(appDefinition) {
84
+ const raw = appDefinition?.user?.orgAdminRoles;
85
+ if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
86
+ if (
87
+ !Array.isArray(raw) ||
88
+ raw.length === 0 ||
89
+ raw.some((role) => typeof role !== 'string' || role.trim() === '')
90
+ ) {
91
+ throw configError(
92
+ 'appDefinition.user.orgAdminRoles',
93
+ 'must be a non-empty array of role names'
94
+ );
95
+ }
96
+ return [...raw];
97
+ }
98
+
99
+ /**
100
+ * `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
101
+ * like adopter-token callers. Default false: they keep their pre-roles
102
+ * behaviour and may manage the organization they act in.
103
+ */
104
+ function readGateBearerTokens(appDefinition) {
105
+ const raw = appDefinition?.user?.orgAdminGateBearerTokens;
106
+ if (raw === undefined || raw === null) return false;
107
+ if (typeof raw !== 'boolean') {
108
+ throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
109
+ }
110
+ return raw;
111
+ }
112
+
113
+ /**
114
+ * Every binding of every integration with its resolved scope and where it came
115
+ * from, parsing each integration's settings once. Throws OwnershipConfigError
116
+ * for any invalid one. Exported for tooling that wants to show the chain.
117
+ */
118
+ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
119
+ const app = readAppOwnership(appDefinition);
120
+ const rows = [];
121
+ for (const integrationClass of appDefinition?.integrations || []) {
122
+ const Definition = integrationClass?.Definition;
123
+ const name = Definition?.name || 'unnamed';
124
+ const ownership = readIntegrationOwnership(integrationClass);
125
+ for (const [key, binding] of Object.entries(Definition?.modules || {})) {
126
+ const moduleName = entityTypeNameOf(binding?.definition);
127
+ const resolved = resolveScope({
128
+ binding: parseScopeSetting(
129
+ bindingScopeSetting(Definition, key),
130
+ `integration '${name}' binding '${key}' scope`
131
+ ),
132
+ integration: ownership.scope,
133
+ entityType: moduleName
134
+ ? entityTypeRegistry?.get(moduleName)?.settings?.scope
135
+ : undefined,
136
+ app: app.scope,
137
+ primary,
138
+ });
139
+ rows.push({
140
+ integration: name,
141
+ binding: key,
142
+ moduleName: moduleName ?? null,
143
+ scope: resolved.scope,
144
+ source: resolved.source,
145
+ owner: ownership.owner,
146
+ });
147
+ }
148
+ }
149
+ return rows;
150
+ }
151
+
152
+ /**
153
+ * Validates every ownership setting in one pass and returns the app-level
154
+ * config the runtime needs.
155
+ *
156
+ * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
157
+ * @throws {OwnershipConfigError}
158
+ */
159
+ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
160
+ const app = readAppOwnership(appDefinition);
161
+ for (const [name, entry] of entityTypeRegistry || []) {
162
+ parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`);
163
+ }
164
+ const rows = listBindingScopes(
165
+ appDefinition,
166
+ entityTypeRegistry,
167
+ appDefinition?.user?.primary
168
+ );
169
+ // §3.3: a shared integration must not run on one person's credential.
170
+ for (const row of rows) {
171
+ if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) {
172
+ throw configError(
173
+ `integration '${row.integration}' binding '${row.binding}'`,
174
+ "an organization-owned integration cannot bind an 'individual' entity"
175
+ );
176
+ }
177
+ }
178
+ return Object.freeze({
179
+ scope: app.scope,
180
+ individualAcross: app.individualAcross,
181
+ orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
182
+ gateBearerTokens: readGateBearerTokens(appDefinition),
183
+ });
184
+ }
185
+
186
+ module.exports = {
187
+ bindingScopeSetting,
188
+ readAppOwnership,
189
+ readIntegrationOwnership,
190
+ listBindingScopes,
191
+ resolveOwnershipConfig,
192
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The object a use case asks about scopes (ADR-056 §3): the app's ownership
3
+ * config and entity type registry bound once, as closures. With no arguments it
4
+ * is the behaviour of an app that configures nothing.
5
+ */
6
+
7
+ const {
8
+ INDIVIDUAL_ACROSS_VALUES,
9
+ DEFAULT_ORG_ADMIN_ROLES,
10
+ configError,
11
+ parseScopeSetting,
12
+ resolveScope,
13
+ resolveOwnerForScope,
14
+ } = require('./scope-resolution');
15
+ const {
16
+ accessContext,
17
+ accessToOwner,
18
+ describeRecord,
19
+ } = require('./scope-visibility');
20
+
21
+ /**
22
+ * @param {Object} [params]
23
+ * @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
24
+ * `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
25
+ * `{ default, allow }` parses to itself)
26
+ * @param {Map} [params.entityTypeRegistry]
27
+ */
28
+ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
29
+ const appScope = parseScopeSetting(
30
+ ownership?.scope,
31
+ 'appDefinition.ownership.scope'
32
+ );
33
+ const individualAcross = ownership?.individualAcross ?? 'organization';
34
+ if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
35
+ throw configError(
36
+ 'appDefinition.ownership.individualAcross',
37
+ `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
38
+ );
39
+ }
40
+
41
+ const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
42
+ const gateBearerTokens = ownership?.gateBearerTokens === true;
43
+
44
+ const contextFor = (user) =>
45
+ accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
46
+
47
+ /** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
48
+ const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
49
+ resolveScope({
50
+ request: requestScope,
51
+ binding,
52
+ integration,
53
+ entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope,
54
+ app: appScope,
55
+ primary: user?.config?.primary,
56
+ });
57
+
58
+ /**
59
+ * Scope, then owner and organization, for a new entity. `legacyOrgId` (the
60
+ * person's legacy organization pointer) rides along so a later lookup can
61
+ * reuse an entity written before scoping.
62
+ */
63
+ const resolveOwnership = ({ entityType, requestScope, user }) => {
64
+ const ctx = contextFor(user);
65
+ const resolved = resolveEntityScope({ entityType, requestScope, user });
66
+ return {
67
+ ...resolveOwnerForScope(resolved.scope, ctx),
68
+ legacyOrgId: ctx.legacyOrgId,
69
+ source: resolved.source,
70
+ };
71
+ };
72
+
73
+ /**
74
+ * The owner and organization for a scope that is already decided: a
75
+ * re-authorization keeps the scope its connection has, whatever the config
76
+ * says now. The admin gate applies as it does on create.
77
+ */
78
+ const ownershipForScope = ({ scope, user }) => {
79
+ const ctx = contextFor(user);
80
+ return {
81
+ ...resolveOwnerForScope(scope, ctx),
82
+ legacyOrgId: ctx.legacyOrgId,
83
+ source: 'fixed',
84
+ };
85
+ };
86
+
87
+ /** Whether `user` may load this stored entity for use (same clauses as the list). */
88
+ const canUseEntity = (user, entity) =>
89
+ describeRecord(entity, contextFor(user), 'entity') !== null;
90
+
91
+ /**
92
+ * What `user` may do with a record owned by `ownerId` that has no
93
+ * organization of its own (a credential): manage, or null when not theirs.
94
+ */
95
+ const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
96
+
97
+ return Object.freeze({
98
+ individualAcross,
99
+ orgAdminRoles,
100
+ accessToOwnedBy,
101
+ accessContext: contextFor,
102
+ resolveScope: resolveEntityScope,
103
+ resolveOwnership,
104
+ ownershipForScope,
105
+ canUseEntity,
106
+ });
107
+ }
108
+
109
+ /** The policy of an app that configures nothing; what a use case gets by default. */
110
+ const DEFAULT_SCOPE_POLICY = createScopePolicy();
111
+
112
+ module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY };