@friggframework/core 2.0.0--canary.684.0da024b.0 → 2.0.0--canary.685.a13fdab.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.
package/CLAUDE.md CHANGED
@@ -502,6 +502,29 @@ The single source of the max receive count is
502
502
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
503
503
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
504
504
 
505
+ ### Management API v2 notes (`/management-api`)
506
+
507
+ - Handlers in `management-api/v2/handlers/` call use cases only; dependencies are wired in
508
+ `management-api/v2/dependencies.js`. Errors are `{ error: { code, message, details? } }`.
509
+ - `appDefinition.managementApi` is resolved by `resolveManagementApiConfig`, which `loadAppDefinition`
510
+ also calls so a bad value stops the app at load. Numeric proxy limits fall back to their defaults;
511
+ `proxy.scopes` (ADR-056 §3.5, default `['individual']`) never does: an unknown scope, `'global'`, a
512
+ non-list, an empty list or a repeated scope throws `ManagementApiConfigError`
513
+ (`MANAGEMENT_API_CONFIG_INVALID`).
514
+ - **Entity proxy** (`ExecuteEntityProxyRequest`): visibility in the active organization comes from
515
+ `GetEntityModuleForUser` (404), then `assertProxyable` reads the same `{ scope, access }` description
516
+ the v2 lists return: `grantsUse(access)` (`use` or `manage`, from `modules/scope-visibility.js`) or 403
517
+ `PROXY_NOT_ALLOWED` `reason: 'access'`, and the scope must be in `proxy.scopes` or 403 `reason: 'scope'`.
518
+ Do not add a second ownership rule for the proxy; change the description instead.
519
+ - **Not visible is 404, and so is missing.** Module repositories throw `entityNotFoundError(id)`
520
+ (`modules/repositories/entity-not-found.js`) for a missing row and use cases match it with
521
+ `isEntityNotFound`. A 500 for a missing id next to a 404 for another tenant's id tells a caller which
522
+ ids exist. A test double that resolves `findEntityById` to `null` hides this: no adapter does that.
523
+ - **Tenant isolation on real PostgreSQL**: `management-api/v2/tenant-isolation.postgres.test.js`, run with
524
+ `FRIGG_TEST_POSTGRES_URL=<a database it may wipe>`. It applies the migrations, truncates every table
525
+ before each test, and drives real adopter-JWT auth through the real router and Prisma repositories.
526
+ Without the variable it is skipped (and says so), not passed. See `docs/guides/entity-scopes.md`.
527
+
505
528
  ### 8. Error Handling (`/errors`)
506
529
 
507
530
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -12,6 +12,9 @@ const {
12
12
  getEntityTypeDefinitions,
13
13
  } = require('../modules/entity-type-registry');
14
14
  const { resolveOwnershipConfig } = require('../modules/entity-scope');
15
+ const {
16
+ resolveManagementApiConfig,
17
+ } = require('../management-api/management-api-config');
15
18
 
16
19
  // Handlers without a database never load the encryption registry, so the
17
20
  // credential leaf keys are registered for redaction here too.
@@ -39,6 +42,7 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
39
42
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
40
43
  * @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
41
44
  * @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
45
+ * @throws {ManagementApiConfigError} Throws if `managementApi.proxy.scopes` is invalid (ADR-056 §3.5).
42
46
  * @example
43
47
  * const { integrations, userConfig, telemetry } = loadAppDefinition();
44
48
  * console.log(`Found ${integrations.length} integrations`);
@@ -84,6 +88,11 @@ function loadAppDefinition() {
84
88
  // individual entity stops the app at load.
85
89
  const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry);
86
90
 
91
+ // And for the Management API: `managementApi.proxy.scopes` decides whose
92
+ // credentials the entity proxy may act through (ADR-056 §3.5), so an
93
+ // unknown scope stops the app here rather than being read as a default.
94
+ resolveManagementApiConfig(managementApi);
95
+
87
96
  registerCredentialLogKeys(appDefinition, moduleDefinitions);
88
97
 
89
98
  // Degrade consistently: an invalid telemetry block must never take down a
