diff --git a/db/src/scripts/api-server-pg/2026.06.08T16.00.00.drop_gdpr_delete_requests.sql b/db/src/scripts/api-server-pg/2026.06.08T16.00.00.drop_gdpr_delete_requests.sql new file mode 100644 index 0000000..e2159ef --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.06.08T16.00.00.drop_gdpr_delete_requests.sql @@ -0,0 +1,6 @@ +-- The `gdpr_delete_requests` table backed an HTTP endpoint that accepted GDPR +-- erasure requests but never processed them. The endpoint and table are +-- being removed: self-hosting adopters own their deployment and handle +-- deletion through their own admin tooling or SQL access. + +DROP TABLE IF EXISTS public.gdpr_delete_requests; diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b16291e..566f635 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -36,7 +36,6 @@ - [Appeal](api/appeal.md) - [Policies](api/policies.md) - [User Scores](api/user-scores.md) - - [GDPR Deletion](api/gdpr.md) - [Handling Actions](api/actions.md) - [Partial Items](api/partial-items.md) - [Errors](api/errors.md) diff --git a/docs/api/README.md b/docs/api/README.md index 6d903dc..224fa67 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -18,7 +18,6 @@ You can find or rotate your API key under **Settings** → **API Keys** in the C | `POST /api/v1/report/appeal` | [Appeal](appeal.md): submit a user appeal | | `GET /api/v1/policies/` | [Policies](policies.md): fetch your configured policies | | `GET /api/v1/user_scores` | [User Scores](user-scores.md): fetch a user's moderation score | -| `POST /api/v1/gdpr/delete` | [GDPR Deletion](gdpr.md): delete a user's data | See also: diff --git a/docs/api/gdpr.md b/docs/api/gdpr.md deleted file mode 100644 index 2851157..0000000 --- a/docs/api/gdpr.md +++ /dev/null @@ -1,56 +0,0 @@ -# GDPR Deletion API - -Delete a user's personal data from Coop. Use this endpoint to fulfill GDPR "right to erasure" requests from EU residents whose data Coop has processed. - -## Endpoint - -```http -POST /api/v1/gdpr/delete -``` - -Authentication: `X-API-KEY` header. See [API Keys & Authentication](../development/api-auth.md). - -## Request - -```json -{ - "userIds": [{ "id": "user-123", "typeId": "your-user-type-id" }] -} -``` - -You can include multiple users in a single request. - -### Request body fields - -| Field | Type | Required? | Description | -| :----------------- | :----- | :-------- | :----------------------------------------------------------------------- | -| `userIds` | Array | Required | One or more users whose data should be deleted. Minimum 1 entry | -| `userIds[].id` | String | Required | Your unique identifier for the user | -| `userIds[].typeId` | String | Required | The Coop Item Type ID for this user type, as configured in the dashboard | - -## Response - -Returns a unique ID for this deletion request, for your records. - -```json -{ - "requestId": "deletion-request-uuid" -} -``` - -HTTP responses: - -| Status | Meaning | -| :---------------- | :--------------------------------------------- | -| `201 Created` | Deletion request accepted; returns `requestId` | -| `400 Bad Request` | Validation failure; see [Errors](errors.md) | -| `401` or `403` | Authentication failure | - -Deletion is processed asynchronously. The `requestId` can be used to correlate this request with any downstream processing or audit logs. - -See [Errors](errors.md) for the full error response format. - -## Notes - -- GDPR applies to any organization handling personal data of EU residents, regardless of where the organization is based. -- Deletion removes the user's data from Coop's systems; you are responsible for deletion from your own platform and any other processors you use. diff --git a/server/routes/gdpr/dbTypes.ts b/server/routes/gdpr/dbTypes.ts deleted file mode 100644 index 2462f43..0000000 --- a/server/routes/gdpr/dbTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Generated, type GeneratedAlways } from 'kysely'; - -export type GDPRServicePg = { - gdpr_delete_requests: { - request_id: string; - org_id: string; - item_id: string; - item_type_id: string; - created_at: GeneratedAlways; - fulfilled: Generated; - }; -}; diff --git a/server/routes/gdpr/delete.ts b/server/routes/gdpr/delete.ts deleted file mode 100644 index a84cc42..0000000 --- a/server/routes/gdpr/delete.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type Kysely } from 'kysely'; -import { v1 as uuidv1 } from 'uuid'; - -import { type Dependencies } from '../../iocContainer/index.js'; -import { makeInternalServerError, makeUnauthorizedError } from '../../utils/errors.js'; -import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js'; -import { hasOrgId } from '../../utils/apiKeyMiddleware.js'; -import { type GDPRServicePg } from './dbTypes.js'; -import { - type DeleteRequestInput, - type DeleteRequestOutput, -} from './gdprRoutes.js'; - -class itemTypeError extends Error {} -export default function requestDelete({ - KyselyPg, - getItemTypeEventuallyConsistent, -}: Dependencies): RequestHandlerWithBodies< - DeleteRequestInput, - DeleteRequestOutput -> { - return async (req, res, next) => { - // Get orgId from request (set by API key middleware) - if (!hasOrgId(req)) { - return next( - makeUnauthorizedError('Invalid API Key', { - detail: - 'Something went wrong finding or validating your API key. ' + - 'Make sure the proper key is provided in the x-api-key header.', - shouldErrorSpan: true, - }), - ); - } - - const { orgId } = req; - const requestId = uuidv1(); - const { userIds } = req.body; - - try { - const requestRows = await Promise.all( - userIds.map(async (userId) => { - const itemType = await getItemTypeEventuallyConsistent({ - orgId, - typeSelector: { id: userId.typeId }, - }); - if (!itemType) { - throw new itemTypeError( - `typeId '${userId.typeId}', associated with itemId '${userId.id}' does not exist.`, - ); - } - return { - request_id: requestId, - org_id: orgId, - item_id: userId.id, - item_type_id: userId.typeId, - }; - }), - ); - - await (KyselyPg as Kysely) - .insertInto('gdpr_delete_requests') - .values(requestRows) - .execute(); - - res.status(202).json({ - requestId, - }); - } catch (e: unknown) { - if (e instanceof itemTypeError) { - return next( - makeInternalServerError(`Failed to submit data deletion request.`, { - detail: e.message, - shouldErrorSpan: true, - }), - ); - } else { - return next( - makeInternalServerError('Failed to submit data deletion request', { - shouldErrorSpan: true, - }), - ); - } - } - }; -} diff --git a/server/routes/gdpr/gdprRoutes.test.ts b/server/routes/gdpr/gdprRoutes.test.ts deleted file mode 100644 index 0910343..0000000 --- a/server/routes/gdpr/gdprRoutes.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { type ReadonlyDeep } from 'type-fest'; -import { uid } from 'uid'; - -import { type Dependencies } from '../../iocContainer/index.js'; -import { type ContentItemType } from '../../services/moderationConfigService/index.js'; -import createOrg from '../../test/fixtureHelpers/createOrg.js'; -import { makeMockedServer } from '../../test/setupMockedServer.js'; - -describe('POST /gdrp/delete', () => { - let contentType: ReadonlyDeep; - const orgId = uid(); - - let request: Awaited>['request'], - shutdown: Awaited>['shutdown'], - apiKey: Awaited>['apiKey'], - orgCleanup: Awaited>['cleanup'], - ApiKeyService: Dependencies['ApiKeyService'], - ModerationConfigService: Dependencies['ModerationConfigService'], - KyselyPg: Dependencies['KyselyPg']; - - beforeAll(async () => { - try { - ({ - request, - shutdown, - deps: { ModerationConfigService, ApiKeyService, KyselyPg }, - } = await makeMockedServer()); - - ({ apiKey, cleanup: orgCleanup } = await createOrg( - { KyselyPg, ModerationConfigService, ApiKeyService }, - orgId, - )); - - contentType = await ModerationConfigService.createContentType(orgId, { - name: 'TestUser', - description: 'user type', - schema: [ - { - name: 'name', - type: 'STRING', - required: true, - container: null, - }, - ], - schemaFieldRoles: {}, - }); - } catch (e) { - console.log({ e }); - throw e; - } - }); - - afterAll(async () => { - await orgCleanup(); - await shutdown(); - }); - - test('should return the expected response', async () => { - const responseSnapshotMatcher = { - requestId: expect.any(String), - }; - await request - .post('/api/v1/gdpr/delete') - .set('x-api-key', apiKey) - .send({ - userIds: [ - { id: 'pflock', typeId: contentType.id }, - { id: 'jholm', typeId: contentType.id }, - ], - }) - .expect(202) - .expect(({ body }) => { - expect(body).toMatchInlineSnapshot( - responseSnapshotMatcher, - ` - { - "requestId": Any, - } - `, - ); - }); - }); -}); diff --git a/server/routes/gdpr/gdprRoutes.ts b/server/routes/gdpr/gdprRoutes.ts deleted file mode 100644 index 6d6c3d4..0000000 --- a/server/routes/gdpr/gdprRoutes.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { route } from '../../utils/route-helpers.js'; -import { createApiKeyMiddleware } from '../../utils/apiKeyMiddleware.js'; -import { type Controller } from '../index.js'; -import requestDelete from './delete.js'; - -export type DeleteRequestInput = { - userIds: { id: string; typeId: string }[]; -}; - -export type DeleteRequestOutput = { - requestId: string; -}; - -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -export default { - pathPrefix: '/gdpr/delete', - routes: [ - route.post( - '/', - { - bodySchema: { - $schema: 'http://json-schema.org/draft-04/schema#', - title: 'SubmitGDPRDeleteRequestInputModel', - type: 'object', - properties: { - userIds: { - type: 'array', - minItems: 1, - items: { - type: 'object', - properties: { - id: { type: 'string' }, - typeId: { type: 'string' }, - }, - required: ['id', 'typeId'], - }, - }, - }, - required: ['userIds'], - }, - }, - (deps) => [createApiKeyMiddleware(deps), requestDelete(deps)], - ), - ], -} as Controller; diff --git a/server/routes/index.ts b/server/routes/index.ts index 4b8dd5f..e920109 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,7 +1,6 @@ import { type Route } from '../utils/route-helpers.js'; import ActionRoutes from './action/ActionRoutes.js'; import ContentRoutes from './content/ContentRoutes.js'; -import GDPRRoutes from './gdpr/gdprRoutes.js'; import IntegrationLogosRoutes from './integration_logos/IntegrationLogosRoutes.js'; import ItemRoutes from './items/ItemRoutes.js'; import PoliciesRoutes from './policies/PoliciesRoutes.js'; @@ -26,6 +25,5 @@ export default { Policies: PoliciesRoutes, UserScores: UserScoresRoutes, Actions: ActionRoutes, - GDPR: GDPRRoutes, IntegrationLogos: IntegrationLogosRoutes, } satisfies { [key: string]: Controller };