@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.
- package/CHANGELOG.md +0 -11
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +99 -47
- package/database/encryption/field-encryption-service.js +5 -69
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +103 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/health.js +4 -2
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +63 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +115 -73
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +71 -2
- package/management-api/meta-router.js +21 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/route-registry.js +123 -6
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +40 -13
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +65 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +61 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +139 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
toObjectId,
|
|
4
|
+
fromObjectId,
|
|
5
|
+
findOne,
|
|
6
|
+
findManyDrained,
|
|
7
|
+
updateOne,
|
|
8
|
+
} = require('../../database/documentdb-utils');
|
|
9
|
+
const { DUPLICATE_KEY_CODE } = require('../../database/unique-violation');
|
|
10
|
+
const {
|
|
11
|
+
OrganizationMembershipRepositoryInterface,
|
|
12
|
+
} = require('./organization-membership-repository-interface');
|
|
13
|
+
const { toMembership } = require('./organization-membership-record');
|
|
14
|
+
const { DEFAULT_ORG_ROLE } = require('../membership');
|
|
15
|
+
|
|
16
|
+
const COLLECTION = 'OrganizationMembership';
|
|
17
|
+
|
|
18
|
+
const parseDate = (value) => {
|
|
19
|
+
if (!value) return undefined;
|
|
20
|
+
const date = new Date(value.$date ?? value);
|
|
21
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Organization membership persistence for DocumentDB through raw commands.
|
|
26
|
+
* Nothing in a membership is encrypted.
|
|
27
|
+
*/
|
|
28
|
+
class OrganizationMembershipRepositoryDocumentDB extends OrganizationMembershipRepositoryInterface {
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
this.prisma = prisma;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findMembership(individualUserId, organizationUserId) {
|
|
35
|
+
const doc = await this._findDoc(individualUserId, organizationUserId);
|
|
36
|
+
return doc ? this._toMembership(doc) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async addMembership(individualUserId, organizationUserId, options = {}) {
|
|
40
|
+
// $setOnInsert: an existing membership is left exactly as it is.
|
|
41
|
+
const result = await updateOne(
|
|
42
|
+
this.prisma,
|
|
43
|
+
COLLECTION,
|
|
44
|
+
this._pair(individualUserId, organizationUserId),
|
|
45
|
+
{
|
|
46
|
+
$setOnInsert: {
|
|
47
|
+
orgRole: options.orgRole ?? DEFAULT_ORG_ROLE,
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{ upsert: true }
|
|
52
|
+
);
|
|
53
|
+
// The loser of a first-login race can hit the unique pair; the row
|
|
54
|
+
// exists then, so only other write errors are failures.
|
|
55
|
+
const writeError = result?.writeErrors?.[0];
|
|
56
|
+
if (writeError && writeError.code !== DUPLICATE_KEY_CODE) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Adding organization membership failed: ${writeError.errmsg} (code: ${writeError.code})`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const doc = await this._findDoc(individualUserId, organizationUserId);
|
|
62
|
+
if (!doc) {
|
|
63
|
+
throw new Error('Organization membership was not stored');
|
|
64
|
+
}
|
|
65
|
+
return this._toMembership(doc);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async listMembershipsForOrganization(organizationUserId) {
|
|
69
|
+
// Drained: findMany returns only the first cursor batch (~101 documents),
|
|
70
|
+
// which would silently drop members of a large organization.
|
|
71
|
+
const docs = await findManyDrained(
|
|
72
|
+
this.prisma,
|
|
73
|
+
COLLECTION,
|
|
74
|
+
{ organizationUserId: toObjectId(organizationUserId) },
|
|
75
|
+
{ sort: { createdAt: 1 } }
|
|
76
|
+
);
|
|
77
|
+
return docs.map((doc) => this._toMembership(doc));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_pair(individualUserId, organizationUserId) {
|
|
81
|
+
return {
|
|
82
|
+
individualUserId: toObjectId(individualUserId),
|
|
83
|
+
organizationUserId: toObjectId(organizationUserId),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _findDoc(individualUserId, organizationUserId) {
|
|
88
|
+
return findOne(
|
|
89
|
+
this.prisma,
|
|
90
|
+
COLLECTION,
|
|
91
|
+
this._pair(individualUserId, organizationUserId)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_toMembership(doc) {
|
|
96
|
+
return toMembership({
|
|
97
|
+
id: fromObjectId(doc._id),
|
|
98
|
+
individualUserId: fromObjectId(doc.individualUserId),
|
|
99
|
+
organizationUserId: fromObjectId(doc.organizationUserId),
|
|
100
|
+
orgRole: doc.orgRole ?? DEFAULT_ORG_ROLE,
|
|
101
|
+
createdAt: parseDate(doc.createdAt),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { OrganizationMembershipRepositoryDocumentDB };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const databaseConfig = require('../../database/config');
|
|
2
|
+
const {
|
|
3
|
+
OrganizationMembershipRepositoryMongo,
|
|
4
|
+
} = require('./organization-membership-repository-mongo');
|
|
5
|
+
const {
|
|
6
|
+
OrganizationMembershipRepositoryPrisma,
|
|
7
|
+
} = require('./organization-membership-repository-prisma');
|
|
8
|
+
const {
|
|
9
|
+
OrganizationMembershipRepositoryPostgres,
|
|
10
|
+
} = require('./organization-membership-repository-postgres');
|
|
11
|
+
const {
|
|
12
|
+
OrganizationMembershipRepositoryDocumentDB,
|
|
13
|
+
} = require('./organization-membership-repository-documentdb');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Membership repository for the configured database. Every method returns
|
|
17
|
+
* String ids whatever the database stores.
|
|
18
|
+
*
|
|
19
|
+
* @returns {import('./organization-membership-repository-interface').OrganizationMembershipRepositoryInterface}
|
|
20
|
+
*/
|
|
21
|
+
function createOrganizationMembershipRepository() {
|
|
22
|
+
switch (databaseConfig.DB_TYPE) {
|
|
23
|
+
case 'mongodb':
|
|
24
|
+
return new OrganizationMembershipRepositoryMongo();
|
|
25
|
+
case 'postgresql':
|
|
26
|
+
return new OrganizationMembershipRepositoryPostgres();
|
|
27
|
+
case 'documentdb':
|
|
28
|
+
return new OrganizationMembershipRepositoryDocumentDB();
|
|
29
|
+
default:
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Unsupported DB_TYPE: ${databaseConfig.DB_TYPE}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
createOrganizationMembershipRepository,
|
|
38
|
+
OrganizationMembershipRepositoryPrisma,
|
|
39
|
+
OrganizationMembershipRepositoryMongo,
|
|
40
|
+
OrganizationMembershipRepositoryPostgres,
|
|
41
|
+
OrganizationMembershipRepositoryDocumentDB,
|
|
42
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization Membership Repository Interface (ADR-056 §3.6).
|
|
3
|
+
*
|
|
4
|
+
* Atomic persistence of "this person belongs to that organization": a row or
|
|
5
|
+
* nothing. No policy and no reading of `IndividualUser.organizationId`: the
|
|
6
|
+
* legacy pointer counts as an implicit membership, and that rule lives in the
|
|
7
|
+
* domain (`user/membership.js`), applied by the use cases.
|
|
8
|
+
*
|
|
9
|
+
* Every method returns String ids whatever the database stores.
|
|
10
|
+
*
|
|
11
|
+
* @abstract
|
|
12
|
+
*/
|
|
13
|
+
class OrganizationMembershipRepositoryInterface {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string|number} individualUserId
|
|
16
|
+
* @param {string|number} organizationUserId
|
|
17
|
+
* @returns {Promise<Object|null>} The stored membership, or null.
|
|
18
|
+
* @abstract
|
|
19
|
+
*/
|
|
20
|
+
async findMembership(individualUserId, organizationUserId) {
|
|
21
|
+
throw new Error('Method findMembership must be implemented by subclass');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Adds a membership if there is none; never changes an existing one.
|
|
26
|
+
* Idempotent, and safe against a concurrent identical call: both resolve
|
|
27
|
+
* to the same row.
|
|
28
|
+
*
|
|
29
|
+
* @param {string|number} individualUserId
|
|
30
|
+
* @param {string|number} organizationUserId
|
|
31
|
+
* @param {Object} [options]
|
|
32
|
+
* @param {string|null} [options.orgRole] Stored only when the row is
|
|
33
|
+
* created; defaults to `MEMBER`.
|
|
34
|
+
* @returns {Promise<Object>} The stored membership.
|
|
35
|
+
* @abstract
|
|
36
|
+
*/
|
|
37
|
+
async addMembership(individualUserId, organizationUserId, options) {
|
|
38
|
+
throw new Error('Method addMembership must be implemented by subclass');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stored memberships of an organization, oldest first. Rows only: people
|
|
43
|
+
* who are in the organization through the legacy pointer alone are not
|
|
44
|
+
* here.
|
|
45
|
+
*
|
|
46
|
+
* @param {string|number} organizationUserId
|
|
47
|
+
* @returns {Promise<Object[]>}
|
|
48
|
+
* @abstract
|
|
49
|
+
*/
|
|
50
|
+
async listMembershipsForOrganization(organizationUserId) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Method listMembershipsForOrganization must be implemented by subclass'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { OrganizationMembershipRepositoryInterface };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const {
|
|
2
|
+
OrganizationMembershipRepositoryPrisma,
|
|
3
|
+
} = require('./organization-membership-repository-prisma');
|
|
4
|
+
|
|
5
|
+
/** Organization membership persistence for MongoDB: ids are ObjectId strings, used as they are. */
|
|
6
|
+
class OrganizationMembershipRepositoryMongo extends OrganizationMembershipRepositoryPrisma {
|
|
7
|
+
/** @protected */
|
|
8
|
+
_toDbId(id) {
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { OrganizationMembershipRepositoryMongo };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const {
|
|
2
|
+
OrganizationMembershipRepositoryPrisma,
|
|
3
|
+
} = require('./organization-membership-repository-prisma');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Organization membership persistence for PostgreSQL. The database keys are
|
|
7
|
+
* Int, the application's are strings, so ids are converted in and the record
|
|
8
|
+
* mapper stringifies them out.
|
|
9
|
+
*/
|
|
10
|
+
class OrganizationMembershipRepositoryPostgres extends OrganizationMembershipRepositoryPrisma {
|
|
11
|
+
/**
|
|
12
|
+
* Strict: digits only. `parseInt` would turn '12abc' into 12 and query
|
|
13
|
+
* the wrong person.
|
|
14
|
+
* @protected
|
|
15
|
+
*/
|
|
16
|
+
_toDbId(id) {
|
|
17
|
+
const text = typeof id === 'number' ? String(id) : id;
|
|
18
|
+
if (typeof text !== 'string' || !/^\d+$/.test(text)) {
|
|
19
|
+
throw new Error(`Invalid ID: ${id} cannot be converted to integer`);
|
|
20
|
+
}
|
|
21
|
+
const parsed = Number(text);
|
|
22
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
23
|
+
throw new Error(`Invalid ID: ${id} cannot be converted to integer`);
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { OrganizationMembershipRepositoryPostgres };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const { isUniqueViolation } = require('../../database/unique-violation');
|
|
3
|
+
const {
|
|
4
|
+
OrganizationMembershipRepositoryInterface,
|
|
5
|
+
} = require('./organization-membership-repository-interface');
|
|
6
|
+
const { toMembership } = require('./organization-membership-record');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Organization membership persistence through the Prisma client. Both schemas
|
|
10
|
+
* declare the same model; they differ only in the id type, which the subclass
|
|
11
|
+
* supplies through `_toDbId` (MongoDB: ObjectId strings, PostgreSQL: Int).
|
|
12
|
+
*
|
|
13
|
+
* @abstract
|
|
14
|
+
*/
|
|
15
|
+
class OrganizationMembershipRepositoryPrisma extends OrganizationMembershipRepositoryInterface {
|
|
16
|
+
constructor(prismaClient = prisma) {
|
|
17
|
+
super();
|
|
18
|
+
this.prisma = prismaClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @protected
|
|
23
|
+
* @abstract
|
|
24
|
+
*/
|
|
25
|
+
_toDbId(id) {
|
|
26
|
+
throw new Error('Method _toDbId must be implemented by subclass');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async findMembership(individualUserId, organizationUserId) {
|
|
30
|
+
const row = await this._findRow(individualUserId, organizationUserId);
|
|
31
|
+
return row ? toMembership(row) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async addMembership(individualUserId, organizationUserId, options = {}) {
|
|
35
|
+
const pair = this._pair(individualUserId, organizationUserId);
|
|
36
|
+
try {
|
|
37
|
+
const row = await this.prisma.organizationMembership.upsert({
|
|
38
|
+
where: { individualUserId_organizationUserId: pair },
|
|
39
|
+
// `undefined` lets the column default (MEMBER) apply.
|
|
40
|
+
create: { ...pair, orgRole: options.orgRole ?? undefined },
|
|
41
|
+
update: {},
|
|
42
|
+
});
|
|
43
|
+
return toMembership(row);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// Two first logins can both miss the row and both try to create it.
|
|
46
|
+
// The loser's insert hits the unique pair; the row is there now.
|
|
47
|
+
if (!isUniqueViolation(error)) throw error;
|
|
48
|
+
const row = await this._findRow(individualUserId, organizationUserId);
|
|
49
|
+
if (!row) throw error;
|
|
50
|
+
return toMembership(row);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async listMembershipsForOrganization(organizationUserId) {
|
|
55
|
+
const rows = await this.prisma.organizationMembership.findMany({
|
|
56
|
+
where: { organizationUserId: this._toDbId(organizationUserId) },
|
|
57
|
+
orderBy: { createdAt: 'asc' },
|
|
58
|
+
});
|
|
59
|
+
return rows.map(toMembership);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_pair(individualUserId, organizationUserId) {
|
|
63
|
+
return {
|
|
64
|
+
individualUserId: this._toDbId(individualUserId),
|
|
65
|
+
organizationUserId: this._toDbId(organizationUserId),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _findRow(individualUserId, organizationUserId) {
|
|
70
|
+
return this.prisma.organizationMembership.findUnique({
|
|
71
|
+
where: {
|
|
72
|
+
individualUserId_organizationUserId: this._pair(
|
|
73
|
+
individualUserId,
|
|
74
|
+
organizationUserId
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { OrganizationMembershipRepositoryPrisma };
|
|
@@ -4,7 +4,7 @@ const {
|
|
|
4
4
|
toObjectId,
|
|
5
5
|
fromObjectId,
|
|
6
6
|
findOne,
|
|
7
|
-
|
|
7
|
+
findManyDrained,
|
|
8
8
|
insertOne,
|
|
9
9
|
updateOne,
|
|
10
10
|
deleteOne,
|
|
@@ -72,7 +72,9 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async findIndividualUsersByOrganizationId(organizationUserId) {
|
|
75
|
-
|
|
75
|
+
// Drained: findMany returns only the first cursor batch, which would
|
|
76
|
+
// drop people from a large organization.
|
|
77
|
+
const docs = await findManyDrained(this.prisma, 'User', {
|
|
76
78
|
organizationId: toObjectId(organizationUserId),
|
|
77
79
|
type: 'INDIVIDUAL',
|
|
78
80
|
});
|
|
@@ -421,6 +423,29 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
421
423
|
);
|
|
422
424
|
return this._mapUser(decrypted);
|
|
423
425
|
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Set the legacy organizationId only if it is still null or absent (see the
|
|
429
|
+
* interface): the filter carries the condition, so it is one atomic update.
|
|
430
|
+
* @param {string} individualUserId
|
|
431
|
+
* @param {string} organizationUserId
|
|
432
|
+
* @returns {Promise<Object|null>} The individual user as stored afterwards
|
|
433
|
+
*/
|
|
434
|
+
async linkIndividualToOrganizationIfUnset(individualUserId, organizationUserId) {
|
|
435
|
+
await updateOne(
|
|
436
|
+
this.prisma,
|
|
437
|
+
'User',
|
|
438
|
+
{
|
|
439
|
+
_id: toObjectId(individualUserId),
|
|
440
|
+
type: 'INDIVIDUAL',
|
|
441
|
+
// Raw MongoDB query (not Prisma): `{ field: null }` matches both a
|
|
442
|
+
// null value and a missing field, so no `$exists` branch is needed.
|
|
443
|
+
organizationId: null,
|
|
444
|
+
},
|
|
445
|
+
{ $set: { organizationId: toObjectId(organizationUserId) } }
|
|
446
|
+
);
|
|
447
|
+
return this.findIndividualUserById(individualUserId);
|
|
448
|
+
}
|
|
424
449
|
}
|
|
425
450
|
|
|
426
451
|
module.exports = { UserRepositoryDocumentDB };
|
|
@@ -209,6 +209,24 @@ class UserRepositoryInterface {
|
|
|
209
209
|
'Method linkIndividualToOrganization must be implemented by subclass'
|
|
210
210
|
);
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Set an individual user's legacy `organizationId` pointer only if it is
|
|
215
|
+
* not set yet (their first link), decided by the database in the same
|
|
216
|
+
* statement so two concurrent first logins cannot overwrite each other.
|
|
217
|
+
* Never changes an existing pointer (ADR-056 §3.6).
|
|
218
|
+
*
|
|
219
|
+
* @param {string|number} individualUserId - Individual user ID
|
|
220
|
+
* @param {string|number} organizationUserId - Organization user ID
|
|
221
|
+
* @returns {Promise<Object|null>} The individual user as stored afterwards
|
|
222
|
+
* (null if there is no such individual)
|
|
223
|
+
* @abstract
|
|
224
|
+
*/
|
|
225
|
+
async linkIndividualToOrganizationIfUnset(individualUserId, organizationUserId) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'Method linkIndividualToOrganizationIfUnset must be implemented by subclass'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
212
230
|
}
|
|
213
231
|
|
|
214
232
|
module.exports = { UserRepositoryInterface };
|
|
@@ -318,6 +318,30 @@ class UserRepositoryMongo extends UserRepositoryInterface {
|
|
|
318
318
|
},
|
|
319
319
|
});
|
|
320
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Set the legacy organizationId only if it is still null (see the interface).
|
|
324
|
+
* @param {string} individualUserId
|
|
325
|
+
* @param {string} organizationUserId
|
|
326
|
+
* @returns {Promise<Object|null>} The individual user as stored afterwards
|
|
327
|
+
*/
|
|
328
|
+
async linkIndividualToOrganizationIfUnset(individualUserId, organizationUserId) {
|
|
329
|
+
await this.prisma.user.updateMany({
|
|
330
|
+
where: {
|
|
331
|
+
id: individualUserId,
|
|
332
|
+
type: 'INDIVIDUAL',
|
|
333
|
+
// Prisma on MongoDB: `null` matches only an explicit null, and
|
|
334
|
+
// `isSet: false` only a missing field. An individual created
|
|
335
|
+
// without the field is the common case, so match both.
|
|
336
|
+
OR: [
|
|
337
|
+
{ organizationId: null },
|
|
338
|
+
{ organizationId: { isSet: false } },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
data: { organizationId: organizationUserId },
|
|
342
|
+
});
|
|
343
|
+
return this.findIndividualUserById(individualUserId);
|
|
344
|
+
}
|
|
321
345
|
}
|
|
322
346
|
|
|
323
347
|
module.exports = { UserRepositoryMongo };
|
|
@@ -372,6 +372,24 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
|
|
|
372
372
|
});
|
|
373
373
|
return this._convertUserIds(user);
|
|
374
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Set the legacy organizationId only if it is still null (see the interface).
|
|
378
|
+
* @param {string} individualUserId - Individual user ID (string from application layer)
|
|
379
|
+
* @param {string} organizationUserId - Organization user ID (string from application layer)
|
|
380
|
+
* @returns {Promise<Object|null>} The individual user as stored afterwards, with string IDs
|
|
381
|
+
*/
|
|
382
|
+
async linkIndividualToOrganizationIfUnset(individualUserId, organizationUserId) {
|
|
383
|
+
await this.prisma.user.updateMany({
|
|
384
|
+
where: {
|
|
385
|
+
id: this._convertId(individualUserId),
|
|
386
|
+
type: 'INDIVIDUAL',
|
|
387
|
+
organizationId: null,
|
|
388
|
+
},
|
|
389
|
+
data: { organizationId: this._convertId(organizationUserId) },
|
|
390
|
+
});
|
|
391
|
+
return this.findIndividualUserById(individualUserId);
|
|
392
|
+
}
|
|
375
393
|
}
|
|
376
394
|
|
|
377
395
|
module.exports = { UserRepositoryPostgres };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/* eslint-disable camelcase -- JWT claim names (org_id, org_role) and reason codes are snake_case by spec */
|
|
2
|
+
const jose = require('jose');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Key material and token signing for adopter-JWT tests. Real keys and real
|
|
6
|
+
* signatures: nothing here fakes the verifier.
|
|
7
|
+
*/
|
|
8
|
+
async function createKeys() {
|
|
9
|
+
const es = await jose.generateKeyPair('ES256');
|
|
10
|
+
const rs = await jose.generateKeyPair('RS256');
|
|
11
|
+
const attacker = await jose.generateKeyPair('ES256');
|
|
12
|
+
const publicJwks = {
|
|
13
|
+
keys: [
|
|
14
|
+
{
|
|
15
|
+
...(await jose.exportJWK(es.publicKey)),
|
|
16
|
+
kid: 'es-1',
|
|
17
|
+
alg: 'ES256',
|
|
18
|
+
use: 'sig',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
...(await jose.exportJWK(rs.publicKey)),
|
|
22
|
+
kid: 'rs-1',
|
|
23
|
+
alg: 'RS256',
|
|
24
|
+
use: 'sig',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
es,
|
|
30
|
+
rs,
|
|
31
|
+
attacker,
|
|
32
|
+
publicJwks,
|
|
33
|
+
localJwks: jose.createLocalJWKSet(publicJwks),
|
|
34
|
+
hmacSecret: 'a-shared-secret-that-is-long-enough-for-hs256',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} claims
|
|
40
|
+
* @param {Object} opts
|
|
41
|
+
* @param {Object|Uint8Array} opts.key - signing key
|
|
42
|
+
* @param {string} [opts.alg='ES256']
|
|
43
|
+
* @param {string} [opts.kid]
|
|
44
|
+
* @param {string|null} [opts.exp='5m'] - null omits exp
|
|
45
|
+
*/
|
|
46
|
+
async function signToken(
|
|
47
|
+
claims,
|
|
48
|
+
{ key, alg = 'ES256', kid, exp = '5m', iss, aud, nbf } = {}
|
|
49
|
+
) {
|
|
50
|
+
const jwt = new jose.SignJWT(claims)
|
|
51
|
+
.setProtectedHeader(kid ? { alg, kid } : { alg })
|
|
52
|
+
.setIssuedAt();
|
|
53
|
+
if (exp !== null) jwt.setExpirationTime(exp);
|
|
54
|
+
if (iss) jwt.setIssuer(iss);
|
|
55
|
+
if (aud) jwt.setAudience(aud);
|
|
56
|
+
if (nbf) jwt.setNotBefore(nbf);
|
|
57
|
+
return jwt.sign(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const b64url = (value) =>
|
|
61
|
+
Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
62
|
+
|
|
63
|
+
/** An unsigned token: `alg: none`, empty signature. */
|
|
64
|
+
function unsignedToken(claims) {
|
|
65
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url({
|
|
66
|
+
...claims,
|
|
67
|
+
exp: Math.floor(Date.now() / 1000) + 300,
|
|
68
|
+
})}.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolves each `wait()` only once `parties` callers have arrived, so a test
|
|
73
|
+
* can make concurrent requests all read before any of them writes.
|
|
74
|
+
*/
|
|
75
|
+
function createBarrier(parties) {
|
|
76
|
+
let arrived = 0;
|
|
77
|
+
let release;
|
|
78
|
+
const gate = new Promise((resolve) => {
|
|
79
|
+
release = resolve;
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
async wait() {
|
|
83
|
+
arrived += 1;
|
|
84
|
+
if (arrived >= parties) release();
|
|
85
|
+
await gate;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Stateful fake of the user repository, built from jest.fn so every call is
|
|
92
|
+
* observable. Individuals carry `organizationId`, as the real repositories
|
|
93
|
+
* return it. Like the database, it refuses a second individual with the same
|
|
94
|
+
* `appUserId` (a Prisma-shaped `P2002`), which is what a first-login race hits.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} [options]
|
|
97
|
+
* @param {number} [options.findBarrier] Make the first N calls to
|
|
98
|
+
* `findIndividualUserByAppUserId` wait for each other (see createBarrier).
|
|
99
|
+
*/
|
|
100
|
+
function createUserRepositoryDouble({
|
|
101
|
+
individuals = [],
|
|
102
|
+
organizations = [],
|
|
103
|
+
findBarrier = 0,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const individualUsers = new Map(
|
|
106
|
+
individuals.map((u) => [u.appUserId, { ...u }])
|
|
107
|
+
);
|
|
108
|
+
const organizationUsers = new Map(
|
|
109
|
+
organizations.map((o) => [o.appOrgId, { ...o }])
|
|
110
|
+
);
|
|
111
|
+
const barrier = findBarrier > 1 ? createBarrier(findBarrier) : null;
|
|
112
|
+
let barrierCalls = 0;
|
|
113
|
+
let seq = 0;
|
|
114
|
+
|
|
115
|
+
const repo = {
|
|
116
|
+
findIndividualUserByAppUserId: jest.fn(async (appUserId) => {
|
|
117
|
+
const found = individualUsers.get(appUserId) ?? null;
|
|
118
|
+
if (barrier && barrierCalls < findBarrier) {
|
|
119
|
+
barrierCalls += 1;
|
|
120
|
+
await barrier.wait();
|
|
121
|
+
}
|
|
122
|
+
return found && { ...found };
|
|
123
|
+
}),
|
|
124
|
+
findOrganizationUserByAppOrgId: jest.fn(
|
|
125
|
+
async (appOrgId) => organizationUsers.get(appOrgId) ?? null
|
|
126
|
+
),
|
|
127
|
+
createIndividualUser: jest.fn(async (params) => {
|
|
128
|
+
if (individualUsers.has(params.appUserId)) {
|
|
129
|
+
throw Object.assign(
|
|
130
|
+
new Error('Unique constraint failed on appUserId'),
|
|
131
|
+
{ code: 'P2002' }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const created = {
|
|
135
|
+
id: `ind-${++seq}`,
|
|
136
|
+
organizationId: undefined,
|
|
137
|
+
...params,
|
|
138
|
+
};
|
|
139
|
+
individualUsers.set(created.appUserId, created);
|
|
140
|
+
return { ...created };
|
|
141
|
+
}),
|
|
142
|
+
createOrganizationUser: jest.fn(async (params) => {
|
|
143
|
+
const created = { id: `org-${++seq}`, ...params };
|
|
144
|
+
organizationUsers.set(created.appOrgId, created);
|
|
145
|
+
return created;
|
|
146
|
+
}),
|
|
147
|
+
findIndividualUserById: jest.fn(async (id) => {
|
|
148
|
+
for (const u of individualUsers.values()) {
|
|
149
|
+
if (u.id === id) return { ...u };
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}),
|
|
153
|
+
// Like the database: sets the pointer only while it is unset.
|
|
154
|
+
linkIndividualToOrganizationIfUnset: jest.fn(
|
|
155
|
+
async (individualId, organizationId) => {
|
|
156
|
+
for (const u of individualUsers.values()) {
|
|
157
|
+
if (u.id !== individualId) continue;
|
|
158
|
+
const unset =
|
|
159
|
+
u.organizationId === undefined ||
|
|
160
|
+
u.organizationId === null ||
|
|
161
|
+
u.organizationId === '';
|
|
162
|
+
if (unset) u.organizationId = organizationId;
|
|
163
|
+
return { ...u };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
repo.state = { individualUsers, organizationUsers };
|
|
170
|
+
return repo;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* In-memory organization membership repository: atomic, like the real ones. The
|
|
175
|
+
* pair is unique and `addMembership` is an upsert. It knows nothing of the
|
|
176
|
+
* legacy `organizationId` pointer; the domain (user/membership.js) does.
|
|
177
|
+
*/
|
|
178
|
+
function createMembershipRepositoryDouble() {
|
|
179
|
+
const { toMembership } = require('../../repositories/organization-membership-record');
|
|
180
|
+
const rows = new Map();
|
|
181
|
+
const key = (i, o) => `${i}|${o}`;
|
|
182
|
+
let seq = 0;
|
|
183
|
+
|
|
184
|
+
const repo = {
|
|
185
|
+
findMembership: jest.fn(async (individualId, organizationId) => {
|
|
186
|
+
const row = rows.get(key(individualId, organizationId));
|
|
187
|
+
return row ? toMembership(row) : null;
|
|
188
|
+
}),
|
|
189
|
+
addMembership: jest.fn(
|
|
190
|
+
async (individualId, organizationId, { orgRole } = {}) => {
|
|
191
|
+
const k = key(individualId, organizationId);
|
|
192
|
+
if (!rows.has(k)) {
|
|
193
|
+
rows.set(k, {
|
|
194
|
+
id: `mem-${++seq}`,
|
|
195
|
+
individualUserId: individualId,
|
|
196
|
+
organizationUserId: organizationId,
|
|
197
|
+
orgRole: orgRole ?? 'MEMBER',
|
|
198
|
+
createdAt: new Date(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return toMembership(rows.get(k));
|
|
202
|
+
}
|
|
203
|
+
),
|
|
204
|
+
listMembershipsForOrganization: jest.fn(async (organizationId) =>
|
|
205
|
+
[...rows.values()]
|
|
206
|
+
.filter((r) => r.organizationUserId === organizationId)
|
|
207
|
+
.map(toMembership)
|
|
208
|
+
),
|
|
209
|
+
};
|
|
210
|
+
repo.rows = rows;
|
|
211
|
+
return repo;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
createKeys,
|
|
216
|
+
signToken,
|
|
217
|
+
unsignedToken,
|
|
218
|
+
createUserRepositoryDouble,
|
|
219
|
+
createMembershipRepositoryDouble,
|
|
220
|
+
};
|