package/index.js CHANGED
@@ -113,6 +113,9 @@ const {
113
113
  listBindingScopes,
114
114
  OwnershipConfigError,
115
115
  } = require('./modules/index');
116
+ const {
117
+ ManagementApiConfigError,
118
+ } = require('./management-api/management-api-config');
116
119
  const application = require('./application');
117
120
  const utils = require('./utils');
118
121
 
@@ -229,6 +232,7 @@ module.exports = {
229
232
  resolveScope,
230
233
  listBindingScopes,
231
234
  OwnershipConfigError,
235
+ ManagementApiConfigError,
232
236
  // queues
233
237
  QueuerUtil,
234
238
 
@@ -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/**' }] },
@@ -17,10 +18,36 @@
17
18
  * }
18
19
  * ```
19
20
  *
20
- * TODO(F6): `proxy.scopes` (ADR-056 §3.5) is not read yet; see
21
- * ExecuteEntityProxyRequest.
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.
22
25
  */
23
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.
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]);
50
+
24
51
  // ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
25
52
  // at 6MB, so the timeout and response defaults are also ceilings: an app may
26
53
  // lower them, not raise them.
@@ -47,9 +74,41 @@ function positiveOr(value, fallback, ceiling) {
47
74
  return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
48
75
  }
49
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
+
50
108
  /**
51
109
  * @param {Object} [managementApi] - `appDefinition.managementApi`
52
- * @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`
53
112
  */
54
113
  function resolveManagementApiConfig(managementApi) {
55
114
  const config =
@@ -74,6 +133,7 @@ function resolveManagementApiConfig(managementApi) {
74
133
  proxy.maxResponseBytes,
75
134
  DEFAULT_PROXY_LIMITS.maxResponseBytes
76
135
  ),
136
+ scopes: resolveProxyScopes(proxy.scopes),
77
137
  modules:
78
138
  proxy.modules && typeof proxy.modules === 'object'
79
139
  ? proxy.modules
@@ -82,4 +142,10 @@ function resolveManagementApiConfig(managementApi) {
82
142
  };
83
143
  }
84
144
 
