diff --git a/CHANGELOG.md b/CHANGELOG.md index df342918f..0d1345085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [v0.57.7](https://github.com/serlo/api.serlo.org/compare/v0.57.6..v0.57.7) - November 7, 2023 + +### Fixed + +- Re-enable user journey in enmeshed middleware + ## [v0.57.6](https://github.com/serlo/api.serlo.org/compare/v0.57.5..v0.57.6) - October 18, 2023 ### Fixed diff --git a/lerna.json b/lerna.json index ca3d0db6f..4fd14ee4a 100644 --- a/lerna.json +++ b/lerna.json @@ -3,5 +3,5 @@ "packages/*" ], "npmClient": "yarn", - "version": "0.57.6" + "version": "0.57.7" } diff --git a/packages/server/package.json b/packages/server/package.json index 5a5bba49c..02982113d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@serlo/api.serlo.org", - "version": "0.57.6", + "version": "0.57.7", "private": true, "repository": "serlo/api.serlo.org", "license": "Apache-2.0", diff --git a/packages/server/src/internals/server/enmeshed-middleware.ts b/packages/server/src/internals/server/enmeshed-middleware.ts index 76c118df6..9e8ca9a34 100644 --- a/packages/server/src/internals/server/enmeshed-middleware.ts +++ b/packages/server/src/internals/server/enmeshed-middleware.ts @@ -22,23 +22,14 @@ import { ConnectorClient, ConnectorError, + ConnectorRelationshipChangeStatus, + ConnectorRelationshipChangeType, ConnectorRequestContent, } from '@nmshd/connector-sdk' -import express, { - Express, - RequestHandler, - Request, - Response, - NextFunction, -} from 'express' +import express, { Express, RequestHandler, Request, Response } from 'express' import { option as O } from 'fp-ts' import * as t from 'io-ts' -import { - EnmeshedWebhookPayload, - Relationship, - RelationshipChange, -} from './enmeshed-payload' import { Cache } from '../cache' import { captureErrorEvent } from '../error-event' @@ -68,6 +59,42 @@ export function applyEnmeshedMiddleware({ return `${basePath}/init` } +const EventBody = t.type({ + trigger: t.string, +}) + +const Relationship = t.type({ + id: t.string, + peer: t.string, + status: t.string, + template: t.type({ + id: t.string, + content: t.partial({ + onNewRelationship: t.partial({ + metadata: t.partial({ sessionId: t.union([t.string, t.null]) }), + }), + }), + }), + changes: t.array(t.type({ type: t.string, status: t.string, id: t.string })), +}) + +type Relationship = t.TypeOf + +const RelationshipChangedEventBody = t.type({ + data: Relationship, + trigger: t.literal('transport.relationshipChanged'), +}) + +const Session = t.intersection([ + t.type({ relationshipTemplateId: t.string }), + t.partial({ + enmeshedId: t.string, + content: t.UnknownRecord, + }), +]) + +type Session = t.TypeOf + /** * Endpoint for enmeshed relationship initialization. * Creates relationship template and returns QR for the user to scan. @@ -110,9 +137,9 @@ function createEnmeshedInitMiddleware( }) } - // TODO: Handle privacy and Lernstand-Mathe See https://github.com/serlo/api.serlo.org/blob/main/packages/server/src/internals/server/enmeshed-middleware.ts#L525 + // TODO: Handle privacy See https://github.com/serlo/api.serlo.org/blob/83db29db4a98f6b32c389a0a0f89612fb9f760f8/packages/server/src/internals/server/enmeshed-middleware.ts#L470 const attributesContent: ConnectorRequestContent = { - metadata: { sessionId: sessionId ?? 'session-id' }, + metadata: { sessionId: sessionId }, items: [ { '@type': 'RequestItemGroup', @@ -147,7 +174,7 @@ function createEnmeshedInitMiddleware( owner: '', value: { '@type': 'GivenName', - value: nameParts.length > 0 ? nameParts[0] : 'Alex', + value: nameParts.length > 0 ? nameParts[0] : '', }, }, }, @@ -162,7 +189,7 @@ function createEnmeshedInitMiddleware( value: nameParts.length > 1 ? nameParts[nameParts.length - 1] - : 'Janowski', + : '', }, }, }, @@ -178,6 +205,21 @@ function createEnmeshedInitMiddleware( }, }, }, + { + '@type': 'CreateAttributeRequestItem', + mustBeAccepted: true, + attribute: { + owner: '', + key: 'LernstandMathe', + confidentiality: 'public', + '@type': 'RelationshipAttribute', + value: { + '@type': 'ProprietaryString', + title: 'LernstandMathe', + value: '', + }, + }, + }, ], }, ], @@ -215,11 +257,7 @@ function createEnmeshedInitMiddleware( relationshipTemplateId = createRelationshipResponse.result.id - await setSession(cache, sessionId, { - relationshipTemplateId, - content: createRelationshipResponse.result - .content as Session['content'], - }) + await setSession(cache, sessionId, { relationshipTemplateId }) } const createTokenResponse = @@ -265,7 +303,7 @@ function createGetAttributesHandler(cache: Cache): RequestHandler { res.status(200).end( JSON.stringify({ status: 'success', - attributes: session.content['onNewRelationship'], + attributes: session.content, }), ) } else { @@ -294,13 +332,6 @@ function createSetAttributesHandler( if (!sessionId) return validationError(res, 'Missing required parameter: sessionId.') const session = await getSession(cache, sessionId) - - const name = readQuery(req, 'name') - if (!name) return validationError(res, 'Missing required parameter: name.') - const value = readQuery(req, 'value') - if (!value) - return validationError(res, 'Missing required parameter: value.') - if (!session) return validationError( res, @@ -309,44 +340,74 @@ function createSetAttributesHandler( if (!session.enmeshedId) return validationError(res, 'Relationship not accepted yet.') - const getIdentityResponse = await client.account.getIdentityInfo() - if (getIdentityResponse.isError) { + const name = readQuery(req, 'name') + if (!name) return validationError(res, 'Missing required parameter: name.') + const value = readQuery(req, 'value') + if (!value) + return validationError(res, 'Missing required parameter: value.') + + const request = await client.outgoingRequests.createRequest({ + content: { + items: [ + { + '@type': 'CreateAttributeRequestItem', + mustBeAccepted: true, + attribute: { + key: name, + owner: '', + confidentiality: 'public', + '@type': 'RelationshipAttribute', + value: { + '@type': 'ProprietaryString', + title: name, + value: value, + }, + }, + }, + ], + }, + peer: session.enmeshedId, + }) + + if (request.isError) { return handleConnectorError({ - error: getIdentityResponse.error, - message: 'Error retrieving connector identity info', - response: res, + error: request.error, + message: 'Failed to create request to change attribute:', }) } - // See: https://enmeshed.eu/explore/schema#attributeschangerequest const sendMessageResponse = await client.messages.sendMessage({ recipients: [session.enmeshedId], content: { - '@type': 'RequestMail', + '@type': 'Mail', to: [session.enmeshedId], - cc: [], subject: 'Aktualisierung deines Lernstands', body: 'Gratulation!\nDu hast den Kurs zum logistischen Wachstum erfolgreich absolviert. Bitte speichere den aktualisierten Lernstand.\nDein Serlo-Team', - requests: [ - { - '@type': 'AttributesChangeRequest', - attributes: [{ name, value }], - applyTo: session.enmeshedId, - reason: - 'Neuer Lernstand nach erfolgreicher Durchführung des Kurses zum logistischen Wachstum', - }, - ], }, }) + if (sendMessageResponse.isError) { return handleConnectorError({ error: sendMessageResponse.error, - message: 'Error retrieving connector identity info', - response: res, + message: 'Failed to send message:', }) } + + const attributeChangeResponse = await client.messages.sendMessage({ + recipients: [session.enmeshedId], + content: request.result.content, + }) + + if (attributeChangeResponse.isError) { + return handleConnectorError({ + error: attributeChangeResponse.error, + message: 'Failed to send attribute change request:', + }) + } + res.status(200).end(JSON.stringify({ status: 'success' })) } + return (request, response) => { handleRequest(request, response).catch((error: Error) => { captureErrorEvent({ @@ -365,82 +426,72 @@ function createEnmeshedWebhookMiddleware( client: ConnectorClient, cache: Cache, ): RequestHandler { - async function handleRequest( - req: Request, - res: Response, - next: NextFunction, - ) { - if (req.headers['x-api-key'] !== process.env.ENMESHED_WEBHOOK_SECRET) - return next() - - const payload = req.body as EnmeshedWebhookPayload - if (!payload || !(payload.relationships || payload.messages)) return next() - - for (const relationship of payload.relationships) { - const sessionId = relationship.template.content.metadata.sessionId ?? null - // FIXME: Uncomment next line when prototype frontend has been replaced - // if (!sessionId) return validationError(res, 'Missing required parameter: sessionId.') - const session = await getSession(cache, sessionId) - - // FIXME: Uncomment next lines when prototype frontend has been replaced - // if (!session) return validationError(res, 'Session not found. Please create a QR code first.') - // if (relationship.template.id !== session.relationshipTemplateId) return validationError(res, 'Mismatching relationship template ID.') - - for (const change of relationship.changes) { - if ( - ['Creation', 'RelationshipRequest'].includes(change.type) && - ['Pending', 'Revoked'].includes(change.status) - ) { - await acceptRelationshipRequest(relationship, change, client) - - if (session) { - await setSession(cache, sessionId, { - ...session, - enmeshedId: relationship.peer, - content: { - ...session.content, - ...getSessionAttributes( - Object.values(change.request.content?.attributes ?? {}), - ), - }, - }) - } else { - const payload = { relationship, client } - await sendWelcomeMessage(payload) - await sendAttributesChangeRequest(payload) - } - } - } + async function handleRequest(req: Request, res: Response) { + if (req.headers['x-api-key'] !== process.env.ENMESHED_WEBHOOK_SECRET) { + res.status(400).send('Wrong X-API-Key') + return } - for (const message of payload.messages) { - if (message.content['@type'] == 'AttributesChangeRequest') { - const sessionId = await getSessionId(cache, message.createdBy) - const session = await getSession(cache, sessionId) - if (session) { - await setSession(cache, sessionId, { - ...session, - enmeshedId: message.createdBy, - content: { - ...session.content, - ...getSessionAttributes(message.content.attributes), - }, - }) - // eslint-disable-next-line no-console - console.log(`Received attributes`) + const body = req.body as unknown + + if (!EventBody.is(body)) { + res.status(400).send('Illegal trigger body') + return + } + + if (body.trigger !== 'transport.relationshipChanged') { + res.sendStatus(200) + return + } + + if (!RelationshipChangedEventBody.is(body)) { + captureErrorEvent({ + error: new Error('Illegal body for relationship change event'), + errorContext: { body, route: '/enmeshed/webhook' }, + }) + res.status(400).send('Illegal trigger body') + return + } + + const relationship = body.data + + const sessionId = + relationship.template.content?.onNewRelationship?.metadata?.sessionId ?? + null + + for (const change of relationship.changes) { + if ( + [ConnectorRelationshipChangeType.CREATION as string].includes( + change.type, + ) && + [ + ConnectorRelationshipChangeStatus.PENDING as string, + ConnectorRelationshipChangeStatus.REJECTED as string, + ].includes(change.status) + ) { + await acceptRelationshipRequest(relationship, change, client) + if (!sessionId) { + await sendWelcomeMessage({ relationship, client }) + await sendAttributesChangeRequest({ relationship, client }) } - } else { - // eslint-disable-next-line no-console - console.log('Received message:') - // eslint-disable-next-line no-console - console.log(message.content) } } + // FIXME: Uncomment next line when prototype frontend has been replaced + // if (!sessionId) return validationError(res, 'Missing required parameter: sessionId.') + const session = await getSession(cache, sessionId) + + if (session) { + await setSession(cache, sessionId, { + relationshipTemplateId: relationship.template.id, + content: relationship.template.content as Session['content'], + }) + } + res.status(200).end('') } - return (request, response, next) => { - handleRequest(request, response, next).catch((error: Error) => { + return (request, response) => { + handleRequest(request, response).catch((error: Error) => { captureErrorEvent({ error, errorContext: { headers: request.headers }, @@ -455,27 +506,20 @@ function createEnmeshedWebhookMiddleware( */ async function acceptRelationshipRequest( relationship: Relationship, - change: RelationshipChange, + change: { id: string }, client: ConnectorClient, -): Promise { +): Promise { const acceptRelationshipResponse = await client.relationships.acceptRelationshipChange( relationship.id, change.id, - { - content: { - // FIXME: The documentation is unclear on what should be submitted here - prop1: 'value', - prop2: 1, - }, - }, ) if (acceptRelationshipResponse.isError) { - // eslint-disable-next-line no-console - console.log(acceptRelationshipResponse) - return false + handleConnectorError({ + error: acceptRelationshipResponse.error, + message: 'Failed while accepting relationship request', + }) } - return true } /** @@ -487,22 +531,24 @@ async function sendWelcomeMessage({ }: { relationship: Relationship client: ConnectorClient -}): Promise { +}): Promise { const expiresAt = new Date() expiresAt.setHours(expiresAt.getHours() + 1) const uploadFileResponse = await client.files.uploadOwnFile({ title: 'Serlo Testdatei', description: 'Test file created by Serlo', file: Buffer.from( - 'Serlo Testdatei

Hello World! – Dies ist eine Testdatei.

', + 'Serlo Testdatei

Hello World! - Dies ist eine Testdatei.

', ), filename: 'serlo-test.html', expiresAt: expiresAt.toISOString(), }) + if (uploadFileResponse.isError) { - // eslint-disable-next-line no-console - console.log(uploadFileResponse) - return false + handleConnectorError({ + error: uploadFileResponse.error, + message: 'Failed to upload file in welcome message', + }) } const sendMessageResponse = await client.messages.sendMessage({ @@ -515,12 +561,13 @@ async function sendWelcomeMessage({ }, attachments: [uploadFileResponse.result.id], }) + if (sendMessageResponse.isError) { - // eslint-disable-next-line no-console - console.log(sendMessageResponse) - return false + handleConnectorError({ + error: sendMessageResponse.error, + message: 'Failed to upload file in welcome message', + }) } - return true } /** @@ -533,52 +580,65 @@ async function sendAttributesChangeRequest({ }: { relationship: Relationship client: ConnectorClient -}): Promise { - const getIdentityResponse = await client.account.getIdentityInfo() - if (getIdentityResponse.isError) { - // eslint-disable-next-line no-console - console.log(getIdentityResponse) - return false - } - const connectorIdentity = getIdentityResponse.result.address - - // Send message to create/change attribute - // See: https://enmeshed.eu/explore/schema#attributeschangerequest - const sendMessageResponse = await client.messages.sendMessage({ - recipients: [relationship.peer], +}): Promise { + const request = await client.outgoingRequests.createRequest({ content: { - '@type': 'RequestMail', - to: [relationship.peer], - cc: [], - subject: 'Dein Lernstand', - body: 'Hallo!\nBitte speichere deinen aktuellen Lernstand und teile ihn mit uns.\nDein Serlo-Team', - requests: [ + items: [ { - '@type': 'AttributesChangeRequest', - attributes: [ - { - name: 'lenabi.level', + '@type': 'CreateAttributeRequestItem', + mustBeAccepted: true, + attribute: { + key: 'LernstandMathe', + owner: '', + confidentiality: 'public', + '@type': 'RelationshipAttribute', + value: { + '@type': 'ProprietaryString', + title: 'LernstandMathe', value: '42', }, - ], - applyTo: relationship.peer, - reason: 'Lernstand', - }, - { - '@type': 'AttributesShareRequest', - attributes: ['lenabi.level'], - recipients: [connectorIdentity], - reason: 'Lernstand', + }, }, ], }, + peer: relationship.peer, }) - if (sendMessageResponse.isError) { - // eslint-disable-next-line no-console - console.log(sendMessageResponse) - return false + + if (request.isError) { + handleConnectorError({ + error: request.error, + message: 'Failed to create request to change attribute:', + }) + } + + const sendMailResponse = await client.messages.sendMessage({ + recipients: [relationship.peer], + content: { + '@type': 'Mail', + to: [relationship.peer], + subject: 'Dein Lernstand', + body: 'Hallo!\nBitte speichere deinen aktuellen Lernstand.\nDein Serlo-Team', + }, + }) + + if (sendMailResponse.isError) { + handleConnectorError({ + error: sendMailResponse.error, + message: 'Failed to send mail', + }) + } + + const attributeChangeResponse = await client.messages.sendMessage({ + recipients: [relationship.peer], + content: request.result.content, + }) + + if (attributeChangeResponse.isError) { + handleConnectorError({ + error: attributeChangeResponse.error, + message: 'Failed to send attribute change request:', + }) } - return true } function handleConnectorError({ @@ -588,9 +648,17 @@ function handleConnectorError({ }: { error: ConnectorError message: string - response: Response + response?: Response }) { - return response.status(500).end(`${message}: ${error.code} ${error.message}`) + const log = `${message}: ${error.code} ${error.message}` + if (response) { + return response.status(500).end(log) + } else { + captureErrorEvent({ + error: new Error(log), + errorContext: { error }, + }) + } } function validationError(res: Response, message: string) { @@ -603,15 +671,6 @@ function validationError(res: Response, message: string) { ) } -const Session = t.intersection([ - t.type({ relationshipTemplateId: t.string }), - t.partial({ - enmeshedId: t.string, - content: t.UnknownRecord, - }), -]) -type Session = t.TypeOf - async function getSession( cache: Cache, sessionId: string | null, @@ -627,16 +686,6 @@ async function getSession( return null } -async function getSessionId(cache: Cache, id: string): Promise { - const cachedValue = await cache.get({ key: getIdentityKey(id) }) - - if (!O.isNone(cachedValue)) { - return cachedValue.value.value as string - } - - return null -} - async function setSession( cache: Cache, sessionId: string | null, @@ -666,12 +715,6 @@ function getIdentityKey(id: string) { return `enmeshed:${id}` } -function getSessionAttributes( - attributeList: { name: string; value: string }[], -) { - return attributeList.reduce((al, a) => ({ ...al, [a.name]: a.value }), {}) -} - function readQuery(req: ExpressRequest, key: string): string | null { const value = req.query[key] diff --git a/packages/server/src/internals/server/enmeshed-payload.ts b/packages/server/src/internals/server/enmeshed-payload.ts deleted file mode 100644 index 63e2a92cb..000000000 --- a/packages/server/src/internals/server/enmeshed-payload.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * This file is part of Serlo.org API - * - * Copyright (c) 2020-2023 Serlo Education e.V. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * @copyright Copyright (c) 2020-2023 Serlo Education e.V. - * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 - * @link https://github.com/serlo-org/api.serlo.org for the canonical source repository - */ -// Enmeshed Webhook payload -// https://enmeshed.eu/integrate/connector-configuration#payload - -export interface EnmeshedWebhookPayload { - messages: Message[] - relationships: Relationship[] -} - -export interface Message { - id: string - /* eslint-disable @typescript-eslint/no-explicit-any */ - content: { - '@type': string - attributes: { name: string; value: string }[] - } - createdBy: string - createdByDevice: string - recipients: Recipient[] - relationshipIds: string[] - createdAt: string - attachments: string[] -} - -export interface Recipient { - address: string -} - -export interface Relationship { - id: string - template: RelationshipTemplate - status: string - peer: string - changes: RelationshipChange[] - lastMessageSentAt?: string - lastMessageReceivedAt?: string -} - -export interface RelationshipTemplate { - id: string - isOwn: boolean - createdBy: string - createdByDevice: string - createdAt: string - content: { - attributes: { name: string; value: string }[] - metadata: { [key: string]: string | undefined } - } - expiresAt?: string - maxNumberOfRelationships?: number -} - -export interface RelationshipChange { - id: string - request: { - createdBy: string - createdByDevice: string - createdAt: string - content?: { - attributes?: { [key: string]: { name: string; value: string } } - } - } - status: 'Pending' | 'Rejected' | 'Revoked' | 'Accepted' - type: - | 'Creation' - | 'Termination' - | 'TerminationCancellation' - | 'RelationshipRequest' - response?: { - createdBy: string - createdByDevice: string - createdAt: string - content: { - attributes: { name: string; value: string }[] - } - } -} diff --git a/scripts/changelog.ts b/scripts/changelog.ts index 2388834c6..997183872 100755 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -1592,6 +1592,11 @@ async function exec(): Promise { date: '2023-10-18', fixed: ['Actualize enmeshed relationship template schema to version 2'], }, + { + tagName: 'v0.57.7', + date: '2023-11-07', + fixed: ['Re-enable user journey in enmeshed middleware'], + }, ], })