85
- 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
+ };
@@ -165,14 +165,14 @@ const V2_OPERATIONS = {
165
165
  proxyEntityRequest: {
166
166
  summary: "Call the entity's API through Frigg (beta)",
167
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. 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.",
169
169
  requestBody: body(ref('ProxyRequest')),
170
170
  responses: {
171
171
  200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
172
172
  400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
173
173
  401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
174
- 403: ok('PROXY_NOT_ALLOWED', ref('ProxyError')),
175
- 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')),
176
176
  413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
177
177
  415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
178
178
  429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The error `findEntityById` throws for an id with no row, and the one
3
+ * predicate that recognises it (as integration-not-found.js does for
4
+ * integrations). The adapters build the error here and use cases match it
5
+ * here, so a missing entity can be told from any other failure without
6
+ * widening a regex.
7
+ *
8
+ * Why it matters: a use case that answers 404 for another tenant's entity must
9
+ * answer the same 404 for an id that does not exist. Otherwise the status code
10
+ * says which ids exist (ADR-052 §3, ADR-056 §3.6).
11
+ */
12
+ const MESSAGE = /^Entity .+ not found$/;
13
+
14
+ const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
15
+
16
+ const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
17
+
18
+ module.exports = { entityNotFoundError, isEntityNotFound };
@@ -10,6 +10,7 @@ const {
10
10
  deleteOne,
11
11
  } = require('../../database/documentdb-utils');
12
12
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
13
+ const { entityNotFoundError } = require('./entity-not-found');
13
14
  const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
14
15
 
15
16
  /**
@@ -34,11 +35,11 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
34
35
  async findEntityById(entityId) {
35
36
  const objectId = toObjectId(entityId);
36
37
  if (!objectId) {
37
- throw new Error(`Entity ${entityId} not found`);
38
+ throw entityNotFoundError(entityId);
38
39
  }
39
40
  const doc = await findOne(this.prisma, 'Entity', { _id: objectId });
40
41
  if (!doc) {
41
- throw new Error(`Entity ${entityId} not found`);
42
+ throw entityNotFoundError(entityId);
42
43
  }
43
44
  const credential = await this._fetchCredential(doc.credentialId);
44
45
  return this._mapEntity(doc, credential);
@@ -1,6 +1,7 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { organizationWhere } = require('../../database/organization-where');
3
3
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
4
+ const { entityNotFoundError } = require('./entity-not-found');
4
5
 
5
6
  /**
6
7
  * MongoDB Module Repository Adapter
@@ -111,7 +112,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
111
112
  });
112
113
 
113
114
  if (!entity) {
114
- throw new Error(`Entity ${entityId} not found`);
115
+ throw entityNotFoundError(entityId);
115
116
  }
116
117
 
117
118
  const credential = await this._fetchCredential(entity.credentialId);
@@ -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
@@ -144,7 +145,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
144
145
  });
145
146
 
146
147
  if (!entity) {
147
- throw new Error(`Entity ${entityId} not found`);
148
+ throw entityNotFoundError(entityId);
148
149
  }
149
150
 
150
151
  const credential = await this._fetchCredential(entity.credentialId);
@@ -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 {
@@ -175,6 +175,13 @@ const accessFor = (scope, ctx) => {
175
175
  return ACCESS.USE;
176
176
  };
177
177
 
178
+ /**
179
+ * Whether an access level lets the caller use a record: bind it, proxy through
180
+ * it (§3.4). `manage` includes `use`; `view` (metadata only) does not, and
181
+ * neither does anything unrecognised.
182
+ */
183
+ const grantsUse = (access) => access === ACCESS.USE || access === ACCESS.MANAGE;
184
+
178
185
  /**
179
186
  * What the caller may do with a record owned by `ownerId` that carries no
180
187
  * organization of its own (a credential): manage the person's own, manage the
@@ -234,6 +241,7 @@ module.exports = {
234
241
  matchesClause,
235
242
  scopeOf,
236
243
  describeRecord,
244
+ grantsUse,
237
245
  LIST_OWNERS,
238
246
  matchesListFilters,
239
247
  };
@@ -2,6 +2,8 @@ const Boom = require('@hapi/boom');
2
2
  const { getLogger } = require('../../logs');
3
3
  const { getTelemetry } = require('../../telemetry/telemetry-runtime');
4
4
  const { ProxyRequestError } = require('../proxy/proxy-errors');
5
+ const { SCOPES } = require('../scope-resolution');
6
+ const { grantsUse } = require('../scope-visibility');
5
7
  const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
6
8
  const {
7
9
  validateProxyRequest,
@@ -55,9 +57,11 @@ const authHeaderNamesOf = (api) =>
55
57
  * through its module with the stored credential, inside the limits the app
56
58
  * and the module set.
57
59
  *
58
- * Order of checks: request shape and size, path rules, entity ownership
59
- * (404), the module's allow-list (403, deny by default), credential
60
- * readiness (401), then the call through Requester._proxyRequest. Every call,
60
+ * Order of checks: request shape and size, path rules, entity visibility in
61
+ * the active organization (404), `use` access (403), the entity's scope
62
+ * against `managementApi.proxy.scopes` (403), the module's allow-list (403,
63
+ * deny by default), credential readiness (401), then the call through
64
+ * Requester._proxyRequest. Every call,
61
65
  * allowed or denied, writes one audit record and one usage count. Bodies,
62
66
  * headers, query values and the raw path are never logged.
63
67
  */
@@ -98,24 +102,14 @@ class ExecuteEntityProxyRequest {
98
102
  audit.requestBytes = validated.requestBytes;
99
103
  const path = normalizeProxyPath(validated.path);
100
104
 
101
- // Access is `use` in the active organization (ADR-056 §3.5, §3.6): the
102
- // owner's entity there, or the organization's. A `global` entity is
103
- // never the tenant's.
105
+ // Visible in the active organization, or 404 (ADR-056 §3.6): the
106
+ // same clauses the v2 lists use, so a missing entity, another
107
+ // person's and one paired with another organization look alike.
104
108
  const { entity, definition, module } =
105
109
  await this.getEntityModuleForUser.execute(entityId, user);
106
110
  audit.module = definition.moduleName;
107
- // Narrowest by default (§3.5): proxying through a shared credential
108
- // lets any member act on the organization's account, so only an
109
- // individual entity may be proxied until `managementApi.proxy.scopes`
110
- // lets an app opt in to `organization`.
111
- // TODO(F6): read proxy.scopes here instead of refusing outright.
112
- if (this.getEntityModuleForUser.describe(entity, user)?.scope !== 'individual') {
113
- throw new ProxyRequestError(
114
- 'PROXY_NOT_ALLOWED',
115
- 'The entity proxy only accepts individual entities; organization-level entities are not proxied',
116
- { module: definition.moduleName, reason: 'scope' }
117
- );
118
- }
111
+ this.assertProxyable(entityId, entity, definition, user);
112
+
119
113
  const credentialRef = entity.credential;
120
114
  const credentialId =
121
115
  credentialRef && typeof credentialRef === 'object'
@@ -217,6 +211,44 @@ class ExecuteEntityProxyRequest {
217
211
  }
218
212
  }
219
213
 
214
+ /**
215
+ * ADR-056 §3.5: the caller needs `use` access to the entity in the active
216
+ * organization, and the entity's scope must be one the app lets the proxy
217
+ * accept. `access` and `scope` come from the description the v2 lists
218
+ * return (GetEntityModuleForUser.describe), never from a second rule.
219
+ *
220
+ * Both refusals are 403 PROXY_NOT_ALLOWED: the caller can already see the
221
+ * entity, so saying it exists leaks nothing. `details.reason` tells them
222
+ * apart ('access' or 'scope').
223
+ */
224
+ assertProxyable(entityId, entity, definition, user) {
225
+ const tenancy = this.getEntityModuleForUser.describe(entity, user);
226
+ if (!tenancy) {
227
+ // Not visible after all: the same 404 as a missing entity.
228
+ throw Boom.notFound(`Entity ${entityId} not found`, {
229
+ code: 'ENTITY_NOT_FOUND',
230
+ });
231
+ }
232
+ if (!grantsUse(tenancy.access)) {
233
+ throw new ProxyRequestError(
234
+ 'PROXY_NOT_ALLOWED',
235
+ 'Proxying through an entity needs use access to it; you can only view this one',
236
+ { module: definition.moduleName, reason: 'access' }
237
+ );
238
+ }
239
+ // Narrowest by default (§3.5): with no setting only individual
240
+ // entities are proxied, because proxying through a shared credential
241
+ // lets any member act on the organization's account.
242
+ const acceptedScopes = this.proxyConfig.scopes ?? [SCOPES.INDIVIDUAL];
243
+ if (!acceptedScopes.includes(tenancy.scope)) {
244
+ throw new ProxyRequestError(
245
+ 'PROXY_NOT_ALLOWED',
246
+ `The entity proxy does not accept ${tenancy.scope} entities in this app`,
247
+ { module: definition.moduleName, reason: 'scope', scope: tenancy.scope }
248
+ );
249
+ }
250
+ }
251
+
220
252
  /** A module may pin its proxy base URL (string, or a function of the api). */
221
253
  resolveBaseUrl(definition, api) {
222
254
  const declared = definition.proxy?.baseUrl;
@@ -1,6 +1,7 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { describeRecord } = require('../scope-visibility');
3
3
  const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
4
+ const { isEntityNotFound } = require('../repositories/entity-not-found');
4
5
 
5
6
  /**
6
7
  * True when `user` may act on records owned by `ownerId`. Uses the User
@@ -79,8 +80,14 @@ class GetEntityModuleForUser {
79
80
  try {
80
81
  entity = await this.moduleRepository.findEntityById(entityId);
81
82
  } catch (error) {
82
- // A malformed id (e.g. not an integer on Postgres) is "not found".
83
- if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
83
+ // Every adapter throws for a missing row, and a malformed id (e.g.
84
+ // not an integer on Postgres) is "not found" too. Both must be the
85
+ // 404 another tenant's entity gets, or the status says which ids
86
+ // exist. Anything else is a real failure.
87
+ if (
88
+ isEntityNotFound(error) ||
89
+ /Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
90
+ ) {
84
91
  entity = null;
85
92
  } else {
86
93
  throw error;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.684.0da024b.0",
4
+ "version": "2.0.0--canary.685.a13fdab.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.684.0da024b.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.684.0da024b.0",
53
- "@friggframework/test": "2.0.0--canary.684.0da024b.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.685.a13fdab.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.685.a13fdab.0",
53
+ "@friggframework/test": "2.0.0--canary.685.a13fdab.0",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "0da024bd724c987e4bc839ab0c302580333466a1"
93
+ "gitHead": "a13fdab0900eaf5edb30f284f6b598c8e20686d8"
94
94
  }
@@ -68,12 +68,49 @@ declare module "@friggframework/core" {
68
68
  owner?: "derive" | "individual" | "organization";
69
69
  }
70
70
 
71
+ /** One operation a module or the app lets the entity proxy forward (ADR-052). */
72
+ export interface ProxyAllowRule {
73
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "*";
74
+ /** Relative to the module's base URL. `:param` is one segment; a trailing `/**` is a subtree. */
75
+ path: string;
76
+ }
77
+
78
+ /** `appDefinition.managementApi.proxy`: the entity proxy, POST /api/v2/entities/:id/proxy (ADR-052). */
79
+ export interface ManagementApiProxyConfig {
80
+ /** Mount the route. Default false. */
81
+ enable?: boolean;
82
+ /** Upstream timeout in ms; can only be lowered from 25000. */
83
+ timeoutMs?: number;
84
+ /** Largest request body in bytes; default 1 MiB, at most 10 MiB. */
85
+ maxRequestBytes?: number;
86
+ /** Largest response body in bytes; can only be lowered from 5 MiB. */
87
+ maxResponseBytes?: number;
88
+ /**
89
+ * Entity scopes the proxy accepts (ADR-056 §3.5). Default `['individual']`.
90
+ * Add `'organization'` to let a member with use access proxy through the
91
+ * organization's shared credential. `global` is never accepted. An unknown,
92
+ * empty or repeated value throws ManagementApiConfigError at load.
93
+ */
94
+ scopes?: Array<Exclude<EntityScope, "global">>;
95
+ /** Per-module allow-list override; `'module'` uses the module's own list. */
96
+ modules?: Record<string, { allow: "module" | ProxyAllowRule[] }>;
97
+ }
98
+
99
+ /** `appDefinition.managementApi` (ADR-052, ADR-053). */
100
+ export interface ManagementApiConfig {
101
+ /** Serve the deprecated v1 routes. Default true. */
102
+ v1?: boolean;
103
+ proxy?: ManagementApiProxyConfig;
104
+ }
105
+
71
106
  export interface AppDefinition {
72
107
  integrations: any[];
73
108
  /** App-level entity type registry (ADR-059). */
74
109
  entities?: AppDefinitionEntities;
75
110
  /** Who owns what a person connects (ADR-056). */
76
111
  ownership?: AppOwnership;
112
+ /** Management API versions and the entity proxy (ADR-052, ADR-053, ADR-056 §3.5). */
113
+ managementApi?: ManagementApiConfig;
77
114
  [key: string]: unknown;
78
115
  }
79
116
 
@@ -138,6 +175,11 @@ declare module "@friggframework/core" {
138
175
  appDefinition?: Pick<AppDefinition, "entities" | "integrations">
139
176
  ): EntityTypeRegistry;
140
177
 
178
+ /** Invalid `managementApi` setting (today: `proxy.scopes`), thrown at app-definition load. */
179
+ export class ManagementApiConfigError extends Error {
180
+ code: "MANAGEMENT_API_CONFIG_INVALID";
181
+ }
182
+
141
183
  /**
142
184
  * @deprecated ADR-059. Use `getEntityTypeRegistry(appDefinition)` and read
143
185
  * each entry's `definition`. Returns the registry's definitions for these