From 7887711a556dcfc6b49d6a048f599460e9d0dce1 Mon Sep 17 00:00:00 2001 From: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:19:09 +0300 Subject: [PATCH 01/11] fix: stop whiteboard-draft sweep crashing on distinctAlias column (#6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stop whiteboard-draft sweep crashing on distinctAlias column The hourly WhiteboardDraftSweepService.sweep crashed on every run with Postgres 42703 (column "distinctAlias.Whiteboard_draftExpiresAt" does not exist). findExpired used repository.find({ select: { id: true }, order: { draftExpiresAt: 'ASC' }, take }). Whiteboard's inherited eager `authorization` relation forces a LEFT JOIN, so TypeORM applied the LIMIT by wrapping the read in a `SELECT DISTINCT ... FROM () "distinctAlias"` pagination query. It appended the ORDER BY column to the outer select/order, but because `select` restricted the inner projection to `id`, the derived table never emitted `draftExpiresAt` — so the outer reference could not resolve. It is a query-construction defect, not a schema/migration problem: the column exists, and it failed on every sweep regardless of data. Rebuild findExpired as an explicit, join-free query that selects only the scalar `id`, which keeps the SQL flat and avoids the distinct-alias path. Tests: the service spec now drives the query builder and guards against regressing to the eager-join `find` path (proven red against the old code); a new find-expired.query.spec builds real TypeORM SQL offline to show the old option shape omits the ORDER BY column from its projection while the new query is flat and self-consistent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013tVHtw561B9drGNY6kL8BS * test: await protected buildMetadatas() in a beforeAll Addresses CodeRabbit review on #6437. In our TypeORM fork buildMetadatas() is async and populates entityMetadatas only after two awaits, so calling it at describe-body scope and discarding the promise left correctness to depend on the tick gap before the it() callbacks. Await it in an async beforeAll so metadata is deterministically populated before any createQueryBuilder call. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013tVHtw561B9drGNY6kL8BS * refactor: unify sweep expiry predicate + harden find-expired tests Addresses the /code-review high pass on #6437 (fix itself confirmed correct): - findExpired now expresses the expiry boundary as `.where({ draftExpiresAt: LessThanOrEqual(new Date()) })`, the same operator cleanupExpired's locked re-read uses, so the candidate query and the re-check can't drift (was a raw string after the initial fix). Generated SQL is identical and still flat/join-free. - find-expired.query.spec: match on our own identifiers + case-insensitive keywords instead of exact quoting/casing, so a fork SQL-formatting change can't silently break or falsely pass; mirror the object-where form; and reframe the old-shape test honestly as asserting the distinct-alias *precondition* (join present + ORDER BY column unprojected), not the executed crash. The real-code behavioural guard stays in the service spec. - service spec asserts the where() operator is a lessThanOrEqual FindOperator. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013tVHtw561B9drGNY6kL8BS --------- Co-authored-by: Claude Opus 4.8 --- ...hiteboard.draft.find-expired.query.spec.ts | 123 ++++++++++++++++++ .../whiteboard.draft.service.spec.ts | 72 +++++++--- .../whiteboard.draft.service.ts | 28 +++- 3 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 src/domain/common/whiteboard-draft/whiteboard.draft.find-expired.query.spec.ts diff --git a/src/domain/common/whiteboard-draft/whiteboard.draft.find-expired.query.spec.ts b/src/domain/common/whiteboard-draft/whiteboard.draft.find-expired.query.spec.ts new file mode 100644 index 0000000000..f0ca84ced4 --- /dev/null +++ b/src/domain/common/whiteboard-draft/whiteboard.draft.find-expired.query.spec.ts @@ -0,0 +1,123 @@ +import { + Column, + DataSource, + Entity, + JoinColumn, + LessThanOrEqual, + OneToOne, + PrimaryColumn, +} from 'typeorm'; +import { beforeAll, describe, expect, it } from 'vitest'; + +/** + * Regression coverage for the whiteboard-draft sweep crash — Postgres 42703 + * `column distinctAlias.Whiteboard_draftExpiresAt does not exist`, thrown every + * hour by WhiteboardDraftSweepService.sweep -> WhiteboardDraftService.findExpired. + * + * The service unit spec mocks the repository, so it proves findExpired builds a + * join-free query but never sees the SQL TypeORM actually generates — which is + * exactly where the bug lived. These tests build REAL SQL from REAL TypeORM + * metadata. No database is involved: `getSql()` needs metadata, not a live + * connection, and `DataSource.buildMetadatas()` builds metadata without one. + * + * The probe entity reproduces the shape that triggered the bug: a scalar `id`, + * a nullable `draftExpiresAt` marker, and an EAGER one-to-one relation (the + * real Whiteboard inherits an eager `authorization` relation). The eager + * relation is the pivot: it forces a LEFT JOIN, which pushes `repository.find` + * onto its distinct-alias pagination strategy. + */ + +@Entity('draft_probe_authorization') +class DraftProbeAuthorization { + @PrimaryColumn('uuid') + id!: string; + + @Column('int') + version!: number; +} + +@Entity('draft_probe_whiteboard') +class DraftProbeWhiteboard { + @PrimaryColumn('uuid') + id!: string; + + @Column('timestamptz', { nullable: true }) + draftExpiresAt?: Date | null; + + @OneToOne(() => DraftProbeAuthorization, { eager: true, nullable: true }) + @JoinColumn() + authorization?: DraftProbeAuthorization; +} + +describe('findExpired sweep query (real TypeORM SQL generation)', () => { + const dataSource = new DataSource({ + type: 'postgres', + entities: [DraftProbeAuthorization, DraftProbeWhiteboard], + synchronize: false, + }); + beforeAll(async () => { + // Build entity metadata without opening a connection (initialize() would + // try to connect, and neither CI nor this test has a database). It is all + // we need to generate SQL offline via getSql(). buildMetadatas is + // `protected` and async — await it so entityMetadatas is populated before + // any createQueryBuilder()/getMetadata() call runs. + await ( + dataSource as unknown as { buildMetadatas(): Promise } + ).buildMetadatas(); + }); + + // Assertions match on our own identifiers (draftExpiresAt / whiteboard / id) + // and case-insensitive keywords, not exact quoting or keyword casing, so a + // fork upgrade that changes SQL formatting can't silently break — or falsely + // pass — these checks. + + it('the OLD find-options shape omits the ORDER BY column from its projection (the distinct-alias precondition)', () => { + // What `repository.find({ select: { id: true }, + // order: { draftExpiresAt: 'ASC' }, take })` builds. NOTE: getSql() is the + // inner query, before TypeORM's distinct-alias pagination wrapper; this test + // asserts the *precondition* that makes that wrapper fail — the eager join + // is present and the ORDER BY column is not projected — not the executed + // `SELECT DISTINCT ... "distinctAlias"` itself (that is only built at + // execution, needs a live connection, and is Postgres 42703). The + // behavioural guard on findExpired's real code path is in + // whiteboard.draft.service.spec.ts. + const sql = dataSource + .createQueryBuilder(DraftProbeWhiteboard, 'Whiteboard') + .setFindOptions({ + select: { id: true }, + where: { draftExpiresAt: LessThanOrEqual(new Date()) }, + order: { draftExpiresAt: 'ASC' }, + take: 25, + }) + .getSql(); + + const projection = sql.slice(0, sql.indexOf(' FROM ')); + + // The eager relation is joined in, and draftExpiresAt is referenced + // (WHERE / ORDER BY)... + expect(sql).toMatch(/left join/i); + expect(sql).toContain('draftExpiresAt'); + // ...but it is never SELECTed, so the pagination wrapper's outer ORDER BY + // would reference a column the derived table never exposes. + expect(projection).not.toContain('draftExpiresAt'); + }); + + it('the join-free findExpired query is flat and self-consistent', () => { + // The shape findExpired now builds (object-where mirrors the real code). + const sql = dataSource + .createQueryBuilder(DraftProbeWhiteboard, 'whiteboard') + .select('whiteboard.id', 'id') + .where({ draftExpiresAt: LessThanOrEqual(new Date()) }) + .orderBy('whiteboard.draftExpiresAt', 'ASC') + .limit(25) + .getSql(); + + // No eager join, so no distinct-alias pagination wrapper is generated... + expect(sql).not.toMatch(/left join/i); + expect(sql).not.toMatch(/distinctalias/i); + // ...and everything the query references, it also selects. + expect(sql).toMatch(/select\b[^;]*\bid\b/i); + expect(sql).toMatch(/order by[^;]*draftExpiresAt[^;]*\basc\b/i); + expect(sql).toMatch(/\blimit\b\s+25\b/i); + }); +}); diff --git a/src/domain/common/whiteboard-draft/whiteboard.draft.service.spec.ts b/src/domain/common/whiteboard-draft/whiteboard.draft.service.spec.ts index e0685b1551..54918176fc 100644 --- a/src/domain/common/whiteboard-draft/whiteboard.draft.service.spec.ts +++ b/src/domain/common/whiteboard-draft/whiteboard.draft.service.spec.ts @@ -6,7 +6,13 @@ import { Whiteboard } from '@domain/common/whiteboard/whiteboard.entity'; import { WhiteboardService } from '@domain/common/whiteboard/whiteboard.service'; import { WhiteboardAuthorizationService } from '@domain/common/whiteboard/whiteboard.service.authorization'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; -import { DataSource, QueryRunner, Repository } from 'typeorm'; +import { + DataSource, + FindOperator, + QueryRunner, + Repository, + SelectQueryBuilder, +} from 'typeorm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { WhiteboardDraftService } from './whiteboard.draft.service'; @@ -47,7 +53,13 @@ describe('WhiteboardDraftService', () => { const parentAuthorization = { id: 'parent-auth' } as IAuthorizationPolicy; const draftID = 'draft-wb'; let drafts: Map; - let repository: Pick, 'find'>; + let repository: Pick, 'find' | 'createQueryBuilder'>; + // Chainable stand-in for the query builder findExpired() now drives. + let expiredQueryBuilder: Record< + 'select' | 'where' | 'orderBy' | 'limit' | 'getRawMany', + ReturnType + >; + let expiredRows: Array<{ id: string }>; let lockedRepository: Pick, 'findOne' | 'update'>; let whiteboardService: Pick< WhiteboardService, @@ -70,7 +82,20 @@ describe('WhiteboardDraftService', () => { beforeEach(() => { vi.clearAllMocks(); drafts = new Map(); - repository = { find: vi.fn() }; + expiredRows = []; + expiredQueryBuilder = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + getRawMany: vi.fn(async () => expiredRows), + }; + repository = { + find: vi.fn(), + createQueryBuilder: vi.fn( + () => expiredQueryBuilder as unknown as SelectQueryBuilder + ), + }; lockedRepository = { findOne: vi.fn(async options => { const where = options.where as { @@ -399,9 +424,7 @@ describe('WhiteboardDraftService', () => { ...futureDraft(), draftExpiresAt: new Date(Date.now() - 60_000), } as Whiteboard); - vi.mocked(repository.find).mockResolvedValue([ - { id: draftID } as Whiteboard, - ]); + expiredRows = [{ id: draftID }]; await expect(service.findExpired(25)).resolves.toEqual([draftID]); drafts.get(draftID)!.draftExpiresAt = @@ -463,18 +486,35 @@ describe('WhiteboardDraftService', () => { ).rejects.toThrow('Whiteboard draft has expired'); }); - it('periodic cleanup discovers only expired non-NULL draft expiries', async () => { - vi.mocked(repository.find).mockResolvedValue([ - { id: 'expired-draft' } as Whiteboard, - ]); + it('periodic cleanup selects expired drafts via a flat, join-free id query', async () => { + expiredRows = [{ id: 'expired-draft' }]; await expect(service.findExpired(25)).resolves.toEqual(['expired-draft']); - expect(repository.find).toHaveBeenCalledWith({ - select: { id: true }, - where: { draftExpiresAt: expect.anything() }, - order: { draftExpiresAt: 'ASC' }, - take: 25, - }); + // Regression guard for the sweep crash (Postgres 42703 + // `column distinctAlias.Whiteboard_draftExpiresAt does not exist`): + // findExpired must NOT use the eager-join `repository.find` path — that + // wraps the paginated read in a distinctAlias subquery whose inner + // projection omits the ORDER BY column. It must build a flat id-only query. + expect(repository.find).not.toHaveBeenCalled(); + expect(repository.createQueryBuilder).toHaveBeenCalledWith('whiteboard'); + expect(expiredQueryBuilder.select).toHaveBeenCalledWith( + 'whiteboard.id', + 'id' + ); + // Expiry predicate: draftExpiresAt <= now, the same LessThanOrEqual operator + // cleanupExpired's locked re-read uses. + const whereArg = vi.mocked(expiredQueryBuilder.where).mock + .calls[0]?.[0] as { + draftExpiresAt: FindOperator; + }; + expect(whereArg.draftExpiresAt).toBeInstanceOf(FindOperator); + expect(whereArg.draftExpiresAt.type).toBe('lessThanOrEqual'); + expect(whereArg.draftExpiresAt.value).toBeInstanceOf(Date); + expect(expiredQueryBuilder.orderBy).toHaveBeenCalledWith( + 'whiteboard.draftExpiresAt', + 'ASC' + ); + expect(expiredQueryBuilder.limit).toHaveBeenCalledWith(25); }); }); diff --git a/src/domain/common/whiteboard-draft/whiteboard.draft.service.ts b/src/domain/common/whiteboard-draft/whiteboard.draft.service.ts index b044c2c2da..1775c2696a 100644 --- a/src/domain/common/whiteboard-draft/whiteboard.draft.service.ts +++ b/src/domain/common/whiteboard-draft/whiteboard.draft.service.ts @@ -186,13 +186,27 @@ export class WhiteboardDraftService { async findExpired(limit: number): Promise { // This is the complete cleanup corpus. Ordinary Whiteboards have NULL and // cannot be selected by this query. - const drafts = await this.repository.find({ - select: { id: true }, - where: { draftExpiresAt: LessThanOrEqual(new Date()) }, - order: { draftExpiresAt: 'ASC' }, - take: limit, - }); - return drafts.map(draft => draft.id); + // + // Built as an explicit, join-free query on purpose. A `repository.find` + // that selects only `id` while ordering by `draftExpiresAt` makes TypeORM + // take its distinct-alias pagination path — because Whiteboard has an eager + // `authorization` relation, the LIMIT is applied by wrapping everything in + // `SELECT DISTINCT ... FROM () "distinctAlias"`. The order column is + // appended to the OUTER select/order, but `select: { id: true }` keeps it + // out of the INNER projection, so the outer query references + // `distinctAlias."..._draftExpiresAt"`, a column the subquery never emits + // (Postgres 42703). Selecting only the scalar `id` with no eager join keeps + // the query flat and avoids that path entirely. + const rows = await this.repository + .createQueryBuilder('whiteboard') + .select('whiteboard.id', 'id') + // Same expiry boundary as cleanupExpired's locked re-read, expressed the + // same way, so the two never drift. + .where({ draftExpiresAt: LessThanOrEqual(new Date()) }) + .orderBy('whiteboard.draftExpiresAt', 'ASC') + .limit(limit) + .getRawMany<{ id: string }>(); + return rows.map(row => row.id); } async cleanupExpired(whiteboardID: string): Promise { From 7f78334b43827f9e353d512ff7758170b5f8b25b Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Tue, 8 Sep 2026 08:46:09 +0200 Subject: [PATCH 02/11] fix: replace expired TypeORM preview with durable package (#6472) * fix: repin TypeORM preview dependency * fix: use durable TypeORM package --- package.json | 4 +- pnpm-lock.yaml | 184 ++++++++++++++++++++++++------------------------- 2 files changed, 93 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 169fd4d37e..58c305474f 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "replace-special-characters": "^1.2.7", "rxjs": "^7.8.1", "sharp": "^0.35.3", - "typeorm": "https://pkg.pr.new/antst/typeorm@2c8f380", + "typeorm": "npm:@alkemio/typeorm@0.3.13-cti.1", "uuid": "^13.0.0", "web-push": "^3.6.7", "winston": "^3.13.1", @@ -252,7 +252,7 @@ "@swc/core-linux-arm64-musl": "false" }, "patchedDependencies": { - "typeorm@0.3.13": "patches/typeorm@0.3.13.patch" + "@alkemio/typeorm@0.3.13-cti.1": "patches/typeorm@0.3.13.patch" } }, "volta": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e718252d9b..501def1442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ overrides: '@swc/core-linux-arm64-musl': 'false' patchedDependencies: - typeorm@0.3.13: + '@alkemio/typeorm@0.3.13-cti.1': hash: 900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4 path: patches/typeorm@0.3.13.patch @@ -119,7 +119,7 @@ importers: version: 4.1.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) '@nestjs/typeorm': specifier: 10.0.2 - version: 10.0.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@https://pkg.pr.new/antst/typeorm@2c8f380(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2))) + version: 10.0.2(@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)))(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@ory/kratos-client': specifier: ^26.2.0 version: 26.2.0 @@ -307,8 +307,8 @@ importers: specifier: ^0.35.3 version: 0.35.3(@types/node@22.19.2) typeorm: - specifier: https://pkg.pr.new/antst/typeorm@2c8f380 - version: https://pkg.pr.new/antst/typeorm@2c8f380(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)) + specifier: npm:@alkemio/typeorm@0.3.13-cti.1 + version: '@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2))' uuid: specifier: ^13.0.0 version: 13.0.0 @@ -472,6 +472,64 @@ packages: resolution: {integrity: sha512-4OhPH/HH2M4nXRgvq9UJFOdj8/Hh/Pn5rBv1cN1aRuKWThwNPXHQADbYPJHY/vGJtN7MgpEER7Hcf4G9X0i6AQ==} engines: {node: '>=20.0.0', npm: '>=8.5.5'} + '@alkemio/typeorm@0.3.13-cti.1': + resolution: {integrity: sha512-ylANam3GD5XYVEQPoOr04SLrjBkOFfE8CH1K2CPeHdyOUF9sBGzVAYUL7gDq/7bO/3F42I63v0/j3diuhnAXHg==} + engines: {node: '>= 12.9.0'} + hasBin: true + peerDependencies: + '@google-cloud/spanner': ^5.18.0 + '@sap/hana-client': ^2.12.25 + better-sqlite3: ^7.1.2 || ^8.0.0 + hdb-pool: ^0.1.6 + ioredis: ^5.0.4 + mongodb: ^3.6.0 + mssql: ^9.1.1 + mysql2: ^2.2.5 || ^3.0.1 + oracledb: ^5.1.0 + pg: ^8.5.1 + pg-native: ^3.0.0 + pg-query-stream: ^4.0.0 + redis: ^3.1.1 || ^4.0.0 + sql.js: ^1.4.0 + sqlite3: ^5.0.3 + ts-node: ^10.7.0 + typeorm-aurora-data-api-driver: ^2.0.0 + peerDependenciesMeta: + '@google-cloud/spanner': + optional: true + '@sap/hana-client': + optional: true + better-sqlite3: + optional: true + hdb-pool: + optional: true + ioredis: + optional: true + mongodb: + optional: true + mssql: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + redis: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + ts-node: + optional: true + typeorm-aurora-data-api-driver: + optional: true + '@angular-devkit/core@17.3.11': resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -1852,7 +1910,6 @@ packages: '@nestjs/typeorm@10.0.2': resolution: {integrity: sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==} - version: 10.0.2 peerDependencies: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -6072,65 +6129,6 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@https://pkg.pr.new/antst/typeorm@2c8f380: - resolution: {tarball: https://pkg.pr.new/antst/typeorm@2c8f380} - version: 0.3.13 - engines: {node: '>= 12.9.0'} - hasBin: true - peerDependencies: - '@google-cloud/spanner': ^5.18.0 - '@sap/hana-client': ^2.12.25 - better-sqlite3: ^7.1.2 || ^8.0.0 - hdb-pool: ^0.1.6 - ioredis: ^5.0.4 - mongodb: ^3.6.0 - mssql: ^9.1.1 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^5.1.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 - peerDependenciesMeta: - '@google-cloud/spanner': - optional: true - '@sap/hana-client': - optional: true - better-sqlite3: - optional: true - hdb-pool: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} @@ -6660,6 +6658,33 @@ snapshots: - encoding - supports-color + '@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2))': + dependencies: + '@sqltools/formatter': 1.2.5 + app-root-path: 3.1.0 + buffer: 6.0.3 + chalk: 4.1.2 + cli-highlight: 2.1.11 + debug: 4.4.3 + dotenv: 16.4.5 + glob: 8.1.0 + js-yaml: 4.3.0 + mkdirp: 2.1.6 + reflect-metadata: 0.1.14 + sha.js: 2.4.12 + tslib: 2.8.1 + uuid: 9.0.1 + xml2js: 0.4.23 + yargs: 17.7.2 + optionalDependencies: + ioredis: 5.10.1 + mysql2: 3.15.1 + pg: 8.16.3 + redis: 3.1.2 + ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - supports-color + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': dependencies: ajv: 8.12.0 @@ -8073,13 +8098,13 @@ snapshots: '@nestjs/microservices': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(amqp-connection-manager@4.1.15(amqplib@0.10.9))(amqplib@0.10.9)(cache-manager@5.7.6)(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@https://pkg.pr.new/antst/typeorm@2c8f380(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)))': + '@nestjs/typeorm@10.0.2(@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)))(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.20)(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: https://pkg.pr.new/antst/typeorm@2c8f380(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)) + typeorm: '@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2))' uuid: 9.0.1 '@noble/hashes@1.8.0': {} @@ -12540,33 +12565,6 @@ snapshots: typedarray@0.0.6: {} - typeorm@https://pkg.pr.new/antst/typeorm@2c8f380(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2)): - dependencies: - '@sqltools/formatter': 1.2.5 - app-root-path: 3.1.0 - buffer: 6.0.3 - chalk: 4.1.2 - cli-highlight: 2.1.11 - debug: 4.4.3 - dotenv: 16.4.5 - glob: 8.1.0 - js-yaml: 4.3.0 - mkdirp: 2.1.6 - reflect-metadata: 0.1.14 - sha.js: 2.4.12 - tslib: 2.8.1 - uuid: 9.0.1 - xml2js: 0.4.23 - yargs: 17.7.2 - optionalDependencies: - ioredis: 5.10.1 - mysql2: 3.15.1 - pg: 8.16.3 - redis: 3.1.2 - ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2) - transitivePeerDependencies: - - supports-color - typescript@4.9.5: {} typescript@5.3.3: {} From 0873395eb9d2897302f7aa2e68aa86ad2e73395e Mon Sep 17 00:00:00 2001 From: bobbykolev Date: Wed, 9 Sep 2026 10:17:32 +0300 Subject: [PATCH 03/11] 0.165.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58c305474f..7629b9bb81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alkemio-server", - "version": "0.164.0", + "version": "0.165.0", "description": "Alkemio server, responsible for managing the shared Alkemio platform", "author": "Alkemio Foundation", "private": false, From f66a9183f725037cca7113895f1d64b48d1949c8 Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Wed, 9 Sep 2026 10:05:27 +0200 Subject: [PATCH 04/11] feat: add Cleverbase memo document signing (#6468) * feat: add signing attempt persistence * test: strengthen signing persistence evidence * test: specify memo signing preparation * test: strengthen signing persistence evidence * test: specify memo signing API boundaries * test: retain changed coverage evidence * test: specify actor-bound signing attempt read * test: fail closed on missing coverage targets * test: support shallow CI coverage checkouts * test: address final signing persistence review * feat: prepare memo signing previews * test: retain changed coverage evidence * test: fail closed on missing coverage targets * test: support shallow CI coverage checkouts * test: address final signing persistence review * test: enforce signing preview coverage * chore: update signing preview schema * test: verify signing preview glyph behavior * test: specify memo signing continuation * test: verify signing preview glyph behavior * feat: harden memo signing preparation * fix: map lost memo access in signing preview * test: cover prepare deletion boundaries * test: tie live-read failure to memo deletion * test: label modelled collaboration deletion boundary * test: model collaboration purge error precisely * feat: start memo signing through trust gateway * test: prove memo signing continuation boundaries * fix: close memo signing continuation gates * test: specify memo signing return flow * test: assert safe signing failure logs * fix: address memo signing review findings * test: include signing inputs in scoped coverage * test: report file-service revision mismatch * feat: complete memo signing returns * fix: harden memo signing returns * test: harden memo signing return wiring * fix: validate memo signing gateway responses * feat: wire local memo signing gateway * docs: make local signing fixture reproducible * docs: correct signing gateway eviction check * chore: pin trust gateway v0.2.0 locally * feat: verify signed memo integrity * fix: tighten memo signature verification * docs: add sandbox signing acceptance runbook * docs: correct sandbox signing prerequisites * chore: pin trust gateway v0.2.1 * fix: populate authentication identity in actor context * test: verify actor context identity caching The User authenticationID query runs only on an actor-context cache miss. The complete context, including authenticationID, is retained in the existing per-actor cache entry. * fix: route memo signing previews to server The generated preview URL uses the private REST content-signing prefix. The local quickstart now gives that owned prefix a dedicated GET router instead of falling through to the client catch-all. * docs: verify natural signing expiry The Alkemio browser gate leaves a continued attempt uncompleted and observes the persisted deadline plus sweep outcome. Gateway restart eviction remains covered by the gateway contract tests rather than the local Alkemio journey. --- .build/traefik/http.yml | 21 + .github/workflows/ci-tests.yml | 12 + .../content-signing/test-real-services.sh | 45 + alkemio.yml | 4 + docs/local-content-signing.md | 124 ++ docs/sandbox-content-signing-acceptance.md | 146 ++ package.json | 5 + pnpm-lock.yaml | 158 ++ quickstart-services.yml | 36 + schema.graphql | 63 + src/app.module.ts | 2 + src/common/enums/rest.endpoint.ts | 2 + .../actor.context.service.spec.ts | 20 +- .../actor-context/actor.context.service.ts | 10 +- .../authentication.service.spec.ts | 53 +- .../content-signing/content.signing.module.ts | 11 + .../content-signing/signing.attempt.entity.ts | 50 + .../signing.attempt.interface.ts | 11 + .../signing.attempt.service.spec.ts | 369 ++++ .../signing.attempt.service.ts | 216 +++ .../content-signing/signing.attempt.status.ts | 11 + .../memo/dto/memo.signature.verify.input.ts | 10 + .../memo/dto/memo.signing.continue.input.ts | 10 + .../memo/dto/memo.signing.continue.result.ts | 7 + .../memo/dto/memo.signing.prepare.input.ts | 10 + .../memo/dto/memo.signing.prepare.result.ts | 11 + src/domain/common/memo/memo.module.ts | 21 + .../memo.pdf.renderer.image-budget.spec.ts | 61 + .../memo/memo.pdf.renderer.limits.spec.ts | 135 ++ .../common/memo/memo.pdf.renderer.spec.ts | 458 +++++ src/domain/common/memo/memo.pdf.renderer.ts | 197 ++ .../common/memo/memo.resolver.fields.ts | 51 +- .../memo/memo.resolver.mutations.spec.ts | 57 + .../common/memo/memo.resolver.mutations.ts | 27 + src/domain/common/memo/memo.service.spec.ts | 17 +- src/domain/common/memo/memo.service.ts | 5 +- .../memo/memo.signature.resolver.fields.ts | 37 + .../memo.signature.verification.status.ts | 11 + .../common/memo/memo.signing.resolver.spec.ts | 150 ++ .../common/memo/memo.signing.service.spec.ts | 1297 +++++++++++++ .../common/memo/memo.signing.service.ts | 453 +++++ .../memo/memo.signing.sweep.service.spec.ts | 56 + .../common/memo/memo.signing.sweep.service.ts | 26 + .../storage-bucket/storage.bucket.module.ts | 2 + .../storage.bucket.service.spec.ts | 53 + .../storage-bucket/storage.bucket.service.ts | 20 +- .../1788609600000-CreateSigningAttempt.ts | 47 + .../file.service.adapter.spec.ts | 35 + .../file.service.adapter.ts | 29 +- .../trust.gateway.client.spec.ts | 304 ++++ .../trust-gateway/trust.gateway.client.ts | 165 ++ .../content.signing.controller.spec.ts | 228 +++ .../content.signing.controller.ts | 100 ++ .../content-signing/content.signing.module.ts | 11 + .../content.signing.return.filter.spec.ts | 77 + .../content.signing.return.filter.ts | 19 + .../kratos/kratos.service.spec.ts | 59 + .../infrastructure/kratos/kratos.service.ts | 16 +- .../url.generator.service.spec.ts | 14 + .../url-generator/url.generator.service.ts | 7 + src/types/alkemio.config.ts | 3 + test/integration/content-signing/compose.yml | 38 + .../content-signing/quickstart.config.spec.ts | 124 ++ .../renderer.performance.spec.ts | 119 ++ .../signing-attempt.postgres.spec.ts | 1600 +++++++++++++++++ .../content-signing/vitest.coverage.config.ts | 103 ++ 66 files changed, 7625 insertions(+), 24 deletions(-) create mode 100755 .scripts/content-signing/test-real-services.sh create mode 100644 docs/local-content-signing.md create mode 100644 docs/sandbox-content-signing-acceptance.md create mode 100644 src/domain/common/content-signing/content.signing.module.ts create mode 100644 src/domain/common/content-signing/signing.attempt.entity.ts create mode 100644 src/domain/common/content-signing/signing.attempt.interface.ts create mode 100644 src/domain/common/content-signing/signing.attempt.service.spec.ts create mode 100644 src/domain/common/content-signing/signing.attempt.service.ts create mode 100644 src/domain/common/content-signing/signing.attempt.status.ts create mode 100644 src/domain/common/memo/dto/memo.signature.verify.input.ts create mode 100644 src/domain/common/memo/dto/memo.signing.continue.input.ts create mode 100644 src/domain/common/memo/dto/memo.signing.continue.result.ts create mode 100644 src/domain/common/memo/dto/memo.signing.prepare.input.ts create mode 100644 src/domain/common/memo/dto/memo.signing.prepare.result.ts create mode 100644 src/domain/common/memo/memo.pdf.renderer.image-budget.spec.ts create mode 100644 src/domain/common/memo/memo.pdf.renderer.limits.spec.ts create mode 100644 src/domain/common/memo/memo.pdf.renderer.spec.ts create mode 100644 src/domain/common/memo/memo.pdf.renderer.ts create mode 100644 src/domain/common/memo/memo.signature.resolver.fields.ts create mode 100644 src/domain/common/memo/memo.signature.verification.status.ts create mode 100644 src/domain/common/memo/memo.signing.resolver.spec.ts create mode 100644 src/domain/common/memo/memo.signing.service.spec.ts create mode 100644 src/domain/common/memo/memo.signing.service.ts create mode 100644 src/domain/common/memo/memo.signing.sweep.service.spec.ts create mode 100644 src/domain/common/memo/memo.signing.sweep.service.ts create mode 100644 src/migrations/1788609600000-CreateSigningAttempt.ts create mode 100644 src/services/adapters/trust-gateway/trust.gateway.client.spec.ts create mode 100644 src/services/adapters/trust-gateway/trust.gateway.client.ts create mode 100644 src/services/api-rest/content-signing/content.signing.controller.spec.ts create mode 100644 src/services/api-rest/content-signing/content.signing.controller.ts create mode 100644 src/services/api-rest/content-signing/content.signing.module.ts create mode 100644 src/services/api-rest/content-signing/content.signing.return.filter.spec.ts create mode 100644 src/services/api-rest/content-signing/content.signing.return.filter.ts create mode 100644 test/integration/content-signing/compose.yml create mode 100644 test/integration/content-signing/quickstart.config.spec.ts create mode 100644 test/integration/content-signing/renderer.performance.spec.ts create mode 100644 test/integration/content-signing/signing-attempt.postgres.spec.ts create mode 100644 test/integration/content-signing/vitest.coverage.config.ts diff --git a/.build/traefik/http.yml b/.build/traefik/http.yml index 06a4599195..7624504243 100644 --- a/.build/traefik/http.yml +++ b/.build/traefik/http.yml @@ -25,6 +25,11 @@ http: servers: - url: 'http://host.docker.internal:3001/' + trust-gateway: + loadBalancer: + servers: + - url: 'http://trust-gateway:8080/' + hydra: loadBalancer: servers: @@ -234,6 +239,13 @@ http: trustForwardHeader: true routers: + trust-gateway-callback: + rule: 'Method(`GET`) && Path(`/oauth/cleverbase/callback`)' + service: 'trust-gateway' + entryPoints: + - 'web' + priority: 200 + oidc-public: rule: 'PathPrefix(`/oidc`)' service: 'oidc-service' @@ -449,6 +461,15 @@ http: - 'web' priority: 150 + content-signing-snapshot: + rule: 'Method(`GET`) && PathPrefix(`/api/private/rest/content-signing/`)' + service: 'alkemio-server' + middlewares: + - strip-api-private-prefix + entryPoints: + - 'web' + priority: 150 + kratos-public: rule: 'PathPrefix(`/ory/kratos/public`)' service: 'kratos-public' diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index d94eab458d..9daf7a52ae 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -38,6 +38,18 @@ jobs: timeout-minutes: 10 run: pnpm run test:ci + - name: Checkout pinned file-service for content-signing integration + uses: actions/checkout@v7 + with: + repository: alkem-io/file-service + ref: 0a3995b235ef427c9d7cfd1092e7945e5244c137 + path: .content-signing-file-service + + - name: Run content-signing real-service integration tests + run: | + docker build --label org.opencontainers.image.source=https://github.com/alkem-io/file-service --label org.opencontainers.image.revision=0a3995b235ef427c9d7cfd1092e7945e5244c137 --tag aiai2025-file-service-pr1:0a3995 .content-signing-file-service + CONTENT_SIGNING_FILE_SERVICE_IMAGE=aiai2025-file-service-pr1:0a3995 pnpm run test:content-signing:coverage + - name: Upload coverage artifact if: always() uses: actions/upload-artifact@v7 diff --git a/.scripts/content-signing/test-real-services.sh b/.scripts/content-signing/test-real-services.sh new file mode 100755 index 0000000000..9b38b98a62 --- /dev/null +++ b/.scripts/content-signing/test-real-services.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "${script_dir}/../.." && pwd)" +compose_file="${repo_dir}/test/integration/content-signing/compose.yml" +project_name="content-signing-${PPID}-${RANDOM}" + +export CONTENT_SIGNING_DB_PORT="${CONTENT_SIGNING_DB_PORT:-55426}" +export CONTENT_SIGNING_FILE_SERVICE_PORT="${CONTENT_SIGNING_FILE_SERVICE_PORT:-44003}" +export CONTENT_SIGNING_FILE_SERVICE_IMAGE="${CONTENT_SIGNING_FILE_SERVICE_IMAGE:-aiai2025-file-service-pr1@sha256:fdd302dd8c3f1a272d7215237aec5bc246edcdaf52cce33193b79be968c55144}" +expected_file_service_revision="0a3995b235ef427c9d7cfd1092e7945e5244c137" + +image_revision="$(docker image inspect "${CONTENT_SIGNING_FILE_SERVICE_IMAGE}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" +if [[ "${image_revision}" != "${expected_file_service_revision}" ]]; then + echo "file-service image revision mismatch: expected ${expected_file_service_revision}, got ${image_revision:-}" >&2 + exit 1 +fi +cleanup() { + docker compose --project-name "${project_name}" --file "${compose_file}" down --volumes --remove-orphans +} +trap cleanup EXIT + +docker compose --project-name "${project_name}" --file "${compose_file}" up --detach --wait + +for _ in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}/health" >/dev/null; then + break + fi + sleep 1 +done +curl --fail --silent "http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}/health" >/dev/null + +export CONTENT_SIGNING_REAL_SERVICES=true +export CONTENT_SIGNING_DB_HOST=127.0.0.1 +export CONTENT_SIGNING_DB_NAME=content_signing +export CONTENT_SIGNING_DB_USER=content_signing +export CONTENT_SIGNING_DB_PASSWORD=content_signing +export CONTENT_SIGNING_FILE_SERVICE_URL="http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}" + +cd "${repo_dir}" +if ! pnpm exec vitest run test/integration/content-signing/signing-attempt.postgres.spec.ts "$@"; then + docker compose --project-name "${project_name}" --file "${compose_file}" logs file-service + exit 1 +fi diff --git a/alkemio.yml b/alkemio.yml index bf69c9c1c8..62ba1d9050 100644 --- a/alkemio.yml +++ b/alkemio.yml @@ -99,6 +99,10 @@ search: # uploaded can always be parsed. Raising it above that has no effect. collabora_document_max_source_size: ${SEARCH_COLLABORA_DOCUMENT_MAX_SOURCE_SIZE}:15728640 +trustGateway: + # Local host-run default; deployments must set TRUST_GATEWAY_URL to the cluster Service. + url: ${TRUST_GATEWAY_URL}:http://localhost:8080 + licensing: wingback: enabled: ${LICENSING_WINGBACK_ENABLED}:false diff --git a/docs/local-content-signing.md b/docs/local-content-signing.md new file mode 100644 index 0000000000..5b93d12663 --- /dev/null +++ b/docs/local-content-signing.md @@ -0,0 +1,124 @@ +# Local memo signing + +This fixture uses synthetic keys and an RFC 3161 TSA. It proves Alkemio wiring and B-T PDF +integrity, not real Cleverbase subject equivalence, certificate trust, revocation or QES status. +The host and the existing development stack are the local trust boundary: the gateway and mock +publish only on loopback, but sibling containers share `alkemio_dev_net`. + +## Start the fixture + +The Compose file pins trust-gateway v0.2.0 and the Cleverbase reference mock by digest. Start the +normal quickstart with fresh storage, then run the server and client on their usual host ports. +Docker, pnpm and `jq` are prerequisites. `COMPOSE_PROJECT_NAME` names only this task-owned fresh +stack; never run the volume-removal command against a default or developer project. + +```bash +export COMPOSE_PROJECT_NAME=aiai-2025-content-signing +docker compose -p "$COMPOSE_PROJECT_NAME" -f quickstart-services.yml \ + --env-file .env.docker down -v --remove-orphans +pnpm start:services +pnpm migration:run +pnpm start:dev +``` + +In a second terminal, from the client-web feature checkout, start the host client on port 3001: + +```bash +pnpm start +``` + +The host-run server uses `http://localhost:8080`; Traefik routes only exact GET requests for +`/oauth/cleverbase/callback` to the gateway. `/v1/sign/*` has no Traefik route. Check both pinned +containers before using the UI: + +```bash +curl -fsS http://127.0.0.1:8080/readyz +curl -fsS http://127.0.0.1:9000/healthz +``` + +## Link the local admin identity + +Import the mock subject through Kratos's normal admin identity API. The update explicitly +re-imports the local password credential, so browser login remains available. Run this only on a +fresh local Kratos database; it is not a production identity bypass. + +```bash +export SIGNING_EMAIL=admin@alkem.io SIGNING_PASSWORD=password +export KRATOS_ADMIN=http://localhost:3000/ory/kratos/admin +identity_id=$(curl -fsS --get "$KRATOS_ADMIN/identities" \ + --data-urlencode "credentials_identifier=$SIGNING_EMAIL" | jq -er '.[0].id') +identity=$(curl -fsS "$KRATOS_ADMIN/identities/$identity_id") +body=$(jq --arg password "$SIGNING_PASSWORD" ' + {schema_id,state,traits,metadata_admin,metadata_public, + credentials:{password:{config:{password:$password}}, + oidc:{config:{providers:[{provider:"cleverbase",subject:"PNONL-123"}]}}}}' \ + <<<"$identity") +curl -fsS -X PUT "$KRATOS_ADMIN/identities/$identity_id" \ + -H 'Content-Type: application/json' --data "$body" >/dev/null +curl -fsS "$KRATOS_ADMIN/identities/$identity_id?include_credential=oidc" | + jq -e '.credentials.password != null and + (.credentials.oidc.identifiers | index("cleverbase:PNONL-123") != null)' +``` + +`PNONL-123` is the provider subject from the +[SDK mock-signer contract](https://github.com/alkem-io/cleverbase-sdk/blob/develop/examples/reference-integration/mock-upstream/README.md), +not the X.509 certificate serial. The complete environment recipe is owned by the +[gateway v0.2.0 local-stack documentation](https://github.com/alkem-io/trust-gateway/blob/v0.2.0/README.md#local-alkemio-stack-mock-and-public-stub). + +## Verify the journey + +Log in at `http://localhost:3000/login` with the seeded local admin, open a memo and select **Sign**. +Record these checks: + +1. Unsaved collaboration changes become durable before prepare; the same-origin preview iframe + shows the exact PDF with `Content-Disposition: inline`, no `X-Frame-Options: DENY`, and no + `frame-ancestors 'none'` on the response. +2. Continue traverses both mock authorization redirects and returns through + `/api/public/rest/content-signing/complete`; the final memo URL contains only + `signingAttemptId=` for the signing outcome. +3. Download the signed PDF and run `pdfsig `: the signature is valid and the timestamp is + present. The UI's **Recorded** value is the server `updatedDate` and stays unchanged on reload. +4. Sign the memo again: a second copy is appended and the first is unchanged. +5. For login restoration, log out before following the terminal gateway return, then log in again; + the original REST return URL completes and redirects to the memo. +6. For decline, copy the first mock authorization URL from the browser before following it, then + run the commands below. The attempt becomes cancelled without a signed document. +7. For natural expiry, create and continue another attempt, copy its ID, then leave it uncompleted: + do not follow its authorization URL or complete its callback. Read the gateway's authoritative + `expiresAt` from the persisted attempt as shown below. After that instant, the one-minute expiry + margin and the next hourly sweep, the actor-bound query reports `EXPIRED` without an attached + result; this is not an immediate-expiry check. + +Read either terminal state through the actor-bound GraphQL query. Copy the session cookie request +header from the browser devtools into the local shell without committing or printing it: + +```bash +export SIGNING_ATTEMPT_ID='' +export ALKEMIO_SESSION_COOKIE='' +read_attempt() { + jq -nc --arg id "$SIGNING_ATTEMPT_ID" \ + '{query:"query($id: UUID!) { signingAttempt(ID: $id) { id status } }",variables:{id:$id}}' | + curl -fsS http://localhost:3000/graphql -H 'Content-Type: application/json' \ + -H "Cookie: $ALKEMIO_SESSION_COOKIE" --data-binary @- | + jq -e '.data.signingAttempt | {id,status}' +} + +authorize_url='' +state=$(jq -nr --arg url "$authorize_url" '$url | capture("[?&]state=(?[^&]+)").value') +curl -fsS -o /dev/null -D - \ + "http://localhost:3000/oauth/cleverbase/callback?state=$state&error=access_denied" +read_attempt + +# Prepare and continue a new attempt, do not visit its authorization URL, then set its ID here. +export SIGNING_ATTEMPT_ID='' +read_attempt +expires_at=$(docker compose -p "$COMPOSE_PROJECT_NAME" -f quickstart-services.yml \ + --env-file .env.docker exec -T postgres psql -U synapse -d alkemio -Atc \ + "SELECT \"expiresAt\" FROM signing_attempt WHERE id = '$SIGNING_ATTEMPT_ID'") +printf 'wait until after %s + 1 minute, then allow up to one hour for the sweep\n' "$expires_at" +# After that bounded wait: +read_attempt +``` + +Live Cleverbase needs the real client/TSA credentials and a confirmed subject mapping supplied out +of band. It uses no mock container and must not place credentials in this repository. diff --git a/docs/sandbox-content-signing-acceptance.md b/docs/sandbox-content-signing-acceptance.md new file mode 100644 index 0000000000..a31136ea9d --- /dev/null +++ b/docs/sandbox-content-signing-acceptance.md @@ -0,0 +1,146 @@ +# SANDBOX memo-signing acceptance + +Run this after the trust-gateway overlay, the server feature and the client feature are deployed to +SANDBOX. An operator-configured, signing-compatible Cleverbase OIDC provider and a provider-confirmed +OIDC-subject-to-certificate mapping are prerequisites; stop if either is unavailable. Do not infer +that mapping from the Wallet Connection Suite's pairwise subject. Use an enrolled Cleverbase +acceptance signer. This is an acceptance-only B-T journey using the public, non-qualified +`https://thameur.org/tsa`; it is not evidence of qualified status, chain trust or revocation. + +## Prepare private evidence + +Docker, `jq`, `kubectl`, `pdfsig`, Bash and a browser with developer tools are prerequisites. Run the +snippets in Bash. Keep the session cookie, authorize URL, client state, OIDC subject and certificate +details out of Git, PRs, screenshots and terminal history. + +```bash +export SANDBOX_CONTEXT="$(kubectl config current-context)" +test "$SANDBOX_CONTEXT" = k8s-hetzner-sandbox +export EVIDENCE_DIR='/absolute/operator-owned/path/sandbox-signing-acceptance' +install -d -m 0700 "$EVIDENCE_DIR" + +graphql() { + curl -fsS https://sandbox-alkem.io/api/public/graphql \ + -H 'Content-Type: application/json' \ + -H "Cookie: $ALKEMIO_SESSION_COOKIE" --data-binary @- +} + +read_attempt() { + jq -nc --arg id "$SIGNING_ATTEMPT_ID" \ + '{query:"query($id: UUID!) { signingAttempt(ID: $id) { id status } }",variables:{id:$id}}' | + graphql | tee "$EVIDENCE_DIR/attempt-$1.json" +} + +read_attempt_row() { + printf "SELECT id,status,\"snapshotDocumentId\",\"correlationId\",\"expiresAt\",\"signedDocumentId\",\"updatedDate\" FROM signing_attempt WHERE id = '%s';\n" \ + "$SIGNING_ATTEMPT_ID" | + kubectl --context "$SANDBOX_CONTEXT" -n default exec -i deploy/postgres -c postgres -- \ + sh -lc 'psql -X --csv -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$DATABASE_NAME"' | + tee "$EVIDENCE_DIR/attempt-$1.csv" +} +``` + +## Link the real identity + +1. In a private browser window, open `https://sandbox-alkem.io/login`, select **Cleverbase**, and + finish the real OIDC flow. Stop if Cleverbase is not offered. Do not use the local Kratos seed or + the Wallet Connection Suite. +2. After Alkemio opens, copy that browser session's Cookie request header into the shell without + printing or storing it, then confirm the linked provider through the server's normal mapping: + + ```bash + read -rsp 'Paste the authenticated browser Cookie request header: ' ALKEMIO_SESSION_COOKIE + printf '\n' + jq -nc '{query:"{ me { user { id authentication { methods } } } }"}' | + graphql | tee "$EVIDENCE_DIR/authentication-methods.json" | + jq -e '.data.me.user.authentication.methods | index("CLEVERBASE") != null' + ``` + +3. Capture a screenshot of the logged-in Alkemio profile without the browser address bar. The + `CLEVERBASE` method proves only that the provider is linked; it does not prove the signing + identity mapping. + +## Complete one signature + +1. Create a memo in a test Space where this user has `CONTRIBUTE`, enter unique acceptance text, + save it, select **Sign**, and confirm the exact text in the inline PDF preview. Save a screenshot. +2. From the `prepareMemoSigning` response in browser developer tools, copy only `attemptId`: + + ```bash + export SIGNING_ATTEMPT_ID='' + read_attempt prepared + read_attempt_row prepared + ``` + + Expect `PENDING`, a snapshot document, and null correlation, expiry and signed-document fields. +3. Select **Continue** once. Record the `continueMemoSigning` response as a screenshot with the + authorize URL redacted, then begin the Cleverbase Wallet consent. While consent is open, run: + + ```bash + read_attempt continued + read_attempt_row continued + ``` + + Expect `PENDING`, the same snapshot, and non-null `correlationId` and `expiresAt`. The CSV is the + gateway correlation evidence; Cleverbase does not return a separate request-id header. +4. Complete consent. The browser must return through + `/api/public/rest/content-signing/complete` to the memo URL with + `signingAttemptId=`. Save the success screenshot, then run: + + ```bash + read_attempt signed + read_attempt_row signed + ``` + + Expect `SIGNED`, a null snapshot and a non-null signed document. Reload and confirm the UI's + **Recorded** value is unchanged. +5. Download the signed PDF as `$EVIDENCE_DIR/signed.pdf`, then capture its digest and PDF signature: + + ```bash + shasum -a 256 "$EVIDENCE_DIR/signed.pdf" | tee "$EVIDENCE_DIR/signed.sha256" + pdfsig "$EVIDENCE_DIR/signed.pdf" | tee "$EVIDENCE_DIR/pdfsig.txt" + ``` + +6. Select **Verify** once in Alkemio and save the integrity-only verdict screenshot. Then run this + additional direct GraphQL check: + + ```bash + jq -nc --arg id "$SIGNING_ATTEMPT_ID" \ + '{query:"query($input: MemoSignatureVerifyInput!) { verifyMemoSignature(verificationData: $input) }",variables:{input:{attemptID:$id}}}' | + graphql | tee "$EVIDENCE_DIR/alkemio-verify.json" | + jq -e '.data.verifyMemoSignature == "VERIFIED"' + ``` + +7. Capture the gateway's private `/v1/verify` result in a second terminal. Keep this raw file private + because it contains certificate attribution that Alkemio intentionally does not expose: + + ```bash + kubectl --context "$SANDBOX_CONTEXT" -n default port-forward service/trust-gateway 18080:8080 + ``` + + ```bash + base64 < "$EVIDENCE_DIR/signed.pdf" | tr -d '\n' | + jq -Rs '{document:.}' > "$EVIDENCE_DIR/verify-request.json" + curl -fsS http://127.0.0.1:18080/v1/verify \ + -H 'Content-Type: application/json' \ + --data-binary "@$EVIDENCE_DIR/verify-request.json" | + tee "$EVIDENCE_DIR/gateway-verify.json" | + jq -e '.integrity == true and .profile == "B-T" and .reasons == []' + rm "$EVIDENCE_DIR/verify-request.json" + ``` + +## Exercise terminal aborts + +- **Decline:** prepare and continue a new attempt, set `SIGNING_ATTEMPT_ID` to its ID, then decline + in the Wallet. After the browser returns, run `read_attempt declined` and + `read_attempt_row declined`. Expect `CANCELLED`, a null snapshot and no signed document; save the + returned memo screenshot. +- **Expiry sweep:** prepare and continue another new attempt, record its row as `expiry-started`, + then abandon the Wallet without changing the database or gateway. Wait until its recorded + `expiresAt`, the one-minute margin, and the next hourly sweep. Run `read_attempt expired` and + `read_attempt_row expired`; expect `EXPIRED`, a null snapshot and no signed document. + +Finally unset `ALKEMIO_SESSION_COOKIE`, stop the port-forward, and retain the private directory. In +the PR or acceptance report publish only the attempt-status sequence, correlation ID, PDF SHA-256, +the three-state Alkemio Verify result and redacted screenshots. Never publish the cookie, authorize +URL, OIDC subject, client state, raw gateway response or certificate details. diff --git a/package.json b/package.json index 58c305474f..960c10f5b4 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "test:debug": "vitest --inspect-brk", "test:ci": "vitest run --coverage && cat ./coverage-ci/lcov.info", "test:ci:no:coverage": "vitest run", + "test:content-signing:real-services": ".scripts/content-signing/test-real-services.sh", + "test:content-signing:coverage": ".scripts/content-signing/test-real-services.sh src/domain/common/content-signing/signing.attempt.service.spec.ts src/domain/common/memo/memo.pdf.renderer.spec.ts src/domain/common/memo/memo.pdf.renderer.image-budget.spec.ts src/domain/common/memo/memo.pdf.renderer.limits.spec.ts src/domain/common/memo/memo.resolver.mutations.spec.ts src/domain/common/memo/memo.signing.resolver.spec.ts src/domain/common/memo/memo.signing.service.spec.ts src/domain/common/memo/memo.signing.sweep.service.spec.ts src/domain/common/memo/memo.service.spec.ts src/domain/storage/storage-bucket/storage.bucket.service.spec.ts src/services/adapters/file-service-adapter/file.service.adapter.spec.ts src/services/adapters/trust-gateway/trust.gateway.client.spec.ts src/services/api-rest/content-signing/content.signing.controller.spec.ts src/services/api-rest/content-signing/content.signing.return.filter.spec.ts src/services/infrastructure/kratos/kratos.service.spec.ts src/services/infrastructure/url-generator/url.generator.service.spec.ts test/integration/content-signing/renderer.performance.spec.ts --config=test/integration/content-signing/vitest.coverage.config.ts --coverage", "test:flake-verify": ".scripts/test/flake-verify.sh", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/config/migration.create.config.ts", "typeorm-no-entities": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/config/migration.config.ts", @@ -115,6 +117,7 @@ "graphql-ws": "^5.6.2", "heic-convert": "^2.1.0", "helmet": "^4.6.0", + "html-to-pdfmake": "2.5.34", "i18n-iso-countries": "^7.14.0", "ioredis": "^5.10.1", "jose": "^5.10.0", @@ -133,6 +136,7 @@ "passport-custom": "^1.1.1", "passport-jwt": "^4.0.1", "path-to-regexp": "^8.4.2", + "pdfmake": "0.3.11", "pg": "^8.13.1", "prosemirror-markdown": "^1.13.2", "prosemirror-model": "^1.25.3", @@ -184,6 +188,7 @@ "coveralls": "^3.1.1", "husky": "^9.1.7", "lint-staged": "^15.2.7", + "pdfjs-dist": "5.6.205", "rimraf": "^6.0.1", "supertest": "^7.0.0", "ts-node": "^10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 501def1442..9ddbc77107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: helmet: specifier: ^4.6.0 version: 4.6.0 + html-to-pdfmake: + specifier: 2.5.34 + version: 2.5.34 i18n-iso-countries: specifier: ^7.14.0 version: 7.14.0 @@ -282,6 +285,9 @@ importers: path-to-regexp: specifier: ^8.4.2 version: 8.4.2 + pdfmake: + specifier: 0.3.11 + version: 0.3.11 pg: specifier: ^8.13.1 version: 8.16.3 @@ -430,6 +436,9 @@ importers: lint-staged: specifier: ^15.2.7 version: 15.5.2 + pdfjs-dist: + specifier: 5.6.205 + version: 5.6.205 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -1917,6 +1926,10 @@ packages: rxjs: ^7.2.0 typeorm: ^0.3.0 + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -3049,6 +3062,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3106,6 +3123,12 @@ packages: breadth-filter@2.0.0: resolution: {integrity: sha512-thQShDXnFWSk2oVBixRCyrWsFoV5tfOpWKHmxwafHQDNxCfDBk539utpvytNjmlFrTMqz41poLwJvA1MW3z0MQ==} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.26.2: resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3297,6 +3320,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -3585,6 +3612,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diacritics@1.3.0: resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} @@ -3924,6 +3954,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4210,6 +4243,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-pdfmake@2.5.34: + resolution: {integrity: sha512-NCutnZWrOA2MJYiqXG8/x1g7gneZu4LlhzdExtKS1tVHyjxgn91gENV/RXP+avqRcSUYx9kMR3dUEfv8C5UW5Q==} + http-errors@1.8.1: resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} engines: {node: '>= 0.6'} @@ -4490,6 +4526,9 @@ packages: jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4609,6 +4648,9 @@ packages: limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -5121,6 +5163,12 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -5234,6 +5282,13 @@ packages: resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + pdfkit@0.19.1: + resolution: {integrity: sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==} + + pdfmake@0.3.11: + resolution: {integrity: sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==} + engines: {node: '>=20'} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -5313,6 +5368,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -5585,6 +5643,9 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -5646,6 +5707,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -5998,6 +6063,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6192,9 +6260,15 @@ packages: unicode-byte-truncate@1.0.0: resolution: {integrity: sha512-GQgHk6DodEoKddKQdjnv7xKS9G09XCfHWX0R4RKht+EbUMSiVEmtWHGFO8HUm+6NvWik3E2/DG4MxTitOLL64A==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + unicode-substring@0.1.0: resolution: {integrity: sha512-36Xaw9wXi7MB/3/EQZZHkZyyiRNa9i3k9YtPAz2KfqMVH2xutdXyMHn4Igarmnvr+wOrfWa/6njhY+jPpXN2EQ==} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -6542,6 +6616,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmldoc@2.0.3: + resolution: {integrity: sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==} + engines: {node: '>=12.0.0'} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -8107,6 +8185,8 @@ snapshots: typeorm: '@alkemio/typeorm@0.3.13-cti.1(patch_hash=900fefc861876e5a2e5379bc9477be4434233cdbad5d3043cf8c810b26cc80a4)(ioredis@5.10.1)(mysql2@3.15.1)(pg@8.16.3)(redis@3.1.2)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@22.19.2)(@typescript/typescript6@6.0.2))' uuid: 9.0.1 + '@noble/ciphers@1.3.0': {} + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -9212,6 +9292,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.8.7: {} @@ -9292,6 +9374,14 @@ snapshots: dependencies: object.entries: 1.1.9 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.26.2: dependencies: baseline-browser-mapping: 2.8.7 @@ -9529,6 +9619,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + cluster-key-slot@1.1.2: {} color-convert@1.9.3: @@ -9783,6 +9875,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dfa@1.2.0: {} + diacritics@1.3.0: {} dicer@0.3.0: @@ -10224,6 +10318,18 @@ snapshots: follow-redirects@1.16.0: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.17 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10520,6 +10626,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-pdfmake@2.5.34: {} + http-errors@1.8.1: dependencies: depd: 1.1.2 @@ -10829,6 +10937,8 @@ snapshots: jpeg-js@0.4.4: {} + js-md5@0.8.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10964,6 +11074,11 @@ snapshots: limiter@1.1.5: {} + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} linkify-it@5.0.2: @@ -11430,6 +11545,10 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@0.2.9: {} + + pako@1.0.11: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -11540,6 +11659,21 @@ snapshots: '@napi-rs/canvas': 0.1.100 node-readable-to-web-readable-stream: 0.4.2 + pdfkit@0.19.1: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fontkit: 2.0.4 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 1.1.0 + + pdfmake@0.3.11: + dependencies: + linebreak: 1.1.0 + pdfkit: 0.19.1 + xmldoc: 2.0.3 + performance-now@2.1.0: {} pg-cloudflare@1.2.7: @@ -11614,6 +11748,10 @@ snapshots: pluralize@8.0.0: {} + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + pngjs@6.0.0: {} possible-typed-array-names@1.1.0: {} @@ -11927,6 +12065,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + restructure@3.0.2: {} + retry@0.13.1: {} reusify@1.1.0: {} @@ -12007,6 +12147,8 @@ snapshots: sax@1.4.1: {} + sax@1.6.1: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -12432,6 +12574,8 @@ snapshots: through@2.3.8: {} + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinycolor2@1.6.0: {} @@ -12627,8 +12771,18 @@ snapshots: is-integer: 1.0.7 unicode-substring: 0.1.0 + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + unicode-substring@0.1.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + universalify@0.2.0: {} universalify@2.0.1: {} @@ -12952,6 +13106,10 @@ snapshots: xmlchars@2.2.0: {} + xmldoc@2.0.3: + dependencies: + sax: 1.6.1 + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/quickstart-services.yml b/quickstart-services.yml index 3ea5c7c633..c79d289013 100644 --- a/quickstart-services.yml +++ b/quickstart-services.yml @@ -43,6 +43,42 @@ services: source: ./.build/traefik/ target: /etc/traefik/ + trust-gateway: + image: alkemio/trust-gateway@sha256:68035416db3fdb89aeaf2cc2f9e93a8906415cca0093edaa5dacf5d910382a39 + depends_on: + - cleverbase-refmock + environment: + - TRUST_GATEWAY_MODE=fixtures + - TRUST_GATEWAY_ENV=acceptance + - TRUST_GATEWAY_CSC_API=v1_rsa + - TRUST_GATEWAY_CLIENT_ID=trust-gateway-fixtures + - TRUST_GATEWAY_CLIENT_SECRET=fixtures + - TRUST_GATEWAY_REDIRECT_URI=http://localhost:3000/oauth/cleverbase/callback + - TRUST_GATEWAY_RETURN_URL=http://localhost:3000/api/public/rest/content-signing/complete + - TRUST_GATEWAY_AUTH_DISABLED=true + - TRUST_GATEWAY_DEFAULT_CONFORMANCE=B-B + - TRUST_GATEWAY_SESSION_TTL=15m + - TRUST_GATEWAY_LISTEN=:8080 + - TRUST_GATEWAY_BASE_URL=http://cleverbase-refmock:9000 + - TRUST_GATEWAY_PUBLIC_BASE_URL=http://localhost:9000 + - TRUST_GATEWAY_TSA_URL=http://cleverbase-refmock:9000/tsr + restart: unless-stopped + networks: + - alkemio_dev_net + ports: + # Host-run server: loopback keeps this off the LAN, but sibling containers on the + # trusted dev network can reach it. Kubernetes NetworkPolicy isolates deployments. + - '127.0.0.1:8080:8080' + + cleverbase-refmock: + image: ghcr.io/alkem-io/cleverbase-refmock@sha256:271f70ee82e8114c0fc03f45788512d5d8f54a9a4fb3c3d7b33057781233fee2 + restart: unless-stopped + networks: + - alkemio_dev_net + ports: + # Loopback keeps the mock off the LAN, not from sibling containers in the trusted dev stack. + - '127.0.0.1:9000:9000' + kratos-migrate: container_name: alkemio_dev_kratos_migrate image: oryd/kratos:v26.2.0 diff --git a/schema.graphql b/schema.graphql index 7e849edfe8..b78d2df827 100644 --- a/schema.graphql +++ b/schema.graphql @@ -528,6 +528,12 @@ enum McpApiKeyStatus { REVOKED } +enum MemoSignatureVerificationStatus { + INVALID + UNAVAILABLE + VERIFIED +} + enum MimeType { AVIF BMP @@ -870,6 +876,14 @@ enum SidebarWidget { VIRTUAL_CONTRIBUTORS } +enum SigningAttemptStatus { + CANCELLED + EXPIRED + FAILED + PENDING + SIGNED +} + enum SpaceLevel { L0 L1 @@ -4101,10 +4115,36 @@ type Memo { nameID: NameID! """The Profile for this Memo.""" profile: Profile! + """Signed copies of this Memo visible to readers of the Memo.""" + signatures: [MemoSignature!]! """The date at which the entity was last updated.""" updatedDate: DateTime! } +type MemoSignature { + """The Alkemio user who initiated this signed copy.""" + actor: User + """The date at which the entity was created.""" + createdDate: DateTime! + """The immutable PDF produced for this signed copy.""" + document: Document + """The ID of the entity""" + id: UUID! + """The terminal outcome of this Memo signing attempt.""" + status: SigningAttemptStatus! + """The date at which the entity was last updated.""" + updatedDate: DateTime! +} + +type MemoSigningContinueResult { + authorizeUrl: String! +} + +type MemoSigningPrepareResult { + attemptId: UUID! + previewUrl: String! +} + type MeQueryResults { """ Self-scoped pre-flight read for account deletion: whether the calling user can delete their own account right now, and if not, exactly what blocks them. @@ -4392,6 +4432,8 @@ type Mutation { castPollVote(voteData: CastPollVoteInput!): Poll! """Deletes collections nameID-...""" cleanupCollections: MigrateEmbeddings! + """Starts signing the prepared Memo copy.""" + continueMemoSigning(signingData: MemoSigningContinueInput!): MemoSigningContinueResult! """Move an L1 Space up in the hierarchy, to be a L0 Space.""" convertSpaceL1ToSpaceL0(convertData: ConvertSpaceL1ToSpaceL0Input!): Space! """ @@ -4610,6 +4652,8 @@ type Mutation { Moves a task to another column on its Tasks board. Authorized as MOVE_TASK on the parent Callout, so a board member can move any task. """ moveTaskToColumn(moveData: MoveTaskToColumnInput!): CalloutContribution! + """Prepares an exact PDF preview for signing the specified Memo.""" + prepareMemoSigning(signingData: MemoSigningPrepareInput!): MemoSigningPrepareResult! """Refresh the Bodies of Knowledge on All VCs""" refreshAllBodiesOfKnowledge: Boolean! """ @@ -5735,6 +5779,8 @@ type Query { rolesVirtualContributor(rolesData: RolesActorInput!): ActorRoles! """Search the platform for terms supplied""" search(searchData: SearchInput!): ISearchResults! + """A Memo signing attempt belonging to the current actor.""" + signingAttempt(ID: UUID!): MemoSignature! """ The Spaces on this platform; If accessed through an Innovation Hub will return ONLY the Spaces defined in it. """ @@ -5797,6 +5843,8 @@ type Query { Returns the VAPID public key needed by clients to subscribe to push notifications. Returns null if push notifications are not enabled on this server. """ vapidPublicKey: String + """Checks the stored integrity of a signed Memo copy.""" + verifyMemoSignature(verificationData: MemoSignatureVerifyInput!): MemoSignatureVerificationStatus! """A particular VirtualContributor""" virtualContributor(ID: UUID!): VirtualContributor! """ @@ -9023,6 +9071,21 @@ input LicensingGrantedEntitlementInput { type: LicenseEntitlementType! } +input MemoSignatureVerifyInput { + """The signed Memo attempt to verify.""" + attemptID: UUID! +} + +input MemoSigningContinueInput { + """The prepared signing attempt to start.""" + attemptID: UUID! +} + +input MemoSigningPrepareInput { + """The Memo to prepare for signing.""" + memoID: UUID! +} + input MintMcpApiKeyInput { """Optional expiry. MUST be in the future when supplied.""" expiresAt: DateTime diff --git a/src/app.module.ts b/src/app.module.ts index 00f619b745..7da142bfb7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -68,6 +68,7 @@ import { RolesModule } from '@services/api/roles/roles.module'; import { SearchModule } from '@services/api/search/search.module'; import { UrlResolverModule } from '@services/api/url-resolver/url.resolver.module'; import { CalendarEventIcsModule } from '@services/api-rest/calendar-event-ics/calendar-event-ics.module'; +import { ContentSigningRestModule } from '@services/api-rest/content-signing/content.signing.module'; import { IdentityResolveModule } from '@services/api-rest/identity-resolve/identity-resolve.module'; import { InternalAdminModule } from '@services/api-rest/internal-admin/internal-admin.module'; import { AuthResetSubscriberModule } from '@services/auth-reset/subscriber/auth-reset.subscriber.module'; @@ -306,6 +307,7 @@ import { AdminSearchIngestModule } from './platform-admin/services/search/admin. ContributionReporterModule, InnovationHubModule, CalendarEventIcsModule, + ContentSigningRestModule, IdentityResolveModule, InternalAdminModule, McpServerModule, diff --git a/src/common/enums/rest.endpoint.ts b/src/common/enums/rest.endpoint.ts index 5cb9c1d6c2..8b989dd89b 100644 --- a/src/common/enums/rest.endpoint.ts +++ b/src/common/enums/rest.endpoint.ts @@ -4,4 +4,6 @@ export enum RestEndpoint { COMPLETE_CREDENTIAL_OFFER_INTERACTION = 'completeCredentialOfferInteraction', GEO_LOCATION = 'geo', CALENDAR_EVENT_ICS = 'event/:id/ics', + CONTENT_SIGNING_SNAPSHOT = ':attemptId/snapshot', + CONTENT_SIGNING_COMPLETE = 'complete', } diff --git a/src/core/actor-context/actor.context.service.spec.ts b/src/core/actor-context/actor.context.service.spec.ts index 8fc82c19a6..64b06b471d 100644 --- a/src/core/actor-context/actor.context.service.spec.ts +++ b/src/core/actor-context/actor.context.service.spec.ts @@ -75,7 +75,7 @@ describe('ActorContextService', () => { }); describe('populateFromActorID', () => { - it('sets actorID and credentials on the context', async () => { + it('sets actorID, credentials and the persisted authenticationID for a user', async () => { const ctx = new ActorContext(); const mockCredentials = [{ type: 'global-admin', resourceID: '' }]; @@ -83,11 +83,29 @@ describe('ActorContextService', () => { (actorLookupService.getActorCredentialsOrFail as any).mockResolvedValue( mockCredentials ); + mockEntityManager.findOne.mockResolvedValue({ + authenticationID: 'kratos-id-1', + }); await service.populateFromActorID(ctx, 'actor-123'); expect(ctx.actorID).toBe('actor-123'); expect(ctx.credentials).toBe(mockCredentials); + expect(ctx.authenticationID).toBe('kratos-id-1'); + }); + + it('keeps authenticationID unset for a non-user actor', async () => { + const ctx = new ActorContext(); + const actorLookupService = module.get(ActorLookupService); + (actorLookupService.getActorCredentialsOrFail as any).mockResolvedValue( + [] + ); + mockEntityManager.findOne.mockResolvedValue(null); + + await service.populateFromActorID(ctx, 'actor-123'); + + expect(ctx.actorID).toBe('actor-123'); + expect(ctx.authenticationID).toBeUndefined(); }); }); diff --git a/src/core/actor-context/actor.context.service.ts b/src/core/actor-context/actor.context.service.ts index 6cd1b1b1a5..1e01ac7302 100644 --- a/src/core/actor-context/actor.context.service.ts +++ b/src/core/actor-context/actor.context.service.ts @@ -51,9 +51,9 @@ export class ActorContextService { } /** - * Populates the given ActorContext with credentials from the database. + * Populates the given ActorContext from the database. * Used when actorID is already known (from JWT token or metadata_public). - * Only loads credentials - no user lookup needed. + * Authentication identity is populated only for User actors. */ public async populateFromActorID( ctx: ActorContext, @@ -62,6 +62,12 @@ export class ActorContextService { ctx.actorID = actorID; ctx.credentials = await this.actorLookupService.getActorCredentialsOrFail(actorID); + const user = await this.entityManager.findOne(User, { + where: { id: actorID }, + select: { authenticationID: true }, + loadEagerRelations: false, + }); + ctx.authenticationID = user?.authenticationID ?? undefined; } /** diff --git a/src/core/authentication/authentication.service.spec.ts b/src/core/authentication/authentication.service.spec.ts index be275e93fc..3a57fbfd57 100644 --- a/src/core/authentication/authentication.service.spec.ts +++ b/src/core/authentication/authentication.service.spec.ts @@ -2,11 +2,14 @@ import { ActorContext } from '@core/actor-context/actor.context'; import { ActorContextCacheService } from '@core/actor-context/actor.context.cache.service'; import { ActorContextService } from '@core/actor-context/actor.context.service'; import type { AlkemioSessionPayload } from '@core/auth/oidc/session-store.redis'; +import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; +import { LoggerService } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { MockCacheManager } from '@test/mocks/cache-manager.mock'; import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; +import { EntityManager } from 'typeorm'; import { type Mocked, vi } from 'vitest'; import { AuthenticationService } from './authentication.service'; @@ -141,21 +144,53 @@ describe('AuthenticationService', () => { expect(result).toEqual(cachedContext); }); - it('should create new context when not in cache and load credentials', async () => { - actorContextCacheService.getByActorID.mockResolvedValue(undefined); - actorContextService.populateFromActorID.mockResolvedValue(undefined); - actorContextCacheService.setByActorID.mockImplementation(ctx => - Promise.resolve(ctx) + it('should create and cache a fully populated context when not cached', async () => { + let cachedContext: ActorContext | undefined; + actorContextCacheService.getByActorID.mockImplementation(async () => + Promise.resolve(cachedContext) + ); + actorContextCacheService.setByActorID.mockImplementation(async ctx => { + cachedContext = ctx; + return ctx; + }); + const actorLookupService = { + getActorCredentialsOrFail: vi.fn().mockResolvedValue([]), + } as unknown as ActorLookupService; + const entityManager = { + findOne: vi.fn().mockResolvedValue({ + authenticationID: 'kratos-id-1', + }), + } as unknown as EntityManager; + const logger = { warn: vi.fn() } as unknown as LoggerService; + const realActorContextService = new ActorContextService( + entityManager, + logger, + actorLookupService + ); + const realService = new AuthenticationService( + actorContextCacheService, + realActorContextService, + logger ); - const result = await service.createActorContext('user-id'); + const result = await realService.createActorContext('user-id'); + const cachedResult = await realService.createActorContext('user-id'); - expect(actorContextCacheService.getByActorID).toHaveBeenCalledWith( + expect(actorContextCacheService.getByActorID).toHaveBeenCalledTimes(2); + expect(actorLookupService.getActorCredentialsOrFail).toHaveBeenCalledWith( 'user-id' ); - expect(actorContextService.populateFromActorID).toHaveBeenCalled(); - expect(actorContextCacheService.setByActorID).toHaveBeenCalled(); + expect( + actorLookupService.getActorCredentialsOrFail + ).toHaveBeenCalledTimes(1); + expect(entityManager.findOne).toHaveBeenCalledTimes(1); + expect(actorContextCacheService.setByActorID).toHaveBeenCalledWith( + result + ); + expect(actorContextCacheService.setByActorID).toHaveBeenCalledTimes(1); expect(result.isAnonymous).toBe(false); + expect(result.authenticationID).toBe('kratos-id-1'); + expect(cachedResult).toBe(result); }); it('should fall back to anonymous when the actor is not found in the DB', async () => { diff --git a/src/domain/common/content-signing/content.signing.module.ts b/src/domain/common/content-signing/content.signing.module.ts new file mode 100644 index 0000000000..09071052d2 --- /dev/null +++ b/src/domain/common/content-signing/content.signing.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SigningAttempt } from './signing.attempt.entity'; +import { SigningAttemptService } from './signing.attempt.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([SigningAttempt])], + providers: [SigningAttemptService], + exports: [SigningAttemptService], +}) +export class ContentSigningModule {} diff --git a/src/domain/common/content-signing/signing.attempt.entity.ts b/src/domain/common/content-signing/signing.attempt.entity.ts new file mode 100644 index 0000000000..d33bdefe08 --- /dev/null +++ b/src/domain/common/content-signing/signing.attempt.entity.ts @@ -0,0 +1,50 @@ +import { BaseAlkemioEntity } from '@domain/common/entity/base-entity'; +import { Column, Entity, Index } from 'typeorm'; +import { SigningAttemptStatus } from './signing.attempt.status'; + +@Entity('signing_attempt') +@Index('IDX_signing_attempt_memo_status', ['memoId', 'status']) +@Index('IDX_signing_attempt_status_expiresAt', ['status', 'expiresAt']) +@Index('IDX_signing_attempt_status_createdDate', ['status', 'createdDate']) +@Index('IDX_signing_attempt_snapshotDocumentId', ['snapshotDocumentId']) +@Index('IDX_signing_attempt_signedDocumentId', ['signedDocumentId']) +@Index('UQ_signing_attempt_correlationId', ['correlationId'], { unique: true }) +@Index('UQ_signing_attempt_clientStateHash', ['clientStateHash'], { + unique: true, +}) +export class SigningAttempt extends BaseAlkemioEntity { + @Column('uuid', { nullable: false }) + memoId!: string; + + @Column('uuid', { nullable: false }) + actorId!: string; + + @Column('varchar', { length: 64, nullable: true }) + contentSha256?: string; + + @Column('uuid', { nullable: true }) + snapshotDocumentId?: string | null; + + @Column('text', { nullable: true }) + correlationId?: string; + + @Column('timestamptz', { nullable: true }) + expiresAt?: Date; + + @Column('varchar', { length: 64, nullable: true }) + clientStateHash?: string; + + @Column({ + type: 'enum', + enum: SigningAttemptStatus, + nullable: false, + default: SigningAttemptStatus.PENDING, + }) + status!: SigningAttemptStatus; + + @Column('uuid', { nullable: true }) + signedDocumentId?: string; + + @Column('jsonb', { nullable: true }) + signerEvidence?: object; +} diff --git a/src/domain/common/content-signing/signing.attempt.interface.ts b/src/domain/common/content-signing/signing.attempt.interface.ts new file mode 100644 index 0000000000..ef3f967abe --- /dev/null +++ b/src/domain/common/content-signing/signing.attempt.interface.ts @@ -0,0 +1,11 @@ +import { IBaseAlkemio } from '@domain/common/entity/base-entity/base.alkemio.interface'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { SigningAttemptStatus } from './signing.attempt.status'; + +@ObjectType('MemoSignature') +export abstract class IMemoSignature extends IBaseAlkemio { + @Field(() => SigningAttemptStatus, { + description: 'The terminal outcome of this Memo signing attempt.', + }) + status!: SigningAttemptStatus; +} diff --git a/src/domain/common/content-signing/signing.attempt.service.spec.ts b/src/domain/common/content-signing/signing.attempt.service.spec.ts new file mode 100644 index 0000000000..3f784d7089 --- /dev/null +++ b/src/domain/common/content-signing/signing.attempt.service.spec.ts @@ -0,0 +1,369 @@ +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MockType } from '@test/utils/mock.type'; +import { repositoryProviderMockFactory } from '@test/utils/repository.provider.mock.factory'; +import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { type Mock } from 'vitest'; +import { SigningAttempt } from './signing.attempt.entity'; +import { SigningAttemptService } from './signing.attempt.service'; +import { SigningAttemptStatus } from './signing.attempt.status'; + +describe('SigningAttemptService', () => { + let service: SigningAttemptService; + let repository: MockType>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SigningAttemptService, + repositoryProviderMockFactory(SigningAttempt), + ], + }).compile(); + + service = module.get(SigningAttemptService); + repository = module.get(getRepositoryToken(SigningAttempt)); + }); + + it('creates an unready pending attempt without document or gateway fields', async () => { + const created = { id: 'attempt-1' } as SigningAttempt; + repository.save!.mockResolvedValue(created); + + const result = await service.createUnready('memo-1', 'actor-1'); + + expect(repository.save).toHaveBeenCalledWith({ + memoId: 'memo-1', + actorId: 'actor-1', + status: SigningAttemptStatus.PENDING, + }); + expect(result).toBe(created); + }); + + it('finalizes the prepared snapshot once with lowercase hexadecimal SHA-256', async () => { + repository.update!.mockResolvedValue({ affected: 1 } as any); + const contentSha256 = 'ab'.repeat(32); + + const finalized = await service.finalizePrepared( + 'attempt-1', + 'snapshot-1', + contentSha256 + ); + + expect(repository.update).toHaveBeenCalledWith( + { + id: 'attempt-1', + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: IsNull(), + }, + { + snapshotDocumentId: 'snapshot-1', + contentSha256, + } + ); + expect(finalized).toBe(true); + }); + + it.each([ + 'AB'.repeat(32), + 'ab'.repeat(31), + `${'ab'.repeat(31)}gg`, + ])('rejects non-canonical content SHA-256 %s', async contentSha256 => { + await expect( + service.finalizePrepared('attempt-1', 'snapshot-1', contentSha256) + ).rejects.toThrow(ValidationException); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('reports a lost conditional finalize without overwriting the attempt', async () => { + repository.update!.mockResolvedValue({ affected: 0 } as any); + + await expect( + service.finalizePrepared('attempt-1', 'snapshot-1', 'ab'.repeat(32)) + ).resolves.toBe(false); + }); + + it('claims one pending attempt before gateway start', async () => { + repository.update!.mockResolvedValue({ affected: 1 } as any); + const clientStateHash = 'cd'.repeat(32); + + await expect( + service.claimStart('attempt-1', clientStateHash) + ).resolves.toBe(true); + expect(repository.update).toHaveBeenCalledWith( + { + id: 'attempt-1', + status: SigningAttemptStatus.PENDING, + clientStateHash: IsNull(), + }, + { clientStateHash } + ); + }); + + it('loses a concurrent start claim and rejects a non-canonical state hash', async () => { + repository.update!.mockResolvedValue({ affected: 0 } as any); + + await expect( + service.claimStart('attempt-1', 'cd'.repeat(32)) + ).resolves.toBe(false); + await expect( + service.claimStart('attempt-1', 'CD'.repeat(32)) + ).rejects.toThrow(ValidationException); + }); + + it('persists gateway correlation and authoritative expiry only for its claim', async () => { + repository.update!.mockResolvedValue({ affected: 1 } as any); + const expiresAt = new Date('2026-09-05T16:30:00Z'); + + await expect( + service.recordGatewayStart( + 'attempt-1', + 'cd'.repeat(32), + 'correlation-1', + expiresAt + ) + ).resolves.toBe(true); + expect(repository.update).toHaveBeenCalledWith( + { + id: 'attempt-1', + status: SigningAttemptStatus.PENDING, + clientStateHash: 'cd'.repeat(32), + correlationId: IsNull(), + expiresAt: IsNull(), + }, + { correlationId: 'correlation-1', expiresAt } + ); + }); + + it('deletes every attempt owned by a memo', async () => { + repository.delete!.mockResolvedValue({ affected: 2 } as any); + + await service.deleteForMemo('memo-1'); + + expect(repository.delete).toHaveBeenCalledWith({ memoId: 'memo-1' }); + }); + + it('loads an attempt only for its initiating actor', async () => { + const attempt = { id: 'attempt-1' } as SigningAttempt; + repository.findOneBy!.mockResolvedValue(attempt); + + await expect( + service.getForActorOrFail('attempt-1', 'actor-1') + ).resolves.toBe(attempt); + expect(repository.findOneBy).toHaveBeenCalledWith({ + id: 'attempt-1', + actorId: 'actor-1', + }); + }); + + it('fails closed when an attempt is not owned by the actor', async () => { + repository.findOneBy!.mockResolvedValue(null); + + await expect( + service.getForActorOrFail('attempt-1', 'other-actor') + ).rejects.toThrow(ValidationException); + }); + + it('does not query the database for an empty document set', async () => { + await expect(service.existsForDocumentIDs([])).resolves.toBe(false); + expect(repository.exist).not.toHaveBeenCalled(); + }); + + it('finds attempts that retain either snapshot or signed documents', async () => { + (repository.exist as Mock).mockResolvedValue(true); + + await expect( + service.existsForDocumentIDs(['doc-1', 'doc-2']) + ).resolves.toBe(true); + + expect(repository.exist).toHaveBeenCalledWith({ + where: [ + { snapshotDocumentId: expect.anything() }, + { signedDocumentId: expect.anything() }, + ], + }); + }); + + it('matches a browser return by initiating actor and client-state hash before checking correlation', async () => { + const attempt = { + id: 'attempt-1', + correlationId: 'correlation-1', + } as SigningAttempt; + repository.findOneBy!.mockResolvedValue(attempt); + + await expect( + service.getForReturnOrFail('correlation-1', 'actor-1', 'ef'.repeat(32)) + ).resolves.toBe(attempt); + expect(repository.findOneBy).toHaveBeenCalledWith({ + actorId: 'actor-1', + clientStateHash: 'ef'.repeat(32), + }); + }); + + it('returns a claimed attempt whose gateway correlation was never persisted', async () => { + const attempt = { + id: 'attempt-1', + correlationId: null, + } as unknown as SigningAttempt; + repository.findOneBy!.mockResolvedValue(attempt); + + await expect( + service.getForReturnOrFail('correlation-1', 'actor-1', 'ef'.repeat(32)) + ).resolves.toBe(attempt); + }); + + it('fails closed for a wrong actor, state or correlation', async () => { + repository.findOneBy!.mockResolvedValue({ + id: 'attempt-1', + correlationId: 'other-correlation', + } as SigningAttempt); + + await expect( + service.getForReturnOrFail( + 'wrong-correlation', + 'other-actor', + 'ef'.repeat(32) + ) + ).rejects.toThrow(ForbiddenException); + }); + + it('conditionally attaches one signed document and releases the snapshot FK', async () => { + repository.update!.mockResolvedValue({ affected: 1 } as any); + const evidence = { signer: { serial_number: 'ABC' } }; + + await expect( + service.finish( + 'attempt-1', + SigningAttemptStatus.SIGNED, + 'signed-document-1', + evidence + ) + ).resolves.toBe(true); + expect(repository.update).toHaveBeenCalledWith( + { id: 'attempt-1', status: SigningAttemptStatus.PENDING }, + { + status: SigningAttemptStatus.SIGNED, + snapshotDocumentId: null, + signedDocumentId: 'signed-document-1', + signerEvidence: evidence, + } + ); + }); + + it.each>([ + SigningAttemptStatus.CANCELLED, + SigningAttemptStatus.FAILED, + SigningAttemptStatus.EXPIRED, + ])('conditionally records %s without a signed document', async status => { + repository.update!.mockResolvedValue({ affected: 0 } as any); + + await expect(service.finish('attempt-1', status)).resolves.toBe(false); + expect(repository.update).toHaveBeenCalledWith( + { id: 'attempt-1', status: SigningAttemptStatus.PENDING }, + { status, snapshotDocumentId: null } + ); + }); + + it('lists only signed copies for one memo in completion order', async () => { + repository.find!.mockResolvedValue([]); + + await service.findSignedForMemo('memo-1'); + + expect(repository.find).toHaveBeenCalledWith({ + where: { memoId: 'memo-1', status: SigningAttemptStatus.SIGNED }, + order: { updatedDate: 'DESC' }, + }); + }); + + it('loads only a signed attempt for verification without initiator binding', async () => { + const attempt = { + id: 'attempt-1', + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + } as SigningAttempt; + repository.findOneBy!.mockResolvedValue(attempt); + + await expect(service.getSignedOrFail('attempt-1')).resolves.toBe(attempt); + expect(repository.findOneBy).toHaveBeenCalledWith({ + id: 'attempt-1', + status: SigningAttemptStatus.SIGNED, + }); + }); + + it('rejects an attempt that is not signed', async () => { + repository.findOneBy!.mockResolvedValue(null); + + await expect(service.getSignedOrFail('attempt-1')).rejects.toThrow( + ValidationException + ); + }); + + it('rejects a signed attempt without its signed document', async () => { + repository.findOneBy!.mockResolvedValue({ + id: 'attempt-1', + status: SigningAttemptStatus.SIGNED, + signedDocumentId: null, + } as unknown as SigningAttempt); + + await expect(service.getSignedOrFail('attempt-1')).rejects.toThrow( + ValidationException + ); + }); + + it('selects a bounded union of gateway-expired and abandoned prepared attempts', async () => { + repository.find!.mockResolvedValue([]); + const now = new Date('2026-09-05T18:00:00Z'); + + await service.findExpired(25, now); + + expect(repository.find).toHaveBeenCalledWith({ + where: [ + { + status: SigningAttemptStatus.PENDING, + expiresAt: LessThanOrEqual(new Date('2026-09-05T17:59:00Z')), + }, + { + status: SigningAttemptStatus.PENDING, + expiresAt: IsNull(), + createdDate: LessThanOrEqual(new Date('2026-09-05T17:00:00Z')), + }, + ], + order: { createdDate: 'ASC' }, + take: 25, + }); + }); + + it.each([ + [ + 'gateway deadline', + { id: 'attempt-1', expiresAt: new Date('2026-09-05T17:58:00Z') }, + { + id: 'attempt-1', + status: SigningAttemptStatus.PENDING, + expiresAt: LessThanOrEqual(new Date('2026-09-05T17:59:00Z')), + }, + ], + [ + 'preparation window', + { id: 'attempt-1', createdDate: new Date('2026-09-05T16:00:00Z') }, + { + id: 'attempt-1', + status: SigningAttemptStatus.PENDING, + expiresAt: IsNull(), + createdDate: LessThanOrEqual(new Date('2026-09-05T17:00:00Z')), + }, + ], + ])('repeats the %s predicate when expiring a candidate', async (_name, attempt, where) => { + repository.update!.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.expire( + attempt as SigningAttempt, + new Date('2026-09-05T18:00:00Z') + ) + ).resolves.toBe(true); + expect(repository.update).toHaveBeenCalledWith(where, { + status: SigningAttemptStatus.EXPIRED, + snapshotDocumentId: null, + }); + }); +}); diff --git a/src/domain/common/content-signing/signing.attempt.service.ts b/src/domain/common/content-signing/signing.attempt.service.ts new file mode 100644 index 0000000000..fb4974b9d6 --- /dev/null +++ b/src/domain/common/content-signing/signing.attempt.service.ts @@ -0,0 +1,216 @@ +import { LogContext } from '@common/enums'; +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { SigningAttempt } from './signing.attempt.entity'; +import { SigningAttemptStatus } from './signing.attempt.status'; + +const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; + +@Injectable() +export class SigningAttemptService { + static readonly PREPARATION_WINDOW_MS = 60 * 60 * 1000; + private static readonly GATEWAY_EXPIRY_MARGIN_MS = 60 * 1000; + + constructor( + @InjectRepository(SigningAttempt) + private readonly repository: Repository + ) {} + + async createUnready( + memoId: string, + actorId: string + ): Promise { + return this.repository.save({ + memoId, + actorId, + status: SigningAttemptStatus.PENDING, + }); + } + + async finalizePrepared( + attemptId: string, + snapshotDocumentId: string, + contentSha256: string + ): Promise { + this.validateHash(contentSha256, 'Content SHA-256'); + const result = await this.repository.update( + { + id: attemptId, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: IsNull(), + }, + { snapshotDocumentId, contentSha256 } + ); + return result.affected === 1; + } + + async claimStart(id: string, clientStateHash: string): Promise { + this.validateHash(clientStateHash, 'Client-state hash'); + const result = await this.repository.update( + { + id, + status: SigningAttemptStatus.PENDING, + clientStateHash: IsNull(), + }, + { clientStateHash } + ); + return result.affected === 1; + } + + async recordGatewayStart( + attemptId: string, + clientStateHash: string, + correlationId: string, + expiresAt: Date + ): Promise { + this.validateHash(clientStateHash, 'Client-state hash'); + const result = await this.repository.update( + { + id: attemptId, + status: SigningAttemptStatus.PENDING, + clientStateHash, + correlationId: IsNull(), + expiresAt: IsNull(), + }, + { correlationId, expiresAt } + ); + return result.affected === 1; + } + + async deleteForMemo(memoId: string): Promise { + await this.repository.delete({ memoId }); + } + + async getForActorOrFail( + attemptId: string, + actorId: string + ): Promise { + const attempt = await this.repository.findOneBy({ id: attemptId, actorId }); + if (!attempt) + throw new ValidationException( + 'Signing attempt is not available for this actor', + LogContext.MEMOS + ); + return attempt; + } + + async getForReturnOrFail( + correlationId: string, + actorId: string, + clientStateHash: string + ): Promise { + const attempt = await this.repository.findOneBy({ + actorId, + clientStateHash, + }); + if ( + !attempt || + (attempt.correlationId && attempt.correlationId !== correlationId) + ) + throw new ForbiddenException( + 'Signing return does not match this actor', + LogContext.MEMOS + ); + return attempt; + } + + async finish( + id: string, + status: Exclude, + signedDocumentId?: string, + signerEvidence?: Record + ): Promise { + const completed = status === SigningAttemptStatus.SIGNED; + const result = await this.repository.update( + { id, status: SigningAttemptStatus.PENDING }, + { + status, + snapshotDocumentId: null, + ...(completed ? { signedDocumentId, signerEvidence } : {}), + } + ); + return result.affected === 1; + } + + findSignedForMemo(memoId: string): Promise { + return this.repository.find({ + where: { memoId, status: SigningAttemptStatus.SIGNED }, + order: { updatedDate: 'DESC' }, + }); + } + + async getSignedOrFail( + id: string + ): Promise { + const attempt = await this.repository.findOneBy({ + id, + status: SigningAttemptStatus.SIGNED, + }); + if (attempt?.signedDocumentId) + return attempt as SigningAttempt & { signedDocumentId: string }; + throw new ValidationException( + 'Signed Memo copy is not available', + LogContext.MEMOS + ); + } + + findExpired(limit: number, now = new Date()): Promise { + return this.repository.find({ + where: [ + { + status: SigningAttemptStatus.PENDING, + ...this.deadline(now, true), + }, + { + status: SigningAttemptStatus.PENDING, + ...this.deadline(now, false), + }, + ], + order: { createdDate: 'ASC' }, + take: limit, + }); + } + + async expire(attempt: SigningAttempt, now = new Date()): Promise { + const where = { + id: attempt.id, + status: SigningAttemptStatus.PENDING, + ...this.deadline(now, Boolean(attempt.expiresAt)), + }; + const result = await this.repository.update(where, { + status: SigningAttemptStatus.EXPIRED, + snapshotDocumentId: null, + }); + return result.affected === 1; + } + + async existsForDocumentIDs(documentIds: string[]): Promise { + if (documentIds.length === 0) return false; + return this.repository.exist({ + where: [ + { snapshotDocumentId: In(documentIds) }, + { signedDocumentId: In(documentIds) }, + ], + }); + } + + private deadline(now: Date, gatewayStarted: boolean) { + const age = gatewayStarted + ? SigningAttemptService.GATEWAY_EXPIRY_MARGIN_MS + : SigningAttemptService.PREPARATION_WINDOW_MS; + const cutoff = LessThanOrEqual(new Date(now.getTime() - age)); + return gatewayStarted + ? { expiresAt: cutoff } + : { expiresAt: IsNull(), createdDate: cutoff }; + } + + private validateHash(value: string, label: string): void { + if (!LOWERCASE_SHA256.test(value)) + throw new ValidationException( + `${label} must be 64 lowercase hexadecimal characters`, + LogContext.MEMOS + ); + } +} diff --git a/src/domain/common/content-signing/signing.attempt.status.ts b/src/domain/common/content-signing/signing.attempt.status.ts new file mode 100644 index 0000000000..6d32bb8f54 --- /dev/null +++ b/src/domain/common/content-signing/signing.attempt.status.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum SigningAttemptStatus { + PENDING = 'pending', + SIGNED = 'signed', + CANCELLED = 'cancelled', + FAILED = 'failed', + EXPIRED = 'expired', +} + +registerEnumType(SigningAttemptStatus, { name: 'SigningAttemptStatus' }); diff --git a/src/domain/common/memo/dto/memo.signature.verify.input.ts b/src/domain/common/memo/dto/memo.signature.verify.input.ts new file mode 100644 index 0000000000..50c2f6fc2c --- /dev/null +++ b/src/domain/common/memo/dto/memo.signature.verify.input.ts @@ -0,0 +1,10 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@InputType() +export class MemoSignatureVerifyInput { + @Field(() => UUID, { description: 'The signed Memo attempt to verify.' }) + @IsUUID() + attemptID!: string; +} diff --git a/src/domain/common/memo/dto/memo.signing.continue.input.ts b/src/domain/common/memo/dto/memo.signing.continue.input.ts new file mode 100644 index 0000000000..8367b5c76f --- /dev/null +++ b/src/domain/common/memo/dto/memo.signing.continue.input.ts @@ -0,0 +1,10 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@InputType() +export class MemoSigningContinueInput { + @Field(() => UUID, { description: 'The prepared signing attempt to start.' }) + @IsUUID() + attemptID!: string; +} diff --git a/src/domain/common/memo/dto/memo.signing.continue.result.ts b/src/domain/common/memo/dto/memo.signing.continue.result.ts new file mode 100644 index 0000000000..341b31acb3 --- /dev/null +++ b/src/domain/common/memo/dto/memo.signing.continue.result.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class MemoSigningContinueResult { + @Field(() => String) + authorizeUrl!: string; +} diff --git a/src/domain/common/memo/dto/memo.signing.prepare.input.ts b/src/domain/common/memo/dto/memo.signing.prepare.input.ts new file mode 100644 index 0000000000..83c323a5b1 --- /dev/null +++ b/src/domain/common/memo/dto/memo.signing.prepare.input.ts @@ -0,0 +1,10 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@InputType() +export class MemoSigningPrepareInput { + @Field(() => UUID, { description: 'The Memo to prepare for signing.' }) + @IsUUID() + memoID!: string; +} diff --git a/src/domain/common/memo/dto/memo.signing.prepare.result.ts b/src/domain/common/memo/dto/memo.signing.prepare.result.ts new file mode 100644 index 0000000000..45fe7d9684 --- /dev/null +++ b/src/domain/common/memo/dto/memo.signing.prepare.result.ts @@ -0,0 +1,11 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class MemoSigningPrepareResult { + @Field(() => UUID) + attemptId!: string; + + @Field(() => String) + previewUrl!: string; +} diff --git a/src/domain/common/memo/memo.module.ts b/src/domain/common/memo/memo.module.ts index 27ef64d019..9387e5ff97 100644 --- a/src/domain/common/memo/memo.module.ts +++ b/src/domain/common/memo/memo.module.ts @@ -1,22 +1,32 @@ import { AuthorizationModule } from '@core/authorization/authorization.module'; import { CollaborationMetadataModule } from '@domain/common/collaboration-metadata'; +import { ContentSigningModule } from '@domain/common/content-signing/content.signing.module'; import { VisualModule } from '@domain/common/visual/visual.module'; import { UserModule } from '@domain/community/user/user.module'; import { ProfileDocumentsModule } from '@domain/profile-documents/profile.documents.module'; +import { DocumentModule } from '@domain/storage/document/document.module'; import { StorageBucketModule } from '@domain/storage/storage-bucket/storage.bucket.module'; +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FileServiceAdapterModule } from '@services/adapters/file-service-adapter/file.service.adapter.module'; +import { TrustGatewayClient } from '@services/adapters/trust-gateway/trust.gateway.client'; import { CollaborationClientModule } from '@services/collaboration-client/collaboration-client.module'; import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; +import { KratosModule } from '@services/infrastructure/kratos/kratos.module'; +import { UrlGeneratorModule } from '@services/infrastructure/url-generator'; import { AuthorizationPolicyModule } from '../authorization-policy/authorization.policy.module'; import { LicenseModule } from '../license/license.module'; import { ProfileModule } from '../profile/profile.module'; import { Memo } from './memo.entity'; +import { MemoPdfRenderer } from './memo.pdf.renderer'; import { MemoResolverFields } from './memo.resolver.fields'; import { MemoResolverMutations } from './memo.resolver.mutations'; import { MemoService } from './memo.service'; import { MemoAuthorizationService } from './memo.service.authorization'; +import { MemoSignatureResolverFields } from './memo.signature.resolver.fields'; +import { MemoSigningService } from './memo.signing.service'; +import { MemoSigningSweepService } from './memo.signing.sweep.service'; @Module({ imports: [ @@ -31,20 +41,31 @@ import { MemoAuthorizationService } from './memo.service.authorization'; TypeOrmModule.forFeature([Memo]), ProfileDocumentsModule, CollaborationMetadataModule, + ContentSigningModule, FileServiceAdapterModule, + HttpModule, CollaborationClientModule, + DocumentModule, + KratosModule, + UrlGeneratorModule, ], providers: [ MemoService, MemoAuthorizationService, MemoResolverMutations, MemoResolverFields, + MemoPdfRenderer, + MemoSigningService, + MemoSignatureResolverFields, + MemoSigningSweepService, + TrustGatewayClient, ], exports: [ MemoService, MemoAuthorizationService, MemoResolverMutations, MemoResolverFields, + MemoSigningService, ], }) export class MemoModule {} diff --git a/src/domain/common/memo/memo.pdf.renderer.image-budget.spec.ts b/src/domain/common/memo/memo.pdf.renderer.image-budget.spec.ts new file mode 100644 index 0000000000..c46f04b7ef --- /dev/null +++ b/src/domain/common/memo/memo.pdf.renderer.image-budget.spec.ts @@ -0,0 +1,61 @@ +import { ActorContext } from '@core/actor-context/actor.context'; +import sharp from 'sharp'; +import { MemoPdfRenderer } from './memo.pdf.renderer'; + +describe('MemoPdfRenderer normalized image budget', () => { + it('rejects more than 16 MiB before PDF layout', async () => { + const internalUrl = + 'https://alkem.io/api/private/rest/storage/document/11111111-1111-4111-8111-111111111111'; + const pixels = Buffer.alloc(1200 * 1200 * 3); + let state = 0x12345678; + for (let index = 0; index < pixels.length; index++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + pixels[index] = state; + } + const source = await sharp(pixels, { + raw: { width: 1200, height: 1200, channels: 3 }, + }) + .png({ compressionLevel: 1 }) + .toBuffer(); + const normalized = await sharp(source, { limitInputPixels: 16_777_216 }) + .rotate() + .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }) + .flatten({ background: '#ffffff' }) + .toColourspace('srgb') + .jpeg({ quality: 80 }) + .toBuffer(); + expect(normalized.length * 20).toBeGreaterThan(16 * 1024 * 1024); + const renderer = new MemoPdfRenderer( + { + isAlkemioDocumentURL: () => true, + getDocumentFromURL: async () => ({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }), + } as any, + { grantAccessOrFail: () => undefined } as any, + { getDocumentContent: async () => source } as any + ); + const convertHtml = vi.spyOn(renderer as any, 'convertHtml'); + + let failure: unknown; + try { + await renderer.render( + Array.from( + { length: 20 }, + (_, index) => `![noise ${index}](${internalUrl})` + ).join('\n'), + 'bucket-1', + Object.assign(new ActorContext(), { actorID: 'actor-1' }) + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/16 MiB normalized size limit/i); + expect(convertHtml).not.toHaveBeenCalled(); + }, 30_000); +}); diff --git a/src/domain/common/memo/memo.pdf.renderer.limits.spec.ts b/src/domain/common/memo/memo.pdf.renderer.limits.spec.ts new file mode 100644 index 0000000000..f6b903f9b0 --- /dev/null +++ b/src/domain/common/memo/memo.pdf.renderer.limits.spec.ts @@ -0,0 +1,135 @@ +import { ActorContext } from '@core/actor-context/actor.context'; +import sharp from 'sharp'; +import { MemoPdfRenderer } from './memo.pdf.renderer'; + +const MAX_MARKDOWN_BYTES = 100_000; +const MAX_IMAGES = 20; + +const fitAsciiBytes = (prefix: string, bytes: number): string => { + const paragraph = + 'Representative signed memo paragraph with **bold**, *emphasis*, and a [link](https://example.com).\n\n'; + return `${prefix}${paragraph.repeat(Math.ceil(bytes / paragraph.length))}`.slice( + 0, + bytes + ); +}; + +describe('MemoPdfRenderer input bounds', () => { + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const internalUrl = + 'https://alkem.io/api/private/rest/storage/document/11111111-1111-4111-8111-111111111111'; + const documentService = { + isAlkemioDocumentURL: vi.fn((url: string) => url === internalUrl), + getDocumentFromURL: vi.fn(), + }; + const authorizationService = { grantAccessOrFail: vi.fn() }; + const fileServiceAdapter = { getDocumentContent: vi.fn() }; + const renderer = new MemoPdfRenderer( + documentService as any, + authorizationService as any, + fileServiceAdapter as any + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the bounded 100,000-byte structured fixture', async () => { + const markdown = fitAsciiBytes( + [ + '# Maximum signing preview', + '', + '- first list item', + '- second list item', + '', + '| Column A | Column B |', + '| --- | --- |', + '| value A | value B |', + '', + ].join('\n'), + MAX_MARKDOWN_BYTES + ); + expect(Buffer.byteLength(markdown)).toBe(MAX_MARKDOWN_BYTES); + + const started = performance.now(); + const pdf = await renderer.render(markdown, 'bucket-1', actor); + const elapsed = performance.now() - started; + process.stdout.write( + `memo-signing-render max-text bytes=${MAX_MARKDOWN_BYTES} images=0 pixels=0 ms=${elapsed.toFixed(1)}\n` + ); + + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + }); + + it('rejects markdown larger than 100,000 UTF-8 bytes before rendering', async () => { + await expect( + renderer.render('a'.repeat(MAX_MARKDOWN_BYTES + 1), 'bucket-1', actor) + ).rejects.toThrow(/100,000 bytes/i); + expect(documentService.getDocumentFromURL).not.toHaveBeenCalled(); + }); + + it('rejects more than 20 images before resolving any image', async () => { + const markdown = Array.from( + { length: MAX_IMAGES + 1 }, + (_, index) => `![image ${index}](${internalUrl})` + ).join('\n'); + + await expect(renderer.render(markdown, 'bucket-1', actor)).rejects.toThrow( + /20 images/i + ); + expect(documentService.getDocumentFromURL).not.toHaveBeenCalled(); + }); + + it('accepts a highly compressible image at the 16,777,216 source-pixel boundary', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + await sharp({ + create: { + width: 4096, + height: 4096, + channels: 3, + background: { r: 80, g: 120, b: 180 }, + }, + }) + .png() + .toBuffer() + ); + + const pdf = await renderer.render( + `![source-boundary](${internalUrl})`, + 'bucket-1', + actor + ); + + expect(pdf.toString('latin1')).toContain('/Filter /DCTDecode'); + expect(pdf.toString('latin1')).toContain('/Width 1200'); + }); + + it('rejects a source above 16,777,216 pixels with an actionable error', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + await sharp({ + create: { + width: 4097, + height: 4096, + channels: 3, + background: { r: 80, g: 120, b: 180 }, + }, + }) + .png() + .toBuffer() + ); + + await expect( + renderer.render(`![too-large](${internalUrl})`, 'bucket-1', actor) + ).rejects.toThrow(/16,777,216 source pixels/i); + }); +}); diff --git a/src/domain/common/memo/memo.pdf.renderer.spec.ts b/src/domain/common/memo/memo.pdf.renderer.spec.ts new file mode 100644 index 0000000000..c6c3b8784b --- /dev/null +++ b/src/domain/common/memo/memo.pdf.renderer.spec.ts @@ -0,0 +1,458 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { JSDOM } from 'jsdom'; +import MarkdownIt from 'markdown-it'; +import { parseOffice } from 'officeparser'; +import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import sharp from 'sharp'; +import { markdownToYjsV2State, yjsStateToMarkdown } from './conversion'; +import { MemoPdfRenderer } from './memo.pdf.renderer'; + +const pdfjsRequire = createRequire(require.resolve('pdfjs-dist/package.json')); +const { createCanvas } = pdfjsRequire( + '@napi-rs/canvas' +) as typeof import('@napi-rs/canvas'); + +const htmlToPdfMake = require('html-to-pdfmake') as ( + html: string, + options: { window: unknown } +) => unknown; +const pdfMake = require('pdfmake') as { + localAccessPolicy(path: string): boolean; + urlAccessPolicy(url: string): boolean; +}; +const fonts = require('pdfmake/fonts/Roboto') as { + Roboto: Record; +}; + +const extractText = async (pdf: Buffer): Promise => { + const document = await parseOffice(pdf, { fileType: 'pdf', ocr: false }); + return document.toText(); +}; + +const countRenderedInk = async (pdf: Buffer): Promise => { + const document = await getDocument({ data: new Uint8Array(pdf) }).promise; + const page = await document.getPage(1); + const viewport = page.getViewport({ scale: 1 }); + const canvas = createCanvas(viewport.width, viewport.height); + const context = canvas.getContext('2d'); + await page.render({ + canvas: canvas as any, + canvasContext: context as any, + viewport, + }).promise; + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + await document.destroy(); + return pixels.reduce( + (count, channel, index) => + index % 4 === 3 && channel > 0 && pixels[index - 3] < 240 + ? count + 1 + : count, + 0 + ); +}; + +describe('MemoPdfRenderer', () => { + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const internalUrl = + 'https://alkem.io/api/private/rest/storage/document/11111111-1111-4111-8111-111111111111'; + const documentService = { + isAlkemioDocumentURL: vi.fn((url: string) => url === internalUrl), + getDocumentFromURL: vi.fn(), + }; + const authorizationService = { grantAccessOrFail: vi.fn() }; + const fileServiceAdapter = { getDocumentContent: vi.fn() }; + const renderer = new MemoPdfRenderer( + documentService as any, + authorizationService as any, + fileServiceAdapter as any + ); + + beforeEach(() => { + vi.clearAllMocks(); + documentService.getDocumentFromURL.mockReset(); + authorizationService.grantAccessOrFail.mockReset(); + fileServiceAdapter.getDocumentContent.mockReset(); + }); + + it('renders the current projection into a real PDF with representative structure', async () => { + const pdf = await renderer.render( + [ + '# Capture heading', + '', + 'Text with **bold**, *emphasis*, [a safe link](https://example.com), and `inline code`.', + '', + '- Parent', + ' - Nested child', + '', + '```ts', + 'const preserved = 2;', + '```', + '', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + '', + '> quoted', + '', + 'Γειά σου', + ].join('\n'), + 'bucket-1', + actor + ); + + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + const text = await extractText(pdf); + expect(text).toContain('Capture heading'); + expect(text).toContain('Nested child'); + expect(text).toContain('const preserved = 2;'); + expect(text).toContain('A'); + expect(text).toContain('quoted'); + expect(text).toContain('Γειά σου'); + }); + + it('renders European platform languages and a visible box for an unsupported symbol', async () => { + const supported = [ + 'Nederlands: officiële beëindiging', + 'Español: acción e información', + 'Български: подписан документ', + 'Deutsch: Größe und äußere', + 'Français: été, cœur où', + ]; + const pdf = await renderer.render( + supported.join('\n\n'), + 'bucket-1', + actor + ); + + const text = await extractText(pdf); + for (const sample of supported) expect(text).toContain(sample); + expect(pdf.toString('latin1')).toContain('Roboto-Regular'); + + // Roboto has no U+2713. Rendering that character alone must still put + // visible replacement ink on the actual PDF page rather than omit it. + const emptyInk = await countRenderedInk( + await renderer.render(' ', 'bucket-1', actor) + ); + const replacementInk = await countRenderedInk( + await renderer.render('✓', 'bucket-1', actor) + ); + expect(replacementInk).toBeGreaterThan(emptyInk); + }); + + it('preserves code whitespace in the converter structure before text extraction normalizes it', () => { + const dom = new JSDOM( + `${new MarkdownIt().render('```ts\nconst preserved = 2;\n```')}` + ); + const content = htmlToPdfMake(dom.window.document.body.innerHTML, { + window: dom.window, + }); + + expect(JSON.stringify(content)).toContain('const preserved = 2;'); + }); + + it('allows only the registered embedded fonts through the local access policy', () => { + expect(pdfMake.urlAccessPolicy('https://example.com/font.ttf')).toBe(false); + expect(pdfMake.localAccessPolicy(Object.values(fonts.Roboto)[0])).toBe( + true + ); + expect(pdfMake.localAccessPolicy('/tmp/unregistered-font.ttf')).toBe(false); + }); + + it('renders current-projection highlight markers without exposing the markers', async () => { + const text = await extractText( + await renderer.render('Before ==highlighted== after', 'bucket-1', actor) + ); + + expect(text).toContain('Before highlighted after'); + expect(text).not.toContain('=='); + }); + + it('does not rewrite highlight-like text inside inline or fenced code', async () => { + const text = await extractText( + await renderer.render( + ['`inline ==literal==`', '', '```ts', 'x ==literal== y', '```'].join( + '\n' + ), + 'bucket-1', + actor + ) + ); + + expect(text).toContain('inline ==literal=='); + expect(text).toContain('x ==literal== y'); + }); + + it('loads only an authorized image from the memo bucket', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ) + ); + + const pdf = await renderer.render( + `Before ![approved](${internalUrl}) after`, + 'bucket-1', + actor + ); + + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + expect(documentService.getDocumentFromURL).toHaveBeenCalledWith( + internalUrl, + { relations: { authorization: true, storageBucket: true } } + ); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + actor, + { id: 'image-auth' }, + AuthorizationPrivilege.READ, + 'read signing image' + ); + expect(fileServiceAdapter.getDocumentContent).toHaveBeenCalledWith( + 'image-1' + ); + }); + + it('normalizes an authorized image to bounded opaque JPEG for PDF pass-through', async () => { + const convertHtml = vi.spyOn(renderer as any, 'convertHtml'); + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + await sharp({ + create: { + width: 2048, + height: 1024, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .png() + .toBuffer() + ); + + const pdf = await renderer.render( + [ + `- ![bounded list image](${internalUrl})`, + '', + '| Nested table image |', + '| --- |', + `| ![bounded table image](${internalUrl}) |`, + ].join('\n'), + 'bucket-1', + actor + ); + const imageObjects = + pdf + .toString('latin1') + .match( + /\d+ 0 obj\s*<<(?:(?!endobj)[\s\S])*?\/Subtype \/Image(?:(?!endobj)[\s\S])*?endobj/g + ) ?? []; + + expect(convertHtml.mock.calls[0][0]).not.toContain('data:'); + expect(convertHtml.mock.calls[0][0]).toContain('memo-signing-image-0'); + expect(convertHtml.mock.calls[0][0]).toContain('memo-signing-image-1'); + expect(imageObjects).toHaveLength(2); + for (const imageObject of imageObjects) { + expect(imageObject).toContain('/Filter /DCTDecode'); + expect(imageObject).toContain('/Width 1200'); + expect(imageObject).toContain('/Height 600'); + expect(imageObject).toContain('/ColorSpace /DeviceRGB'); + expect(imageObject).not.toContain('/FlateDecode'); + expect(imageObject).not.toContain('/SMask'); + } + }); + + it('uses labelled safe links for external images and embeds without fetching', async () => { + const pdf = await renderer.render( + [ + '![diagram](https://example.com/diagram.svg)', + '', + '', + '', + ].join('\n'), + 'bucket-1', + actor + ); + + const text = await extractText(pdf); + expect(text).toContain('Image: diagram'); + expect(text).toContain('Image: https://example.com/no-alt.png'); + expect(text).toContain('Embedded content: https://example.com/embed'); + expect(text).toContain('Embedded content: %'); + expect(documentService.getDocumentFromURL).not.toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('never fetches authored local, data, or unsupported-scheme image URLs', async () => { + const pdf = await renderer.render( + [ + '![local](file:///etc/passwd)', + '![inline](data:text/plain,SECRET_DATA_TEXT)', + '![ftp](ftp://example.com/image.png)', + ].join('\n'), + 'bucket-1', + actor + ); + + const text = await extractText(pdf); + expect(text).toContain('Image: ftp'); + expect(documentService.getDocumentFromURL).not.toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('fails instead of turning an unauthorized private image into a link', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + authorizationService.grantAccessOrFail.mockImplementation(() => { + throw new Error('denied'); + }); + + await expect( + renderer.render(`![private](${internalUrl})`, 'bucket-1', actor) + ).rejects.toThrow('denied'); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('rejects an internal image belonging to another bucket', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'other-bucket' }, + }); + + await expect( + renderer.render(`![private](${internalUrl})`, 'bucket-1', actor) + ).rejects.toThrow(/memo bucket/i); + expect(authorizationService.grantAccessOrFail).not.toHaveBeenCalled(); + }); + + it('rejects a missing private image without turning it into a link', async () => { + documentService.getDocumentFromURL.mockResolvedValue(undefined); + + await expect( + renderer.render(`![private](${internalUrl})`, 'bucket-1', actor) + ).rejects.toThrow(/memo bucket/i); + expect(authorizationService.grantAccessOrFail).not.toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('uses a labelled link for an authorized but unsupported private image', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + Buffer.from('unsupported image bytes') + ); + + const pdf = await renderer.render(`![](${internalUrl})`, 'bucket-1', actor); + + expect(await extractText(pdf)).toContain(`Image: ${internalUrl}`); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalled(); + }); + + it('propagates an unreadable private image instead of using fallback', async () => { + documentService.getDocumentFromURL.mockResolvedValue({ + id: 'image-1', + authorization: { id: 'image-auth' }, + storageBucket: { id: 'bucket-1' }, + }); + fileServiceAdapter.getDocumentContent.mockRejectedValue( + new Error('private read failed') + ); + + await expect( + renderer.render(`![private](${internalUrl})`, 'bucket-1', actor) + ).rejects.toThrow('private read failed'); + }); + + it('removes authored scripts and converter overrides', async () => { + const pdf = await renderer.render( + '

Visible

Local link', + 'bucket-1', + actor + ); + + const text = await extractText(pdf); + expect(text).toContain('Visible'); + expect(text).toContain('Local link'); + expect(text).not.toContain('SECRET_SCRIPT_TEXT'); + }); + + it('replaces authored SVG instead of opening a nested server-local image', async () => { + const directory = await mkdtemp(join(tmpdir(), 'memo-pdf-svg-')); + const localImage = join(directory, 'local.png'); + const firstImage = await sharp({ + create: { + width: 32, + height: 32, + channels: 3, + background: '#ff0000', + }, + }) + .png() + .toBuffer(); + const secondImage = await sharp({ + create: { + width: 32, + height: 32, + channels: 3, + background: '#0000ff', + }, + }) + .png() + .toBuffer(); + const markup = ``; + + try { + await writeFile(localImage, firstImage); + const withFirstFile = await renderer.render(markup, 'bucket-1', actor); + await writeFile(localImage, secondImage); + const withSecondFile = await renderer.render(markup, 'bucket-1', actor); + await rm(localImage); + const withoutFile = await renderer.render(markup, 'bucket-1', actor); + + expect(await extractText(withFirstFile)).toContain('Unsupported content'); + const pdfs = [withFirstFile, withSecondFile, withoutFile]; + for (const pdf of pdfs) + expect(pdf.toString('latin1')).not.toContain('/Subtype /Image'); + expect(new Set(pdfs.map(pdf => pdf.length)).size).toBe(1); + expect(new Set(await Promise.all(pdfs.map(countRenderedInk))).size).toBe( + 1 + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('preserves authored text in unsupported wrappers without enabling embedded content', async () => { + const authoredMarkdown = + '
Agreed payment terms
hidden object text'; + const projectedMarkdown = yjsStateToMarkdown( + Buffer.from(markdownToYjsV2State(authoredMarkdown)) + ); + expect(projectedMarkdown).toBe(authoredMarkdown); + const pdf = await renderer.render(projectedMarkdown, 'bucket-1', actor); + + const text = await extractText(pdf); + expect(text).toContain('Agreed payment terms'); + expect(text).toContain('Unsupported content: object'); + expect(text).not.toContain('hidden object text'); + }); +}); diff --git a/src/domain/common/memo/memo.pdf.renderer.ts b/src/domain/common/memo/memo.pdf.renderer.ts new file mode 100644 index 0000000000..5f16294066 --- /dev/null +++ b/src/domain/common/memo/memo.pdf.renderer.ts @@ -0,0 +1,197 @@ +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { LogContext } from '@common/enums/logging.context'; +import { ValidationException } from '@common/exceptions'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { DocumentService } from '@domain/storage/document/document.service'; +import { Injectable } from '@nestjs/common'; +import { FileServiceAdapter } from '@services/adapters/file-service-adapter/file.service.adapter'; +import { JSDOM } from 'jsdom'; +import MarkdownIt from 'markdown-it'; +import sharp from 'sharp'; + +// pdfmake and html-to-pdfmake publish CommonJS without TypeScript declarations. +const htmlToPdfMake = require('html-to-pdfmake') as ( + html: string, + options: { window: Window; defaultStyles?: Record } +) => unknown; +const pdfMake = require('pdfmake') as { + addFonts(fonts: unknown): void; + setUrlAccessPolicy(policy: (url: string) => boolean): void; + setLocalAccessPolicy(policy: (path: string) => boolean): void; + createPdf(definition: unknown): { getBuffer(): Promise }; +}; +const fonts = require('pdfmake/fonts/Roboto') as { + Roboto: Record; +}; +const allowedFonts = new Set(Object.values(fonts.Roboto)); +pdfMake.addFonts(fonts); +pdfMake.setUrlAccessPolicy(() => false); +pdfMake.setLocalAccessPolicy(path => allowedFonts.has(path)); + +// PR #6469 renderer evidence bounds synchronous layout and source decoding. +const MAX_MARKDOWN_BYTES = 100_000; +const MAX_IMAGES = 20; +const MAX_SOURCE_IMAGE_PIXELS = 16_777_216; +const MAX_RENDERED_IMAGE_EDGE = 1200; +const supportedElement = + /^(a|blockquote|br|code|em|h[1-6]|hr|img|li|mark|ol|p|pre|s|strong|table|tbody|td|th|thead|tr|ul)$/; +const embeddedElement = + /^(audio|button|canvas|embed|form|input|link|math|meta|object|select|svg|template|textarea|video)$/; + +const isSafeUrl = (value: string) => { + try { + return ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } +}; + +const invalid = (message: string): never => { + throw new ValidationException(message, LogContext.MEMOS); +}; + +@Injectable() +export class MemoPdfRenderer { + private readonly markdown = new MarkdownIt({ html: true, linkify: false }); + private readonly convertHtml = htmlToPdfMake; + + constructor( + private readonly documentService: DocumentService, + private readonly authorizationService: AuthorizationService, + private readonly fileServiceAdapter: FileServiceAdapter + ) {} + + async render(markdown: string, bucketId: string, actor: ActorContext) { + if (Buffer.byteLength(markdown) > MAX_MARKDOWN_BYTES) + invalid( + `Signing preview supports at most ${MAX_MARKDOWN_BYTES.toLocaleString('en-US')} bytes of memo content` + ); + const dom = new JSDOM(`${this.markdown.render(markdown)}`); + const { document } = dom.window; + const replaceWithLink = (node: Element, label: string, target: string) => { + const replacement = document.createElement( + isSafeUrl(target) ? 'a' : 'span' + ); + replacement.textContent = label; + if (replacement instanceof dom.window.HTMLAnchorElement) + replacement.href = target; + node.replaceWith(replacement); + }; + + document.querySelectorAll('script,style').forEach(node => node.remove()); + document.body.querySelectorAll('*').forEach(node => { + const tag = node.tagName.toLowerCase(); + if (supportedElement.test(tag) || tag === 'iframe') return; + if (embeddedElement.test(tag)) + replaceWithLink(node, `Unsupported content: ${tag}`, ''); + else node.replaceWith(...Array.from(node.childNodes)); + }); + document.querySelectorAll('*').forEach(node => { + for (const name of node.getAttributeNames()) + if (['data-pdfmake', 'style'].includes(name) || name.startsWith('on')) + node.removeAttribute(name); + }); + document.querySelectorAll('a').forEach(link => { + if (!isSafeUrl(link.href)) link.removeAttribute('href'); + }); + document + .querySelectorAll('iframe') + .forEach(frame => + replaceWithLink(frame, `Embedded content: ${frame.src}`, frame.src) + ); + + const walker = document.createTreeWalker( + document.body, + dom.window.NodeFilter.SHOW_TEXT + ); + const textNodes: Text[] = []; + while (walker.nextNode()) textNodes.push(walker.currentNode as Text); + for (const textNode of textNodes) { + if (textNode.parentElement?.closest('code,pre')) continue; + const parts = textNode.data.split(/(==[^=\n]+==)/g); + if (parts.length === 1) continue; + textNode.replaceWith( + ...parts.map((part, index) => { + if (!(index % 2)) return part; + const mark = document.createElement('mark'); + mark.textContent = part.slice(2, -2); + return mark; + }) + ); + } + + const images = [...document.querySelectorAll('img')]; + if (images.length > MAX_IMAGES) + invalid(`Signing preview supports at most ${MAX_IMAGES} images`); + const imageData = new Map(); + const substituteImageData = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.image === 'string' && imageData.has(node.image)) + node.image = imageData.get(node.image); + Object.values(node).forEach(substituteImageData); + }; + let normalizedImageBytes = 0; + for (const [index, image] of images.entries()) { + const source = image.src; + if (!this.documentService.isAlkemioDocumentURL(source)) { + replaceWithLink(image, `Image: ${image.alt || source}`, source); + continue; + } + const stored = await this.documentService.getDocumentFromURL(source, { + relations: { authorization: true, storageBucket: true }, + }); + if (!stored?.storageBucket || stored.storageBucket.id !== bucketId) + invalid('Signing image does not belong to the memo bucket'); + this.authorizationService.grantAccessOrFail( + actor, + stored!.authorization, + AuthorizationPrivilege.READ, + 'read signing image' + ); + const bytes = await this.fileServiceAdapter.getDocumentContent( + stored!.id + ); + try { + const jpeg = await sharp(bytes, { + limitInputPixels: MAX_SOURCE_IMAGE_PIXELS, + }) + .rotate() + .resize(MAX_RENDERED_IMAGE_EDGE, MAX_RENDERED_IMAGE_EDGE, { + fit: 'inside', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }) + .toColourspace('srgb') + .jpeg({ quality: 80 }) + .toBuffer(); + normalizedImageBytes += jpeg.length; + if (normalizedImageBytes > 16 * 1024 * 1024) + invalid('Signing images exceed the 16 MiB normalized size limit'); + const placeholder = `memo-signing-image-${index}`; + image.setAttribute('src', placeholder); + imageData.set( + placeholder, + `data:image/jpeg;base64,${jpeg.toString('base64')}` + ); + } catch (error) { + if (error instanceof ValidationException) throw error; + if (error instanceof Error && /pixel limit/i.test(error.message)) + invalid( + `Signing image supports at most ${MAX_SOURCE_IMAGE_PIXELS.toLocaleString('en-US')} source pixels` + ); + replaceWithLink(image, `Image: ${image.alt || source}`, source); + } + } + + const content = this.convertHtml(document.body.innerHTML, { + window: dom.window as unknown as Window, + defaultStyles: { mark: { background: '#fff59d' } }, + }); + substituteImageData(content); + return pdfMake + .createPdf({ content, defaultStyle: { font: 'Roboto', fontSize: 10 } }) + .getBuffer(); + } +} diff --git a/src/domain/common/memo/memo.resolver.fields.ts b/src/domain/common/memo/memo.resolver.fields.ts index 5398a8af6e..c31df7fef2 100644 --- a/src/domain/common/memo/memo.resolver.fields.ts +++ b/src/domain/common/memo/memo.resolver.fields.ts @@ -1,4 +1,7 @@ +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; import { LogContext } from '@common/enums/logging.context'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { AuthorizationService } from '@core/authorization/authorization.service'; import { ProfileLoaderCreator, UserLoaderCreator, @@ -9,24 +12,70 @@ import { } from '@core/dataloader/creators/loader.creators/memo/memo.content.loader.creator'; import { Loader } from '@core/dataloader/decorators'; import { ILoader } from '@core/dataloader/loader.interface'; +import { IMemoSignature } from '@domain/common/content-signing/signing.attempt.interface'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; +import { UUID } from '@domain/common/scalars'; import { IUser } from '@domain/community/user/user.interface'; import { Inject, LoggerService } from '@nestjs/common'; -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; +import { CurrentActor } from '@src/common/decorators'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { IProfile } from '../profile/profile.interface'; import { Markdown } from '../scalars/scalar.markdown'; +import { MemoSignatureVerifyInput } from './dto/memo.signature.verify.input'; import { Memo } from './memo.entity'; import { IMemo } from './memo.interface'; import { MemoService } from './memo.service'; +import { MemoSignatureVerificationStatus } from './memo.signature.verification.status'; +import { MemoSigningService } from './memo.signing.service'; @Resolver(() => IMemo) export class MemoResolverFields { constructor( private memoService: MemoService, + private signingAttemptService: SigningAttemptService, + private authorizationService: AuthorizationService, + private memoSigningService: MemoSigningService, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} + @Query(() => IMemoSignature, { + description: 'A Memo signing attempt belonging to the current actor.', + }) + signingAttempt( + @CurrentActor() actor: ActorContext, + @Args('ID', { type: () => UUID }) attemptId: string + ) { + return this.signingAttemptService.getForActorOrFail( + attemptId, + actor.actorID + ); + } + + @Query(() => MemoSignatureVerificationStatus, { + description: 'Checks the stored integrity of a signed Memo copy.', + }) + verifyMemoSignature( + @CurrentActor() actor: ActorContext, + @Args('verificationData') { attemptID }: MemoSignatureVerifyInput + ) { + return this.memoSigningService.verifyMemoSignature(attemptID, actor); + } + + @ResolveField('signatures', () => [IMemoSignature], { + description: 'Signed copies of this Memo visible to readers of the Memo.', + }) + async signatures(@Parent() memo: IMemo, @CurrentActor() actor: ActorContext) { + this.authorizationService.grantAccessOrFail( + actor, + memo.authorization, + AuthorizationPrivilege.READ, + 'read memo signatures' + ); + return this.signingAttemptService.findSignedForMemo(memo.id); + } + @ResolveField(() => Markdown, { nullable: true, description: 'The last saved content of the Memo, represented in Markdown.', diff --git a/src/domain/common/memo/memo.resolver.mutations.spec.ts b/src/domain/common/memo/memo.resolver.mutations.spec.ts index 58d58e515b..a548fb5d61 100644 --- a/src/domain/common/memo/memo.resolver.mutations.spec.ts +++ b/src/domain/common/memo/memo.resolver.mutations.spec.ts @@ -2,11 +2,15 @@ import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; import { ActorContext } from '@core/actor-context/actor.context'; import { AuthorizationService } from '@core/authorization/authorization.service'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { MemoSigningContinueInput } from '@domain/common/memo/dto/memo.signing.continue.input'; +import { MemoSigningPrepareInput } from '@domain/common/memo/dto/memo.signing.prepare.input'; import { IMemo } from '@domain/common/memo/memo.interface'; import { MemoResolverMutations } from '@domain/common/memo/memo.resolver.mutations'; import { MemoService } from '@domain/common/memo/memo.service'; import { MemoAuthorizationService } from '@domain/common/memo/memo.service.authorization'; +import { MemoSigningService } from '@domain/common/memo/memo.signing.service'; import { LoggerService } from '@nestjs/common'; +import { validate } from 'class-validator'; import { EntityManager } from 'typeorm'; import { type Mocked, vi } from 'vitest'; @@ -29,6 +33,11 @@ const createResolver = () => { applyAuthorizationPolicy: vi.fn(), } as unknown as Mocked; + const memoSigningService = { + prepareMemoSigning: vi.fn(), + continueMemoSigning: vi.fn(), + } as unknown as Mocked; + const entityManager = { findOne: vi.fn(), } as unknown as Mocked; @@ -43,6 +52,7 @@ const createResolver = () => { authorizationPolicyService, memoService, memoAuthService, + memoSigningService, entityManager, logger ); @@ -52,6 +62,7 @@ const createResolver = () => { authorizationService, memoService, memoAuthService, + memoSigningService, authorizationPolicyService, entityManager, }; @@ -61,6 +72,52 @@ describe('MemoResolverMutations', () => { const actorContext = new ActorContext(); actorContext.actorID = 'user-1'; + it('delegates server-owned signing preparation without accepting PDF input', async () => { + const { resolver, memoSigningService } = createResolver(); + const result = { + attemptId: 'attempt-1', + previewUrl: '/api/private/rest/content-signing/attempt-1/snapshot', + }; + memoSigningService.prepareMemoSigning.mockResolvedValue(result); + + await expect( + resolver.prepareMemoSigning(actorContext, { memoID: 'memo-1' }) + ).resolves.toBe(result); + expect(memoSigningService.prepareMemoSigning).toHaveBeenCalledWith( + 'memo-1', + actorContext + ); + expect(resolver.prepareMemoSigning).toHaveLength(2); + }); + + it('delegates continuation by attempt ID without accepting state or PDF', async () => { + const { resolver, memoSigningService } = createResolver(); + const result = { + authorizeUrl: 'https://connect.acc.cleverbase.com/authorize', + }; + memoSigningService.continueMemoSigning.mockResolvedValue(result); + + await expect( + resolver.continueMemoSigning(actorContext, { attemptID: 'attempt-1' }) + ).resolves.toBe(result); + expect(memoSigningService.continueMemoSigning).toHaveBeenCalledWith( + 'attempt-1', + actorContext + ); + expect(resolver.continueMemoSigning).toHaveLength(2); + }); + + it.each([ + [MemoSigningPrepareInput, 'memoID'], + [MemoSigningContinueInput, 'attemptID'], + ])('validates %s as a UUID input', async (Input, field) => { + const input = Object.assign(new Input(), { [field]: 'not-a-uuid' }); + + await expect(validate(input)).resolves.toEqual([ + expect.objectContaining({ property: field }), + ]); + }); + describe('updateMemo', () => { it('authorizes and updates memo without re-applying auth when policy unchanged', async () => { const { resolver, authorizationService, memoService } = createResolver(); diff --git a/src/domain/common/memo/memo.resolver.mutations.ts b/src/domain/common/memo/memo.resolver.mutations.ts index 813568a4a2..b366591afc 100644 --- a/src/domain/common/memo/memo.resolver.mutations.ts +++ b/src/domain/common/memo/memo.resolver.mutations.ts @@ -12,9 +12,14 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { EntityManager } from 'typeorm'; import { AuthorizationPolicyService } from '../authorization-policy/authorization.policy.service'; import { DeleteMemoInput } from './dto/memo.dto.delete'; +import { MemoSigningContinueInput } from './dto/memo.signing.continue.input'; +import { MemoSigningContinueResult } from './dto/memo.signing.continue.result'; +import { MemoSigningPrepareInput } from './dto/memo.signing.prepare.input'; +import { MemoSigningPrepareResult } from './dto/memo.signing.prepare.result'; import { IMemo } from './memo.interface'; import { MemoService } from './memo.service'; import { MemoAuthorizationService } from './memo.service.authorization'; +import { MemoSigningService } from './memo.signing.service'; import { UpdateMemoEntityInput } from './types'; @InstrumentResolver() @@ -25,10 +30,32 @@ export class MemoResolverMutations { private authorizationPolicyService: AuthorizationPolicyService, private memoService: MemoService, private memoAuthService: MemoAuthorizationService, + private memoSigningService: MemoSigningService, @InjectEntityManager() private entityManager: EntityManager, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} + @Mutation(() => MemoSigningPrepareResult, { + description: + 'Prepares an exact PDF preview for signing the specified Memo.', + }) + prepareMemoSigning( + @CurrentActor() actor: ActorContext, + @Args('signingData') { memoID }: MemoSigningPrepareInput + ): Promise { + return this.memoSigningService.prepareMemoSigning(memoID, actor); + } + + @Mutation(() => MemoSigningContinueResult, { + description: 'Starts signing the prepared Memo copy.', + }) + continueMemoSigning( + @CurrentActor() actor: ActorContext, + @Args('signingData') { attemptID }: MemoSigningContinueInput + ): Promise { + return this.memoSigningService.continueMemoSigning(attemptID, actor); + } + @Mutation(() => IMemo, { description: 'Updates the specified Memo.', }) diff --git a/src/domain/common/memo/memo.service.spec.ts b/src/domain/common/memo/memo.service.spec.ts index a1ffebe658..f042dfc38e 100644 --- a/src/domain/common/memo/memo.service.spec.ts +++ b/src/domain/common/memo/memo.service.spec.ts @@ -7,6 +7,7 @@ import { RelationshipNotFoundException, } from '@common/exceptions'; import { CollaborationLifecycleService } from '@domain/common/collaboration-metadata'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; import { ILicense } from '@domain/common/license/license.interface'; import { ProfileDocumentsService } from '@domain/profile-documents/profile.documents.service'; import { Test, TestingModule } from '@nestjs/testing'; @@ -39,6 +40,7 @@ describe('MemoService', () => { let collaborationDocumentService: CollaborationDocumentService; let communityResolverService: CommunityResolverService; let licenseService: LicenseService; + let signingAttemptService: SigningAttemptService; beforeEach(async () => { vi.restoreAllMocks(); @@ -71,6 +73,7 @@ describe('MemoService', () => { collaborationDocumentService = module.get(CollaborationDocumentService); communityResolverService = module.get(CommunityResolverService); licenseService = module.get(LicenseService); + signingAttemptService = module.get(SigningAttemptService); }); describe('getMemoOrFail', () => { @@ -93,7 +96,7 @@ describe('MemoService', () => { }); describe('deleteMemo', () => { - it('should delete profile, authorization, and memo', async () => { + it('releases signing attempts after publish-confirm and before deleting the memo profile', async () => { const memo = { id: 'memo-1', profile: { id: 'profile-1' }, @@ -115,6 +118,18 @@ describe('MemoService', () => { expect( collaborationLifecycleService.publishDocumentDeleted ).toHaveBeenCalledWith('memo-1'); + expect(signingAttemptService.deleteForMemo).toHaveBeenCalledWith( + 'memo-1' + ); + const published = ( + collaborationLifecycleService.publishDocumentDeleted as Mock + ).mock.invocationCallOrder[0]; + const attemptsReleased = (signingAttemptService.deleteForMemo as Mock) + .mock.invocationCallOrder[0]; + const profileDeleted = (profileService.deleteProfile as Mock).mock + .invocationCallOrder[0]; + expect(published).toBeLessThan(attemptsReleased); + expect(attemptsReleased).toBeLessThan(profileDeleted); expect(memoRepository.remove).toHaveBeenCalledWith(memo); expect(result.id).toBe('memo-1'); }); diff --git a/src/domain/common/memo/memo.service.ts b/src/domain/common/memo/memo.service.ts index 49d00b1b71..2b98e528ad 100644 --- a/src/domain/common/memo/memo.service.ts +++ b/src/domain/common/memo/memo.service.ts @@ -14,6 +14,7 @@ import { CollaborationMetadata, CollaborationMetadataUpdate, } from '@domain/common/collaboration-metadata'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; import type { ILicense } from '@domain/common/license/license.interface'; import { IProfile } from '@domain/common/profile'; import { ProfileDocumentsService } from '@domain/profile-documents/profile.documents.service'; @@ -54,7 +55,8 @@ export class MemoService { private licenseService: LicenseService, private collaborationLifecycleService: CollaborationLifecycleService, private fileServiceAdapter: FileServiceAdapter, - private collaborationDocumentService: CollaborationDocumentService + private collaborationDocumentService: CollaborationDocumentService, + private signingAttemptService: SigningAttemptService ) {} async createMemo( @@ -252,6 +254,7 @@ export class MemoService { // that remains in the DB, but the tombstone expires and a retry is idempotent. await this.collaborationLifecycleService.publishDocumentDeleted(memoID); + await this.signingAttemptService.deleteForMemo(memoID); await this.profileService.deleteProfile(memo.profile.id); await this.authorizationPolicyService.delete(memo.authorization); const deletedMemo = await this.memoRepository.remove(memo as Memo); diff --git a/src/domain/common/memo/memo.signature.resolver.fields.ts b/src/domain/common/memo/memo.signature.resolver.fields.ts new file mode 100644 index 0000000000..a510aa1090 --- /dev/null +++ b/src/domain/common/memo/memo.signature.resolver.fields.ts @@ -0,0 +1,37 @@ +import { UserLoaderCreator } from '@core/dataloader/creators'; +import { Loader } from '@core/dataloader/decorators'; +import { ILoader } from '@core/dataloader/loader.interface'; +import { SigningAttempt } from '@domain/common/content-signing/signing.attempt.entity'; +import { IMemoSignature } from '@domain/common/content-signing/signing.attempt.interface'; +import { DELETED_USER_SENTINEL } from '@domain/community/user/account-deletion/deleted.user.sentinel'; +import { IUser } from '@domain/community/user/user.interface'; +import { IDocument } from '@domain/storage/document/document.interface'; +import { DocumentService } from '@domain/storage/document/document.service'; +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; + +@Resolver(() => IMemoSignature) +export class MemoSignatureResolverFields { + constructor(private readonly documentService: DocumentService) {} + + @ResolveField('actor', () => IUser, { + nullable: true, + description: 'The Alkemio user who initiated this signed copy.', + }) + async actor( + @Parent() attempt: SigningAttempt, + @Loader(UserLoaderCreator, { resolveToNull: true }) + loader: ILoader + ): Promise { + return (await loader.load(attempt.actorId)) ?? DELETED_USER_SENTINEL; + } + + @ResolveField('document', () => IDocument, { + nullable: true, + description: 'The immutable PDF produced for this signed copy.', + }) + document(@Parent() attempt: SigningAttempt): Promise | null { + return attempt.signedDocumentId + ? this.documentService.getDocumentOrFail(attempt.signedDocumentId) + : null; + } +} diff --git a/src/domain/common/memo/memo.signature.verification.status.ts b/src/domain/common/memo/memo.signature.verification.status.ts new file mode 100644 index 0000000000..183fb0d471 --- /dev/null +++ b/src/domain/common/memo/memo.signature.verification.status.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum MemoSignatureVerificationStatus { + VERIFIED = 'VERIFIED', + INVALID = 'INVALID', + UNAVAILABLE = 'UNAVAILABLE', +} + +registerEnumType(MemoSignatureVerificationStatus, { + name: 'MemoSignatureVerificationStatus', +}); diff --git a/src/domain/common/memo/memo.signing.resolver.spec.ts b/src/domain/common/memo/memo.signing.resolver.spec.ts new file mode 100644 index 0000000000..1a993baca3 --- /dev/null +++ b/src/domain/common/memo/memo.signing.resolver.spec.ts @@ -0,0 +1,150 @@ +import { AuthorizationPrivilege } from '@common/enums'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { UserLoaderCreator } from '@core/dataloader/creators'; +import { DATA_LOADER_CTX_INJECT_TOKEN } from '@core/dataloader/data.loader.inject.token'; +import { SigningAttemptStatus } from '@domain/common/content-signing/signing.attempt.status'; +import { DELETED_USER_SENTINEL } from '@domain/community/user/account-deletion/deleted.user.sentinel'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { MemoResolverFields } from './memo.resolver.fields'; +import { MemoSignatureResolverFields } from './memo.signature.resolver.fields'; + +describe('memo signing GraphQL reads', () => { + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const attempt = { + id: 'attempt-1', + actorId: 'deleted-user', + memoId: 'memo-1', + signedDocumentId: 'document-1', + status: SigningAttemptStatus.SIGNED, + }; + + it('returns an attempt only through its initiating actor binding', async () => { + const attemptService = { + getForActorOrFail: vi.fn().mockResolvedValue(attempt), + }; + const resolver = new MemoResolverFields( + {} as any, + attemptService as any, + {} as any, + {} as any, + {} as any + ); + + await expect(resolver.signingAttempt(actor, 'attempt-1')).resolves.toBe( + attempt + ); + expect(attemptService.getForActorOrFail).toHaveBeenCalledWith( + 'attempt-1', + 'actor-1' + ); + }); + + it('READ-gates a memo signed-copy list and returns only service-filtered rows', async () => { + const memoService = { isMultiUser: vi.fn() }; + const authorizationService = { grantAccessOrFail: vi.fn() }; + const attemptService = { + findSignedForMemo: vi.fn().mockResolvedValue([attempt]), + }; + const resolver = new MemoResolverFields( + memoService as any, + attemptService as any, + authorizationService as any, + {} as any, + {} as any + ); + + await expect( + resolver.signatures( + { id: 'memo-1', authorization: { id: 'memo-auth' } } as any, + actor + ) + ).resolves.toEqual([attempt]); + expect(memoService.isMultiUser).not.toHaveBeenCalled(); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + actor, + { id: 'memo-auth' }, + AuthorizationPrivilege.READ, + 'read memo signatures' + ); + expect(attemptService.findSignedForMemo).toHaveBeenCalledWith('memo-1'); + }); + + it('forwards an explicit signature verification query with the current actor', async () => { + const memoSigningService = { + verifyMemoSignature: vi.fn().mockResolvedValue('VERIFIED'), + }; + const resolver = new MemoResolverFields( + {} as any, + {} as any, + {} as any, + memoSigningService as any, + {} as any + ); + + await expect( + resolver.verifyMemoSignature(actor, { attemptID: 'attempt-1' }) + ).resolves.toBe('VERIFIED'); + expect(memoSigningService.verifyMemoSignature).toHaveBeenCalledWith( + 'attempt-1', + actor + ); + }); + + it('resolves the stored signed document and attributes a missing actor to the deleted-user sentinel', async () => { + const document = { id: 'document-1' }; + const documentService = { + getDocumentOrFail: vi.fn().mockResolvedValue(document), + }; + const resolver = new MemoSignatureResolverFields(documentService as any); + const userLoader = { load: vi.fn().mockResolvedValue(null) }; + + await expect(resolver.document(attempt as any)).resolves.toBe(document); + await expect( + resolver.actor(attempt as any, userLoader as any) + ).resolves.toBe(DELETED_USER_SENTINEL); + expect(documentService.getDocumentOrFail).toHaveBeenCalledWith( + 'document-1' + ); + expect(userLoader.load).toHaveBeenCalledWith('deleted-user'); + + const metadata = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + MemoSignatureResolverFields, + 'actor' + ) as Record< + string, + { + index: number; + factory: (data: unknown, context: unknown) => unknown; + data: unknown; + } + >; + const loaderParameter = Object.values(metadata).find( + parameter => parameter.index === 1 + ); + const get = vi.fn().mockReturnValue(userLoader); + loaderParameter?.factory(loaderParameter.data, { + getType: () => 'graphql', + getArgs: () => [ + attempt, + {}, + { [DATA_LOADER_CTX_INJECT_TOKEN]: { get } }, + undefined, + ], + getClass: () => MemoSignatureResolverFields, + getHandler: () => MemoSignatureResolverFields.prototype.actor, + }); + expect(get).toHaveBeenCalledWith(UserLoaderCreator, { + resolveToNull: true, + }); + + expect( + resolver.document({ + ...attempt, + status: SigningAttemptStatus.CANCELLED, + signedDocumentId: undefined, + } as any) + ).toBeNull(); + expect(documentService.getDocumentOrFail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/domain/common/memo/memo.signing.service.spec.ts b/src/domain/common/memo/memo.signing.service.spec.ts new file mode 100644 index 0000000000..0c8b5d4620 --- /dev/null +++ b/src/domain/common/memo/memo.signing.service.spec.ts @@ -0,0 +1,1297 @@ +import { createHash } from 'node:crypto'; +import { AlkemioErrorStatus } from '@common/enums'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { LogContext } from '@common/enums/logging.context'; +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { ForbiddenAuthorizationPolicyException } from '@common/exceptions/forbidden.authorization.policy.exception'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { SigningAttemptStatus } from '@domain/common/content-signing/signing.attempt.status'; +import { prosemirrorJSONToYDoc } from '@tiptap/y-tiptap'; +import { markdownSchema } from './conversion/markdown.schema'; +import { MemoSigningService } from './memo.signing.service'; + +describe('MemoSigningService', () => { + type MemoFixture = { + id: string; + nameID: string; + authorization: { id: string }; + profile?: { + storageBucket: { id: string; authorization: { id: string } }; + }; + }; + const actor = Object.assign(new ActorContext(), { + actorID: '11111111-1111-4111-8111-111111111111', + authenticationID: 'kratos-1', + }); + const memo: MemoFixture = { + id: '22222222-2222-4222-8222-222222222222', + nameID: 'signed-memo', + authorization: { id: 'memo-auth' }, + profile: { + storageBucket: { id: 'bucket-1', authorization: { id: 'bucket-auth' } }, + }, + }; + const pdf = Buffer.from('%PDF-fixed-preview'); + const calls: string[] = []; + const authorizationService = { + grantAccessOrFail: vi.fn(() => calls.push('authorize')), + }; + const memoService = { + getMemoOrFail: vi.fn<() => Promise>(async () => memo), + }; + const attemptService = { + createUnready: vi.fn(async () => { + calls.push('insert'); + return { id: 'attempt-1' }; + }), + finalizePrepared: vi.fn(async () => { + calls.push('finalize'); + return true; + }), + getForActorOrFail: vi.fn(), + claimStart: vi.fn(), + recordGatewayStart: vi.fn(), + getForReturnOrFail: vi.fn(), + finish: vi.fn(), + findSignedForMemo: vi.fn(), + getSignedOrFail: vi.fn(), + }; + const kratosService = { + getCleverbaseSubject: vi.fn<() => Promise>(async () => { + calls.push('identity'); + return 'linked-subject'; + }), + }; + const collaborationDocumentService = { + read: vi.fn< + ( + id: string, + type: string, + actorId: string, + project: (document: ReturnType) => string + ) => Promise + >(async () => { + calls.push('live-read'); + return '# Current content'; + }), + }; + const renderer = { + render: vi.fn< + ( + markdown: string, + storageBucketId: string, + actor: ActorContext + ) => Promise + >(async () => { + calls.push('render'); + return pdf; + }), + }; + const fileServiceAdapter = { + createInternalDocumentInBucket: vi.fn(async () => { + calls.push('upload'); + return { id: 'snapshot-1' }; + }), + deleteDocument: vi.fn(), + getDocumentContent: vi.fn(), + }; + const trustGatewayClient = { + start: vi.fn(), + getStatus: vi.fn(), + getResult: vi.fn(), + verify: vi.fn(), + }; + const urlGeneratorService = { + getMemoSigningSnapshotRestUrl: vi.fn( + () => + 'https://alkem.io/api/private/rest/content-signing/attempt-1/snapshot' + ), + getMemoUrlPath: vi.fn().mockResolvedValue('/space/demo/callout/memo'), + }; + const logger = { error: vi.fn() }; + const storageBucketService = { + uploadFileAsDocumentFromBuffer: vi.fn(), + }; + const documentAuthorizationService = { applyAuthorizationPolicy: vi.fn() }; + const documentService = { + deleteDocument: vi.fn(), + getPubliclyAccessibleURL: vi.fn( + (document: { id: string }) => `https://alkem.io/document/${document.id}` + ), + }; + const service = new MemoSigningService( + authorizationService as any, + memoService as any, + attemptService as any, + kratosService as any, + collaborationDocumentService as any, + renderer as any, + fileServiceAdapter as any, + trustGatewayClient as any, + urlGeneratorService as any, + storageBucketService as any, + documentAuthorizationService as any, + documentService as any, + logger as any + ); + beforeEach(() => { + calls.length = 0; + vi.clearAllMocks(); + memoService.getMemoOrFail.mockResolvedValue(memo); + kratosService.getCleverbaseSubject.mockImplementation(async () => { + calls.push('identity'); + return 'linked-subject'; + }); + attemptService.createUnready.mockImplementation(async () => { + calls.push('insert'); + return { id: 'attempt-1' }; + }); + collaborationDocumentService.read.mockImplementation(async () => { + calls.push('live-read'); + return '# Current content'; + }); + renderer.render.mockImplementation(async () => { + calls.push('render'); + return pdf; + }); + fileServiceAdapter.createInternalDocumentInBucket.mockImplementation( + async () => { + calls.push('upload'); + return { id: 'snapshot-1' }; + } + ); + attemptService.finalizePrepared.mockImplementation(async () => { + calls.push('finalize'); + return true; + }); + attemptService.claimStart.mockResolvedValue(true); + attemptService.recordGatewayStart.mockResolvedValue(true); + attemptService.finish.mockResolvedValue(true); + trustGatewayClient.start.mockResolvedValue({ + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }); + fileServiceAdapter.deleteDocument.mockResolvedValue(undefined); + storageBucketService.uploadFileAsDocumentFromBuffer.mockResolvedValue({ + id: 'signed-document-1', + }); + documentAuthorizationService.applyAuthorizationPolicy.mockResolvedValue( + undefined + ); + documentService.deleteDocument.mockResolvedValue(undefined); + urlGeneratorService.getMemoSigningSnapshotRestUrl.mockReturnValue( + 'https://alkem.io/api/private/rest/content-signing/attempt-1/snapshot' + ); + }); + + it('checks access and linked identity before row-first live rendering', async () => { + const result = await service.prepareMemoSigning(memo.id, actor); + + expect(calls).toEqual([ + 'authorize', + 'identity', + 'insert', + 'live-read', + 'render', + 'upload', + 'finalize', + ]); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + actor, + memo.authorization, + AuthorizationPrivilege.CONTRIBUTE, + 'sign memo' + ); + expect(collaborationDocumentService.read).toHaveBeenCalledWith( + memo.id, + 'memo', + actor.actorID, + expect.any(Function) + ); + expect(renderer.render).toHaveBeenCalledWith( + '# Current content', + 'bucket-1', + actor + ); + expect( + fileServiceAdapter.createInternalDocumentInBucket + ).toHaveBeenCalledWith( + pdf, + 'bucket-1', + 'memo-signing-preview.pdf', + 'application/pdf', + { skipDedup: true } + ); + expect(attemptService.finalizePrepared).toHaveBeenCalledWith( + 'attempt-1', + 'snapshot-1', + createHash('sha256').update(pdf).digest('hex') + ); + expect(result).toEqual({ + attemptId: 'attempt-1', + previewUrl: + 'https://alkem.io/api/private/rest/content-signing/attempt-1/snapshot', + }); + }); + + it('renders the current projection with its known table span and non-paragraph losses', async () => { + const liveDocument = prosemirrorJSONToYDoc( + markdownSchema, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [{ type: 'highlight' }], + text: 'Projected highlight text', + }, + ], + }, + { + type: 'orderedList', + attrs: { start: 4 }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Projected list item' }], + }, + ], + }, + ], + }, + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + attrs: { colspan: 2, rowspan: 1, colwidth: [120, 120] }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Projected header' }], + }, + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Omitted rich block' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + 'default' + ); + collaborationDocumentService.read.mockImplementation( + async (_id, _type, _actor, project) => project(liveDocument) + ); + + try { + await service.prepareMemoSigning(memo.id, actor); + } finally { + liveDocument.destroy(); + } + + const projection = renderer.render.mock.calls[0][0]; + expect(projection).toContain('==Projected highlight text=='); + expect(projection).toContain('1. Projected list item'); + expect(projection).not.toContain('4. Projected list item'); + expect(projection).toContain('| Projected header |'); + expect(projection).not.toContain('Omitted rich block'); + expect(projection).not.toContain('colspan'); + expect(projection).not.toContain('120'); + }); + + it('fails an unlinked identity before inserting or rendering', async () => { + kratosService.getCleverbaseSubject.mockResolvedValue(undefined); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + ValidationException + ); + expect(attemptService.createUnready).not.toHaveBeenCalled(); + expect(renderer.render).not.toHaveBeenCalled(); + }); + + it('fails memo authorization before checking identity or rendering', async () => { + authorizationService.grantAccessOrFail.mockImplementationOnce(() => { + throw new Error('denied'); + }); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + 'denied' + ); + expect(kratosService.getCleverbaseSubject).not.toHaveBeenCalled(); + expect(attemptService.createUnready).not.toHaveBeenCalled(); + expect(renderer.render).not.toHaveBeenCalled(); + }); + + it('fails a session without a Kratos identity before rendering', async () => { + const unlinkedActor = Object.assign(new ActorContext(), { + actorID: actor.actorID, + }); + + await expect( + service.prepareMemoSigning(memo.id, unlinkedActor) + ).rejects.toThrow(ValidationException); + expect(kratosService.getCleverbaseSubject).not.toHaveBeenCalled(); + expect(renderer.render).not.toHaveBeenCalled(); + }); + + it('fails a memo without a storage bucket before inserting or rendering', async () => { + memoService.getMemoOrFail.mockResolvedValue({ + ...memo, + profile: undefined, + }); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + /storage/i + ); + expect(attemptService.createUnready).not.toHaveBeenCalled(); + expect(renderer.render).not.toHaveBeenCalled(); + }); + + it('deletes only its uploaded PDF when memo deletion wins finalization', async () => { + attemptService.finalizePrepared.mockResolvedValue(false); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + ValidationException + ); + expect(fileServiceAdapter.deleteDocument).toHaveBeenCalledWith( + 'snapshot-1' + ); + }); + + it('does not let compensation failure mask a lost preparation', async () => { + attemptService.finalizePrepared.mockResolvedValue(false); + fileServiceAdapter.deleteDocument.mockRejectedValue( + new Error('cleanup failed') + ); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + /memo was deleted/i + ); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing document cleanup failed', + attemptId: 'attempt-1', + documentId: 'snapshot-1', + }, + undefined, + LogContext.MEMOS + ); + }); + + it('leaves an unready row for the bounded sweep when rendering fails', async () => { + renderer.render.mockRejectedValue(new Error('renderer failed')); + + await expect(service.prepareMemoSigning(memo.id, actor)).rejects.toThrow( + 'renderer failed' + ); + expect(attemptService.createUnready).toHaveBeenCalled(); + expect(fileServiceAdapter.deleteDocument).not.toHaveBeenCalled(); + expect(attemptService.finalizePrepared).not.toHaveBeenCalled(); + }); + + it('streams only the initiating actor snapshot after current memo access', async () => { + attemptService.getForActorOrFail.mockResolvedValue({ + memoId: memo.id, + snapshotDocumentId: 'snapshot-1', + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(pdf); + + await expect(service.getSnapshot('attempt-1', actor)).resolves.toBe(pdf); + expect(attemptService.getForActorOrFail).toHaveBeenCalledWith( + 'attempt-1', + actor.actorID + ); + expect(authorizationService.grantAccessOrFail).toHaveBeenLastCalledWith( + actor, + memo.authorization, + AuthorizationPrivilege.CONTRIBUTE, + expect.any(String) + ); + expect(fileServiceAdapter.getDocumentContent).toHaveBeenCalledWith( + 'snapshot-1' + ); + }); + + it('rejects an unrelated actor before reading the memo or snapshot', async () => { + attemptService.getForActorOrFail.mockRejectedValue( + new ValidationException('not this actor', undefined as any) + ); + + await expect(service.getSnapshot('attempt-1', actor)).rejects.toThrow( + ForbiddenException + ); + expect(memoService.getMemoOrFail).not.toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('preserves an attempt lookup failure', async () => { + attemptService.getForActorOrFail.mockRejectedValue( + new Error('attempt storage unavailable') + ); + + await expect(service.getSnapshot('attempt-1', actor)).rejects.toThrow( + 'attempt storage unavailable' + ); + expect(memoService.getMemoOrFail).not.toHaveBeenCalled(); + }); + + it('rejects an unready preview after checking current memo access', async () => { + attemptService.getForActorOrFail.mockResolvedValue({ + memoId: memo.id, + snapshotDocumentId: undefined, + }); + + await expect(service.getSnapshot('attempt-1', actor)).rejects.toThrow( + /not ready/i + ); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('continues the exact ready snapshot once and exposes only the authorize URL', async () => { + const snapshot = Buffer.from('%PDF-exact-preview'); + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: createHash('sha256').update(snapshot).digest('hex'), + createdDate: new Date(), + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(snapshot); + + const result = await service.continueMemoSigning('attempt-1', actor); + + expect(result).toEqual({ + authorizeUrl: 'https://connect.acc.cleverbase.com/authorize', + }); + expect(Object.keys(result)).toEqual(['authorizeUrl']); + expect(trustGatewayClient.start).toHaveBeenCalledWith( + snapshot, + 'linked-subject', + expect.any(String) + ); + const rawState = trustGatewayClient.start.mock.calls[0][2]; + expect(rawState).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(attemptService.claimStart).toHaveBeenCalledWith( + 'attempt-1', + createHash('sha256').update(rawState).digest('hex') + ); + expect(attemptService.recordGatewayStart).toHaveBeenCalledWith( + 'attempt-1', + createHash('sha256').update(rawState).digest('hex'), + 'correlation-1', + expect.any(Date) + ); + }); + + it.each([ + [ + 'unready', + { snapshotDocumentId: undefined, contentSha256: undefined }, + /not ready/i, + ], + [ + 'expired', + { + snapshotDocumentId: 'snapshot-1', + contentSha256: 'ab'.repeat(32), + createdDate: new Date(Date.now() - 60 * 60 * 1000 - 1), + }, + /expired/i, + ], + [ + 'already signed', + { + status: SigningAttemptStatus.SIGNED, + snapshotDocumentId: 'snapshot-1', + contentSha256: 'ab'.repeat(32), + }, + /expired/i, + ], + ])('rejects an %s attempt before reading bytes or starting', async (_name, fields, message) => { + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + createdDate: new Date(), + ...fields, + }); + + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow(message); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + expect(trustGatewayClient.start).not.toHaveBeenCalled(); + }); + + it('fails closed when the initiating actor or linked identity is unavailable', async () => { + attemptService.getForActorOrFail.mockRejectedValueOnce( + new Error('not this actor') + ); + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow('not this actor'); + expect(memoService.getMemoOrFail).not.toHaveBeenCalled(); + + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: 'ab'.repeat(32), + createdDate: new Date(), + }); + kratosService.getCleverbaseSubject.mockResolvedValue(undefined); + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow(/link a Cleverbase identity/i); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + }); + + it('rejects changed snapshot bytes before claiming or starting', async () => { + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: 'ab'.repeat(32), + createdDate: new Date(), + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + Buffer.from('%PDF-different') + ); + + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow(/changed/i); + expect(attemptService.claimStart).not.toHaveBeenCalled(); + expect(trustGatewayClient.start).not.toHaveBeenCalled(); + }); + + it('rejects revoked memo contribution before reading or claiming the prepared snapshot', async () => { + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: 'ab'.repeat(32), + createdDate: new Date(), + }); + authorizationService.grantAccessOrFail.mockImplementationOnce(() => { + throw new ForbiddenAuthorizationPolicyException( + 'memo contribution revoked', + AuthorizationPrivilege.CONTRIBUTE, + memo.authorization.id, + actor.actorID + ); + }); + + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toBeInstanceOf(ForbiddenAuthorizationPolicyException); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + expect(attemptService.claimStart).not.toHaveBeenCalled(); + expect(trustGatewayClient.start).not.toHaveBeenCalled(); + }); + + it('lets only the winning concurrent claim start the gateway', async () => { + const snapshot = Buffer.from('%PDF-exact-preview'); + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: createHash('sha256').update(snapshot).digest('hex'), + createdDate: new Date(), + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(snapshot); + let claimed = false; + attemptService.claimStart.mockImplementation(async () => { + if (claimed) return false; + claimed = true; + return true; + }); + + const results = await Promise.allSettled([ + service.continueMemoSigning('attempt-1', actor), + service.continueMemoSigning('attempt-1', actor), + ]); + + expect( + results.filter(result => result.status === 'fulfilled') + ).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength( + 1 + ); + expect(trustGatewayClient.start).toHaveBeenCalledOnce(); + }); + + it.each([ + ['lost gateway response', 'gateway-start', 503], + ['failed start persistence', 'gateway-start-persistence', undefined], + ['lost conditional persistence', 'gateway-start-persistence', undefined], + ])('%s consumes the claim and a retry never starts again', async (failure, stage, status) => { + const snapshot = Buffer.from('%PDF-exact-preview'); + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: createHash('sha256').update(snapshot).digest('hex'), + createdDate: new Date(), + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(snapshot); + attemptService.claimStart + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + if (failure === 'lost gateway response') + trustGatewayClient.start.mockRejectedValueOnce( + Object.assign(new Error('secret transport detail'), { + code: 'ECONNRESET', + response: { status: 503 }, + config: { data: '%PDF-secret raw-client-state linked-subject' }, + }) + ); + else if (failure === 'failed start persistence') + attemptService.recordGatewayStart.mockRejectedValueOnce( + Object.assign(new Error('secret database detail'), { code: '40001' }) + ); + else attemptService.recordGatewayStart.mockResolvedValueOnce(false); + + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow(/fresh signing attempt/i); + await expect( + service.continueMemoSigning('attempt-1', actor) + ).rejects.toThrow(/fresh signing attempt/i); + expect(trustGatewayClient.start).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing start failed after the attempt was claimed', + attemptId: 'attempt-1', + stage, + status, + }, + undefined, + LogContext.MEMOS + ); + }); + + it('attaches a fresh authorized signed copy and releases the preview after completed return', async () => { + const state = 'raw-client-state'; + const attempt = { + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + snapshotDocumentId: 'snapshot-1', + }; + attemptService.getForReturnOrFail.mockResolvedValue(attempt); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + const signedPdf = Buffer.from('%PDF-signed'); + const evidence = { signer: { serial_number: 'ABC' } }; + trustGatewayClient.getResult.mockResolvedValue({ + pdf: signedPdf, + evidence, + }); + + await expect( + service.completeMemoSigning('correlation-1', state, actor) + ).resolves.toEqual({ + memoUrl: '/space/demo/callout/memo', + attemptId: 'attempt-1', + status: SigningAttemptStatus.SIGNED, + }); + expect(attemptService.getForReturnOrFail).toHaveBeenCalledWith( + 'correlation-1', + actor.actorID, + createHash('sha256').update(state).digest('hex') + ); + expect( + storageBucketService.uploadFileAsDocumentFromBuffer + ).toHaveBeenCalledWith( + 'bucket-1', + signedPdf, + 'memo-signed.pdf', + 'application/pdf', + undefined, + false, + true + ); + expect( + documentAuthorizationService.applyAuthorizationPolicy + ).toHaveBeenCalledWith( + { id: 'signed-document-1' }, + memo.profile?.storageBucket.authorization + ); + expect(attemptService.finish).toHaveBeenCalledWith( + 'attempt-1', + SigningAttemptStatus.SIGNED, + 'signed-document-1', + evidence + ); + expect(fileServiceAdapter.deleteDocument).toHaveBeenCalledWith( + 'snapshot-1' + ); + }); + + it.each([ + [{ status: 'declined' }, SigningAttemptStatus.CANCELLED], + [ + { status: 'failed', reason: 'authorization_expired' }, + SigningAttemptStatus.EXPIRED, + ], + [ + { status: 'failed', reason: 'session_expired' }, + SigningAttemptStatus.EXPIRED, + ], + [ + { status: 'failed', reason: 'signature_invalid' }, + SigningAttemptStatus.FAILED, + ], + [{ status: 'failed' }, SigningAttemptStatus.FAILED], + [undefined, SigningAttemptStatus.EXPIRED], + ])('records terminal gateway outcome %# without uploading', async (gatewayStatus, status) => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue(gatewayStatus); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status }); + expect(attemptService.finish).toHaveBeenCalledWith('attempt-1', status); + expect( + storageBucketService.uploadFileAsDocumentFromBuffer + ).not.toHaveBeenCalled(); + expect(fileServiceAdapter.deleteDocument).toHaveBeenCalledWith( + 'snapshot-1' + ); + }); + + it('reports a snapshot cleanup failure without changing the terminal outcome', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'declined' }); + fileServiceAdapter.deleteDocument.mockRejectedValue( + new Error('secret file response') + ); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.CANCELLED }); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing document cleanup failed', + attemptId: 'attempt-1', + documentId: 'snapshot-1', + }, + undefined, + LogContext.MEMOS + ); + }); + + it('does not call file-service cleanup for an expired attempt without a snapshot', async () => { + await service.releaseExpiredAttemptFiles({ + id: 'attempt-unprepared', + snapshotDocumentId: null, + } as any); + + expect(fileServiceAdapter.deleteDocument).not.toHaveBeenCalled(); + }); + + it.each([ + { status: 'pending' }, + { status: 'authorizing' }, + ])('leaves a valid nonterminal %s return pending and retryable', async gatewayStatus => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue(gatewayStatus); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('leaves transport failure pending and retryable', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockRejectedValue(new Error('gateway down')); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('leaves a malformed status response pending with its snapshot intact', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockRejectedValue( + new ValidationException('Invalid gateway response', LogContext.MEMOS) + ); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + expect(fileServiceAdapter.deleteDocument).not.toHaveBeenCalled(); + expect(documentService.deleteDocument).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing gateway response was malformed', + attemptId: 'attempt-1', + status: AlkemioErrorStatus.BAD_USER_INPUT, + }, + undefined, + LogContext.MEMOS + ); + }); + + it('leaves a result transport failure pending and retryable', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockRejectedValue(new Error('gateway down')); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('stops a return after revoked memo CONTRIBUTE without gateway or file effects', async () => { + const denied = new ForbiddenAuthorizationPolicyException( + 'memo access denied', + AuthorizationPrivilege.CONTRIBUTE, + 'memo-auth', + actor.actorID + ); + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + authorizationService.grantAccessOrFail.mockImplementationOnce(() => { + throw denied; + }); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toBe(denied); + expect(trustGatewayClient.getStatus).not.toHaveBeenCalled(); + expect(trustGatewayClient.getResult).not.toHaveBeenCalled(); + expect( + storageBucketService.uploadFileAsDocumentFromBuffer + ).not.toHaveBeenCalled(); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('leaves a completed journey pending while its result returns 409', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue(null); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('expires a completed journey whose result has been evicted', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue(undefined); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.EXPIRED }); + expect(attemptService.finish).toHaveBeenCalledWith( + 'attempt-1', + SigningAttemptStatus.EXPIRED + ); + }); + + it('leaves malformed completed evidence pending and logs only a safe status', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockRejectedValue( + new ValidationException('Invalid gateway response', undefined as any) + ); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toThrow(/retry this page/i); + expect(attemptService.finish).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing gateway response was malformed', + attemptId: 'attempt-1', + status: AlkemioErrorStatus.BAD_USER_INPUT, + }, + undefined, + LogContext.MEMOS + ); + expect( + storageBucketService.uploadFileAsDocumentFromBuffer + ).not.toHaveBeenCalled(); + }); + + it('returns a terminal replay without a second gateway or file operation', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + }); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.SIGNED }); + expect(trustGatewayClient.getStatus).not.toHaveBeenCalled(); + expect( + storageBucketService.uploadFileAsDocumentFromBuffer + ).not.toHaveBeenCalled(); + }); + + it('expires a claimed attempt whose gateway start was never recorded', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: null, + snapshotDocumentId: 'snapshot-1', + }); + + await expect( + service.completeMemoSigning('unrecorded-correlation', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.EXPIRED }); + expect(trustGatewayClient.getStatus).not.toHaveBeenCalled(); + expect(attemptService.finish).toHaveBeenCalledWith( + 'attempt-1', + SigningAttemptStatus.EXPIRED + ); + }); + + it('deletes only its losing signed upload and returns the concurrent winner', async () => { + const pending = { + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }; + attemptService.getForReturnOrFail.mockResolvedValue(pending); + attemptService.finish.mockResolvedValue(false); + attemptService.getForActorOrFail.mockResolvedValue({ + ...pending, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'winner-document', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue({ + pdf: Buffer.from('%PDF-signed'), + evidence: {}, + }); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.SIGNED }); + expect(documentService.deleteDocument).toHaveBeenCalledWith({ + ID: 'signed-document-1', + }); + }); + + it('deletes its owned signed upload when authorization fails', async () => { + const authorizationFailure = new Error('policy write failed'); + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue({ + pdf: Buffer.from('%PDF-signed'), + evidence: {}, + }); + documentAuthorizationService.applyAuthorizationPolicy.mockRejectedValue( + authorizationFailure + ); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toBe(authorizationFailure); + expect(documentService.deleteDocument).toHaveBeenCalledWith({ + ID: 'signed-document-1', + }); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('returns expired for a vanished row and reports losing-upload cleanup failure safely', async () => { + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + attemptService.finish.mockResolvedValue(false); + attemptService.getForActorOrFail.mockRejectedValue( + new ValidationException('attempt deleted', undefined as any) + ); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue({ + pdf: Buffer.from('%PDF-signed'), + evidence: {}, + }); + documentService.deleteDocument.mockRejectedValue( + new Error('cleanup failed') + ); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).resolves.toMatchObject({ status: SigningAttemptStatus.EXPIRED }); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signing document cleanup failed', + attemptId: 'attempt-1', + documentId: 'signed-document-1', + }, + undefined, + LogContext.MEMOS + ); + }); + + it('propagates an unexpected winner read failure after deleting its losing upload', async () => { + const readFailure = new Error('database unavailable'); + attemptService.getForReturnOrFail.mockResolvedValue({ + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + correlationId: 'correlation-1', + }); + attemptService.finish.mockResolvedValue(false); + attemptService.getForActorOrFail.mockRejectedValue(readFailure); + trustGatewayClient.getStatus.mockResolvedValue({ status: 'completed' }); + trustGatewayClient.getResult.mockResolvedValue({ + pdf: Buffer.from('%PDF-signed'), + evidence: {}, + }); + + await expect( + service.completeMemoSigning('correlation-1', 'state', actor) + ).rejects.toBe(readFailure); + expect(documentService.deleteDocument).toHaveBeenCalledWith({ + ID: 'signed-document-1', + }); + }); + + it('verifies exact stored signed bytes for any actor with memo READ access', async () => { + const signedPdf = Buffer.from('%PDF-stored-signed-copy'); + const reader = Object.assign(new ActorContext(), { + actorID: '33333333-3333-4333-8333-333333333333', + }); + attemptService.getSignedOrFail.mockResolvedValue({ + id: 'attempt-1', + actorId: actor.actorID, + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(signedPdf); + trustGatewayClient.verify.mockResolvedValue({ + integrity: true, + reasons: [], + }); + + await expect( + service.verifyMemoSignature('attempt-1', reader) + ).resolves.toBe('VERIFIED'); + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + reader, + memo.authorization, + AuthorizationPrivilege.READ, + 'verify memo signature' + ); + expect(fileServiceAdapter.getDocumentContent).toHaveBeenCalledWith( + 'signed-document-1' + ); + expect(trustGatewayClient.verify).toHaveBeenCalledWith(signedPdf); + expect(attemptService.finish).not.toHaveBeenCalled(); + }); + + it('reports invalid integrity without exposing gateway reason codes', async () => { + attemptService.getSignedOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + Buffer.from('%PDF-stored-signed-copy') + ); + trustGatewayClient.verify.mockResolvedValue({ + integrity: false, + reasons: ['message_digest_mismatch'], + }); + + await expect(service.verifyMemoSignature('attempt-1', actor)).resolves.toBe( + 'INVALID' + ); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signature integrity verification failed', + attemptId: 'attempt-1', + reasons: ['message_digest_mismatch'], + }, + undefined, + LogContext.MEMOS + ); + }); + + it.each([ + [new Error('gateway unavailable'), undefined], + [{ response: { status: 503 } }, 503], + [ + new ValidationException('Invalid gateway response', LogContext.MEMOS), + undefined, + ], + ])('reports gateway acquisition failure as unavailable without logging its cause', async (error, status) => { + attemptService.getSignedOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue( + Buffer.from('%PDF-stored-signed-copy') + ); + trustGatewayClient.verify.mockRejectedValue(error); + + await expect(service.verifyMemoSignature('attempt-1', actor)).resolves.toBe( + 'UNAVAILABLE' + ); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + { + message: 'Memo signature verification unavailable', + attemptId: 'attempt-1', + status, + }, + undefined, + LogContext.MEMOS + ); + }); + + it('fails revoked READ before file or gateway access', async () => { + const denied = new ForbiddenAuthorizationPolicyException( + 'memo read denied', + AuthorizationPrivilege.READ, + memo.authorization.id, + actor.actorID + ); + attemptService.getSignedOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + }); + authorizationService.grantAccessOrFail.mockImplementationOnce(() => { + throw denied; + }); + + await expect(service.verifyMemoSignature('attempt-1', actor)).rejects.toBe( + denied + ); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + expect(trustGatewayClient.verify).not.toHaveBeenCalled(); + }); + + it('does not convert signed-file read failures into unavailable results', async () => { + const readFailure = new Error('file service unavailable'); + attemptService.getSignedOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.SIGNED, + signedDocumentId: 'signed-document-1', + }); + fileServiceAdapter.getDocumentContent.mockRejectedValue(readFailure); + + await expect(service.verifyMemoSignature('attempt-1', actor)).rejects.toBe( + readFailure + ); + expect(trustGatewayClient.verify).not.toHaveBeenCalled(); + }); +}); diff --git a/src/domain/common/memo/memo.signing.service.ts b/src/domain/common/memo/memo.signing.service.ts new file mode 100644 index 0000000000..77edd3ad7c --- /dev/null +++ b/src/domain/common/memo/memo.signing.service.ts @@ -0,0 +1,453 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { LogContext } from '@common/enums/logging.context'; +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { SigningAttempt } from '@domain/common/content-signing/signing.attempt.entity'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; +import { SigningAttemptStatus } from '@domain/common/content-signing/signing.attempt.status'; +import { DocumentService } from '@domain/storage/document/document.service'; +import { DocumentAuthorizationService } from '@domain/storage/document/document.service.authorization'; +import { StorageBucketService } from '@domain/storage/storage-bucket/storage.bucket.service'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { FileServiceAdapter } from '@services/adapters/file-service-adapter/file.service.adapter'; +import { TrustGatewayClient } from '@services/adapters/trust-gateway/trust.gateway.client'; +import { CollaborationDocumentService } from '@services/collaboration-client/collaboration-document.service'; +import { KratosService } from '@services/infrastructure/kratos/kratos.service'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import * as Y from 'yjs'; +import { yjsStateToMarkdown } from './conversion'; +import { IMemo } from './memo.interface'; +import { MemoPdfRenderer } from './memo.pdf.renderer'; +import { MemoService } from './memo.service'; +import { MemoSignatureVerificationStatus } from './memo.signature.verification.status'; + +@Injectable() +export class MemoSigningService { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly memoService: MemoService, + private readonly attemptService: SigningAttemptService, + private readonly kratosService: KratosService, + private readonly collaborationDocumentService: CollaborationDocumentService, + private readonly renderer: MemoPdfRenderer, + private readonly fileServiceAdapter: FileServiceAdapter, + private readonly trustGatewayClient: TrustGatewayClient, + private readonly urlGeneratorService: UrlGeneratorService, + private readonly storageBucketService: StorageBucketService, + private readonly documentAuthorizationService: DocumentAuthorizationService, + private readonly documentService: DocumentService, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + ) {} + + async prepareMemoSigning(memoId: string, actor: ActorContext) { + const memo = await this.getAuthorizedMemo(memoId, actor); + await this.requireCleverbaseSubject(actor); + const storageBucketId = this.requireBucket(memo).id; + + const attempt = await this.attemptService.createUnready( + memoId, + actor.actorID + ); + const markdown = await this.collaborationDocumentService.read( + memoId, + 'memo', + actor.actorID, + doc => yjsStateToMarkdown(Buffer.from(Y.encodeStateAsUpdateV2(doc))) + ); + const pdf = await this.renderer.render(markdown, storageBucketId, actor); + const snapshot = + await this.fileServiceAdapter.createInternalDocumentInBucket( + pdf, + storageBucketId, + 'memo-signing-preview.pdf', + 'application/pdf', + { skipDedup: true } + ); + try { + if ( + !(await this.attemptService.finalizePrepared( + attempt.id, + snapshot.id, + createHash('sha256').update(pdf).digest('hex') + )) + ) + throw new ValidationException( + 'The memo was deleted while preparing the signing copy', + LogContext.MEMOS + ); + } catch (error) { + await this.fileServiceAdapter + .deleteDocument(snapshot.id) + .catch(() => this.logCleanupFailure(attempt.id, snapshot.id)); + throw error; + } + return { + attemptId: attempt.id, + previewUrl: this.urlGeneratorService.getMemoSigningSnapshotRestUrl( + attempt.id + ), + }; + } + + async getSnapshot(attemptId: string, actor: ActorContext): Promise { + const attempt = await this.attemptService + .getForActorOrFail(attemptId, actor.actorID) + .catch(error => { + if (error instanceof ValidationException) + throw new ForbiddenException( + 'Signing preview belongs to another actor', + LogContext.MEMOS + ); + throw error; + }); + await this.getAuthorizedMemo(attempt.memoId, actor); + if (!attempt.snapshotDocumentId) + throw new ValidationException( + 'Signing preview is not ready', + LogContext.MEMOS + ); + return this.fileServiceAdapter.getDocumentContent( + attempt.snapshotDocumentId + ); + } + + async continueMemoSigning( + attemptId: string, + actor: ActorContext + ): Promise<{ authorizeUrl: string }> { + const attempt = await this.attemptService.getForActorOrFail( + attemptId, + actor.actorID + ); + if (!attempt.snapshotDocumentId || !attempt.contentSha256) + throw new ValidationException( + 'Signing preview is not ready', + LogContext.MEMOS + ); + if ( + attempt.status !== SigningAttemptStatus.PENDING || + attempt.createdDate.getTime() <= + Date.now() - SigningAttemptService.PREPARATION_WINDOW_MS + ) + throw new ValidationException( + 'Signing preview has expired', + LogContext.MEMOS + ); + await this.getAuthorizedMemo(attempt.memoId, actor); + const subject = await this.requireCleverbaseSubject(actor); + const snapshot = await this.fileServiceAdapter.getDocumentContent( + attempt.snapshotDocumentId + ); + if ( + createHash('sha256').update(snapshot).digest('hex') !== + attempt.contentSha256 + ) + throw new ValidationException( + 'The signing preview bytes have changed', + LogContext.MEMOS + ); + const clientState = randomBytes(32).toString('base64url'); + const clientStateHash = createHash('sha256') + .update(clientState) + .digest('hex'); + if (!(await this.attemptService.claimStart(attemptId, clientStateHash))) + throw this.freshAttemptRequired(); + let stage: 'gateway-start' | 'gateway-start-persistence' = 'gateway-start'; + try { + const start = await this.trustGatewayClient.start( + snapshot, + subject, + clientState + ); + stage = 'gateway-start-persistence'; + if ( + !(await this.attemptService.recordGatewayStart( + attemptId, + clientStateHash, + start.correlationId, + start.expiresAt + )) + ) + throw this.freshAttemptRequired(); + return { authorizeUrl: start.redirectUrl }; + } catch (error) { + const status = (error as { response?: { status?: unknown } })?.response + ?.status; + this.logger.error?.( + { + message: 'Memo signing start failed after the attempt was claimed', + attemptId, + stage, + status: typeof status === 'number' ? status : undefined, + }, + undefined, + LogContext.MEMOS + ); + throw this.freshAttemptRequired(); + } + } + + async completeMemoSigning( + correlationId: string, + clientState: string, + actor: ActorContext + ) { + const attempt = await this.attemptService.getForReturnOrFail( + correlationId, + actor.actorID, + createHash('sha256').update(clientState).digest('hex') + ); + const memo = await this.getAuthorizedMemo(attempt.memoId, actor); + const memoUrl = await this.urlGeneratorService.getMemoUrlPath( + memo.id, + memo.nameID + ); + const outcome = (status: SigningAttemptStatus) => ({ + memoUrl, + attemptId: attempt.id, + status, + }); + const finish = async ( + status: Exclude, + signedDocumentId?: string, + evidence?: Record + ) => + outcome(await this.finish(attempt, status, signedDocumentId, evidence)); + if (attempt.status !== SigningAttemptStatus.PENDING) + return outcome(attempt.status); + + let gatewayStatus; + let result; + try { + gatewayStatus = attempt.correlationId + ? await this.trustGatewayClient.getStatus(correlationId) + : undefined; + if (gatewayStatus?.status === 'completed') + result = await this.trustGatewayClient.getResult(correlationId); + } catch (error) { + if (error instanceof ValidationException) { + this.logger.error?.( + { + message: 'Memo signing gateway response was malformed', + attemptId: attempt.id, + status: error.code, + }, + undefined, + LogContext.MEMOS + ); + } + throw this.returnPending(); + } + const terminal = this.terminalFor(gatewayStatus); + if (terminal) return finish(terminal); + if (result === null) throw this.returnPending(); + if (!result) return finish(SigningAttemptStatus.EXPIRED); + const bucket = this.requireBucket(memo); + const signed = + await this.storageBucketService.uploadFileAsDocumentFromBuffer( + bucket.id, + result.pdf, + 'memo-signed.pdf', + 'application/pdf', + // DocumentAuthorizationService adds USER_SELF_MANAGEMENT for createdBy; + // attribution stays on attempt.actorId without granting document privileges. + undefined, + false, + true + ); + try { + await this.documentAuthorizationService.applyAuthorizationPolicy( + signed, + bucket.authorization + ); + return await finish( + SigningAttemptStatus.SIGNED, + signed.id, + result.evidence + ); + } catch (error) { + await this.deleteSignedDocument(attempt.id, signed.id); + throw error; + } + } + + private async getAuthorizedMemo( + memoId: string, + actor: ActorContext, + privilege = AuthorizationPrivilege.CONTRIBUTE, + action = 'sign memo' + ): Promise { + const memo = await this.memoService.getMemoOrFail(memoId, { + relations: { authorization: true, profile: { storageBucket: true } }, + }); + this.authorizationService.grantAccessOrFail( + actor, + memo.authorization, + privilege, + action + ); + return memo; + } + + private freshAttemptRequired(): ValidationException { + return new ValidationException( + 'Signing was already started; prepare a fresh signing attempt', + LogContext.MEMOS + ); + } + + private async finish( + attempt: SigningAttempt, + status: Exclude, + signedDocumentId?: string, + evidence?: Record + ): Promise { + const saved = signedDocumentId + ? await this.attemptService.finish( + attempt.id, + status, + signedDocumentId, + evidence + ) + : await this.attemptService.finish(attempt.id, status); + if (saved) { + await this.deleteSnapshot(attempt.id, attempt.snapshotDocumentId); + return status; + } + if (signedDocumentId) + await this.deleteSignedDocument(attempt.id, signedDocumentId); + try { + return ( + await this.attemptService.getForActorOrFail(attempt.id, attempt.actorId) + ).status; + } catch (error) { + if (error instanceof ValidationException) + return SigningAttemptStatus.EXPIRED; + throw error; + } + } + + private async deleteSnapshot( + attemptId: string, + documentId?: string | null + ): Promise { + if (documentId) + await this.fileServiceAdapter + .deleteDocument(documentId) + .catch(() => this.logCleanupFailure(attemptId, documentId)); + } + + private async deleteSignedDocument( + attemptId: string, + documentId: string + ): Promise { + await this.documentService + .deleteDocument({ ID: documentId }) + .catch(() => this.logCleanupFailure(attemptId, documentId)); + } + + async releaseExpiredAttemptFiles(attempt: SigningAttempt): Promise { + await this.deleteSnapshot(attempt.id, attempt.snapshotDocumentId); + } + + async verifyMemoSignature(attemptId: string, actor: ActorContext) { + const attempt = await this.attemptService.getSignedOrFail(attemptId); + await this.getAuthorizedMemo( + attempt.memoId, + actor, + AuthorizationPrivilege.READ, + 'verify memo signature' + ); + const pdf = await this.fileServiceAdapter.getDocumentContent( + attempt.signedDocumentId + ); + let verification; + try { + verification = await this.trustGatewayClient.verify(pdf); + } catch (error) { + const status = (error as { response?: { status?: unknown } }).response + ?.status; + this.logger.error?.( + { + message: 'Memo signature verification unavailable', + attemptId, + status: typeof status === 'number' ? status : undefined, + }, + undefined, + LogContext.MEMOS + ); + return MemoSignatureVerificationStatus.UNAVAILABLE; + } + if (!verification.integrity) + this.logger.error?.( + { + message: 'Memo signature integrity verification failed', + attemptId, + reasons: verification.reasons, + }, + undefined, + LogContext.MEMOS + ); + return verification.integrity + ? MemoSignatureVerificationStatus.VERIFIED + : MemoSignatureVerificationStatus.INVALID; + } + + private logCleanupFailure(attemptId: string, documentId: string): void { + this.logger.error?.( + { + message: 'Memo signing document cleanup failed', + attemptId, + documentId, + }, + undefined, + LogContext.MEMOS + ); + } + + private requireBucket(memo: IMemo) { + const bucket = memo.profile?.storageBucket; + if (bucket) return bucket; + throw new ValidationException( + 'Memo storage is unavailable', + LogContext.MEMOS + ); + } + + private terminalFor(gateway?: { + status: string; + reason?: string; + }): Exclude | undefined { + if (!gateway) return SigningAttemptStatus.EXPIRED; + if (gateway.status === 'completed') return undefined; + if (gateway.status === 'declined') return SigningAttemptStatus.CANCELLED; + if (gateway.status === 'failed') + return ['authorization_expired', 'session_expired'].includes( + gateway.reason ?? '' + ) + ? SigningAttemptStatus.EXPIRED + : SigningAttemptStatus.FAILED; + throw this.returnPending(); + } + + private returnPending(): ValidationException { + return new ValidationException( + 'Signing is still in progress; retry this page', + LogContext.MEMOS + ); + } + + private async requireCleverbaseSubject(actor: ActorContext): Promise { + const subject = await (actor.authenticationID + ? this.kratosService.getCleverbaseSubject(actor.authenticationID) + : undefined); + if (subject) return subject; + throw new ValidationException( + 'Link a Cleverbase identity before signing this memo', + LogContext.MEMOS + ); + } +} diff --git a/src/domain/common/memo/memo.signing.sweep.service.spec.ts b/src/domain/common/memo/memo.signing.sweep.service.spec.ts new file mode 100644 index 0000000000..9792b18b0f --- /dev/null +++ b/src/domain/common/memo/memo.signing.sweep.service.spec.ts @@ -0,0 +1,56 @@ +import { MemoSigningSweepService } from './memo.signing.sweep.service'; + +describe('MemoSigningSweepService', () => { + const expired = { + id: 'attempt-1', + snapshotDocumentId: 'snapshot-1', + }; + const attemptService = { + findExpired: vi.fn(), + expire: vi.fn(), + }; + const memoSigningService = { releaseExpiredAttemptFiles: vi.fn() }; + const service = new MemoSigningSweepService( + attemptService as any, + memoSigningService as any + ); + + beforeEach(() => { + vi.clearAllMocks(); + attemptService.findExpired.mockResolvedValue([expired]); + attemptService.expire.mockResolvedValue(true); + memoSigningService.releaseExpiredAttemptFiles.mockResolvedValue(undefined); + }); + + it('expires one bounded batch and releases only a winning snapshot', async () => { + await service.sweep(); + + expect(attemptService.findExpired).toHaveBeenCalledWith(25); + expect(attemptService.expire).toHaveBeenCalledWith(expired); + expect(memoSigningService.releaseExpiredAttemptFiles).toHaveBeenCalledWith( + expired + ); + + attemptService.expire.mockResolvedValue(false); + await service.sweep(); + expect(memoSigningService.releaseExpiredAttemptFiles).toHaveBeenCalledTimes( + 1 + ); + }); + + it('continues the batch when an expired attempt release fails', async () => { + attemptService.findExpired.mockResolvedValue([ + expired, + { id: 'attempt-2', snapshotDocumentId: 'snapshot-2' }, + ]); + attemptService.expire.mockResolvedValue(true); + memoSigningService.releaseExpiredAttemptFiles.mockRejectedValueOnce( + new Error('cleanup failed') + ); + await service.sweep(); + + expect(memoSigningService.releaseExpiredAttemptFiles).toHaveBeenCalledTimes( + 2 + ); + }); +}); diff --git a/src/domain/common/memo/memo.signing.sweep.service.ts b/src/domain/common/memo/memo.signing.sweep.service.ts new file mode 100644 index 0000000000..69a2a685d8 --- /dev/null +++ b/src/domain/common/memo/memo.signing.sweep.service.ts @@ -0,0 +1,26 @@ +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { MemoSigningService } from './memo.signing.service'; + +const SWEEP_BATCH_SIZE = 25; + +@Injectable() +export class MemoSigningSweepService { + constructor( + private readonly attemptService: SigningAttemptService, + private readonly memoSigningService: MemoSigningService + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async sweep(): Promise { + for (const attempt of await this.attemptService.findExpired( + SWEEP_BATCH_SIZE + )) { + if (!(await this.attemptService.expire(attempt))) continue; + await this.memoSigningService + .releaseExpiredAttemptFiles(attempt) + .catch(() => undefined); + } + } +} diff --git a/src/domain/storage/storage-bucket/storage.bucket.module.ts b/src/domain/storage/storage-bucket/storage.bucket.module.ts index b0d5842d1f..6c90e38cc6 100644 --- a/src/domain/storage/storage-bucket/storage.bucket.module.ts +++ b/src/domain/storage/storage-bucket/storage.bucket.module.ts @@ -1,5 +1,6 @@ import { AuthorizationModule } from '@core/authorization/authorization.module'; import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; +import { ContentSigningModule } from '@domain/common/content-signing/content.signing.module'; import { Profile } from '@domain/common/profile/profile.entity'; import { TagsetModule } from '@domain/common/tagset/tagset.module'; import { Module } from '@nestjs/common'; @@ -22,6 +23,7 @@ import { StorageBucketAuthorizationService } from './storage.bucket.service.auth FileServiceAdapterModule, AuthorizationModule, AuthorizationPolicyModule, + ContentSigningModule, TagsetModule, UrlGeneratorModule, TypeOrmModule.forFeature([StorageBucket]), diff --git a/src/domain/storage/storage-bucket/storage.bucket.service.spec.ts b/src/domain/storage/storage-bucket/storage.bucket.service.spec.ts index 53d82654e3..4400fd79cb 100644 --- a/src/domain/storage/storage-bucket/storage.bucket.service.spec.ts +++ b/src/domain/storage/storage-bucket/storage.bucket.service.spec.ts @@ -11,6 +11,7 @@ import { EntityNotFoundException } from '@common/exceptions/entity.not.found.exc import { ActorContext } from '@core/actor-context/actor.context'; import { AuthorizationService } from '@core/authorization/authorization.service'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; import { Profile } from '@domain/common/profile/profile.entity'; import { TagsetService } from '@domain/common/tagset/tagset.service'; import { DocumentAuthorizationService } from '@domain/storage/document/document.service.authorization'; @@ -93,6 +94,7 @@ describe('StorageBucketService', () => { let fileServiceAdapter: FileServiceAdapter; let tagsetService: TagsetService; let configService: ConfigService; + let signingAttemptService: SigningAttemptService; beforeEach(async () => { vi.restoreAllMocks(); @@ -156,6 +158,10 @@ describe('StorageBucketService', () => { fileServiceAdapter = module.get(FileServiceAdapter); tagsetService = module.get(TagsetService); configService = module.get(ConfigService); + signingAttemptService = module.get(SigningAttemptService); + (signingAttemptService.existsForDocumentIDs as Mock).mockResolvedValue( + false + ); }); // ── createStorageBucket ───────────────────────────────────────── @@ -205,6 +211,29 @@ describe('StorageBucketService', () => { // ── deleteStorageBucket ───────────────────────────────────────── describe('deleteStorageBucket', () => { + it('refuses before deleting authorization or documents when a signing attempt references a document', async () => { + const bucket = { + id: 'bucket-signing', + authorization: { id: 'auth-signing' }, + documents: [{ id: 'doc-signing' }], + }; + (storageBucketRepository.findOneOrFail as Mock).mockResolvedValue(bucket); + (signingAttemptService.existsForDocumentIDs as Mock).mockResolvedValue( + true + ); + + await expect( + service.deleteStorageBucket('bucket-signing') + ).rejects.toThrow(ValidationException); + + expect(signingAttemptService.existsForDocumentIDs).toHaveBeenCalledWith([ + 'doc-signing', + ]); + expect(authorizationPolicyService.delete).not.toHaveBeenCalled(); + expect(documentService.deleteDocument).not.toHaveBeenCalled(); + expect(storageBucketRepository.remove).not.toHaveBeenCalled(); + }); + it('should delete authorization, all documents, and remove bucket when bucket exists', async () => { const doc1 = { id: 'doc-1' }; const doc2 = { id: 'doc-2' }; @@ -276,6 +305,30 @@ describe('StorageBucketService', () => { // ── deleteStorageBucketForAccountDeletion ──────────────────────── describe('deleteStorageBucketForAccountDeletion', () => { + it('refuses before transactional authorization cleanup when a signing attempt references a document', async () => { + const bucket = { + id: 'bucket-signing', + authorization: { id: 'auth-signing' }, + documents: [{ id: 'doc-signing' }], + }; + const em = { + findOneOrFail: vi.fn().mockResolvedValue(bucket), + } as any; + (signingAttemptService.existsForDocumentIDs as Mock).mockResolvedValue( + true + ); + + await expect( + service.deleteStorageBucketForAccountDeletion('bucket-signing', em) + ).rejects.toThrow(ValidationException); + + expect(signingAttemptService.existsForDocumentIDs).toHaveBeenCalledWith([ + 'doc-signing', + ]); + expect(authorizationPolicyService.delete).not.toHaveBeenCalled(); + expect(documentService.deleteDocumentDbOnly).not.toHaveBeenCalled(); + }); + it('joins the passed EntityManager, never calls the file-service delete, collects external ids, and never removes the bucket or file rows', async () => { const doc1 = { id: 'doc-1' }; const doc2 = { id: 'doc-2' }; diff --git a/src/domain/storage/storage-bucket/storage.bucket.service.ts b/src/domain/storage/storage-bucket/storage.bucket.service.ts index 107742e99f..20615ca088 100644 --- a/src/domain/storage/storage-bucket/storage.bucket.service.ts +++ b/src/domain/storage/storage-bucket/storage.bucket.service.ts @@ -19,6 +19,7 @@ import { AuthorizationService } from '@core/authorization/authorization.service' import { AuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.entity'; import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; import { Profile } from '@domain/common/profile/profile.entity'; import { TagsetService } from '@domain/common/tagset/tagset.service'; import { DocumentAuthorizationService } from '@domain/storage/document/document.service.authorization'; @@ -68,7 +69,8 @@ export class StorageBucketService { private profileRepository: Repository, private configService: ConfigService, private fileServiceAdapter: FileServiceAdapter, - private tagsetService: TagsetService + private tagsetService: TagsetService, + private signingAttemptService: SigningAttemptService ) {} public createStorageBucket( @@ -122,6 +124,8 @@ export class StorageBucketService { em ); + await this.assertDocumentsAreNotSigning(storage.documents); + if (storage.authorization) { await this.authorizationPolicyService.delete(storage.authorization, em); } @@ -163,6 +167,8 @@ export class StorageBucketService { relations: { documents: true }, }); + await this.assertDocumentsAreNotSigning(storage.documents); + if (storage.authorization) await this.authorizationPolicyService.delete(storage.authorization); @@ -181,6 +187,18 @@ export class StorageBucketService { return result; } + private async assertDocumentsAreNotSigning( + documents?: Pick[] + ): Promise { + const documentIDs = documents?.map(document => document.id) ?? []; + if (await this.signingAttemptService.existsForDocumentIDs(documentIDs)) { + throw new ValidationException( + 'Storage contains a document retained by a signing attempt', + LogContext.STORAGE_BUCKET + ); + } + } + public async save( storage: IStorageBucket, mgr?: EntityManager diff --git a/src/migrations/1788609600000-CreateSigningAttempt.ts b/src/migrations/1788609600000-CreateSigningAttempt.ts new file mode 100644 index 0000000000..8cc599e733 --- /dev/null +++ b/src/migrations/1788609600000-CreateSigningAttempt.ts @@ -0,0 +1,47 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSigningAttempt1788609600000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE "signing_attempt_status_enum" AS ENUM ('pending', 'signed', 'cancelled', 'failed', 'expired'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$`); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "signing_attempt" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "createdDate" timestamptz NOT NULL DEFAULT now(), + "updatedDate" timestamptz NOT NULL DEFAULT now(), + "version" integer NOT NULL DEFAULT 1, + "memoId" uuid NOT NULL, + "actorId" uuid NOT NULL, + "contentSha256" varchar(64), + "snapshotDocumentId" uuid, + "correlationId" text, + "expiresAt" timestamptz, + "clientStateHash" varchar(64), + "status" "signing_attempt_status_enum" NOT NULL DEFAULT 'pending', + "signedDocumentId" uuid, + "signerEvidence" jsonb, + CONSTRAINT "PK_signing_attempt" PRIMARY KEY ("id"), + CONSTRAINT "FK_signing_attempt_memoId" FOREIGN KEY ("memoId") REFERENCES "memo"("id") ON DELETE CASCADE, + CONSTRAINT "FK_signing_attempt_snapshotDocumentId" FOREIGN KEY ("snapshotDocumentId") REFERENCES "file"("id") ON DELETE RESTRICT, + CONSTRAINT "FK_signing_attempt_signedDocumentId" FOREIGN KEY ("signedDocumentId") REFERENCES "file"("id") ON DELETE RESTRICT + ) + `); + for (const statement of [ + `CREATE INDEX IF NOT EXISTS "IDX_signing_attempt_memo_status" ON "signing_attempt" ("memoId", "status")`, + `CREATE INDEX IF NOT EXISTS "IDX_signing_attempt_status_expiresAt" ON "signing_attempt" ("status", "expiresAt")`, + `CREATE INDEX IF NOT EXISTS "IDX_signing_attempt_status_createdDate" ON "signing_attempt" ("status", "createdDate")`, + `CREATE INDEX IF NOT EXISTS "IDX_signing_attempt_snapshotDocumentId" ON "signing_attempt" ("snapshotDocumentId")`, + `CREATE INDEX IF NOT EXISTS "IDX_signing_attempt_signedDocumentId" ON "signing_attempt" ("signedDocumentId")`, + `CREATE UNIQUE INDEX IF NOT EXISTS "UQ_signing_attempt_correlationId" ON "signing_attempt" ("correlationId")`, + `CREATE UNIQUE INDEX IF NOT EXISTS "UQ_signing_attempt_clientStateHash" ON "signing_attempt" ("clientStateHash")`, + ]) + await queryRunner.query(statement); + } + public async down(queryRunner: QueryRunner): Promise { + // Destructive rollback deletes attempt records, not referenced file rows. + await queryRunner.query(`DROP TABLE IF EXISTS "signing_attempt"`); + await queryRunner.query(`DROP TYPE IF EXISTS "signing_attempt_status_enum"`); + } +} diff --git a/src/services/adapters/file-service-adapter/file.service.adapter.spec.ts b/src/services/adapters/file-service-adapter/file.service.adapter.spec.ts index 3a69ad47c6..3acf4aabb0 100644 --- a/src/services/adapters/file-service-adapter/file.service.adapter.spec.ts +++ b/src/services/adapters/file-service-adapter/file.service.adapter.spec.ts @@ -337,6 +337,41 @@ describe('FileServiceAdapter', () => { }); }); + describe('createInternalDocumentInBucket', () => { + it('uploads the caller-chosen PDF metadata without user-facing policy fields', async () => { + (httpService.request as Mock).mockReturnValue( + of( + axiosResponse({ + id: 'pdf-1', + externalID: 'hash-pdf', + mimeType: 'application/pdf', + size: 12, + }) + ) + ); + + await adapter.createInternalDocumentInBucket( + Buffer.from('%PDF-content'), + 'bucket-1', + 'memo-signing-preview.pdf', + 'application/pdf', + { skipDedup: true } + ); + + const callArgs = (httpService.request as Mock).mock.calls[0][0]; + const serialized = callArgs.data.getBuffer().toString('utf8'); + expect(serialized).toContain('filename="memo-signing-preview.pdf"'); + expect(serialized).toMatch( + /name="displayName"\r\n\r\nmemo-signing-preview\.pdf/ + ); + expect(serialized).toContain('Content-Type: application/pdf'); + expect(serialized).not.toContain('name="authorizationId"'); + expect(serialized).not.toContain('name="tagsetId"'); + expect(serialized).not.toContain('name="externalID"'); + expect(serialized).toMatch(/name="skipDedup"\r\n\r\ntrue/); + }); + }); + describe('getContentBatch', () => { it('POSTs { ids } to /internal/file/content-batch and returns items in order', async () => { const items = [ diff --git a/src/services/adapters/file-service-adapter/file.service.adapter.ts b/src/services/adapters/file-service-adapter/file.service.adapter.ts index 8fc26f45e2..2e284357ad 100644 --- a/src/services/adapters/file-service-adapter/file.service.adapter.ts +++ b/src/services/adapters/file-service-adapter/file.service.adapter.ts @@ -143,19 +143,34 @@ export class FileServiceAdapter extends HttpClientBase { snapshot: Buffer, storageBucketId: string ): Promise { - this.checkEnabledAndCircuit('createSnapshotInBucket'); + return this.createInternalDocumentInBucket( + snapshot, + storageBucketId, + SNAPSHOT_DISPLAY_NAME, + 'application/octet-stream', + { filename: SNAPSHOT_FILENAME } + ); + } + async createInternalDocumentInBucket( + file: Buffer, + storageBucketId: string, + displayName: string, + mimeType: string, + options: { filename?: string; skipDedup?: boolean } = {} + ): Promise { + this.checkEnabledAndCircuit('createInternalDocumentInBucket'); const form = new FormData(); - form.append('file', snapshot, { - filename: SNAPSHOT_FILENAME, - contentType: 'application/octet-stream', + form.append('file', file, { + filename: options.filename ?? displayName, + contentType: mimeType, }); - form.append('displayName', SNAPSHOT_DISPLAY_NAME); + form.append('displayName', displayName); form.append('storageBucketId', storageBucketId); - // authorizationId intentionally omitted — see method doc (NULL authz). + if (options.skipDedup) form.append('skipDedup', 'true'); return this.sendRequest( - 'createSnapshotInBucket', + 'createInternalDocumentInBucket', 'post', FILE_PATH_PREFIX, form, diff --git a/src/services/adapters/trust-gateway/trust.gateway.client.spec.ts b/src/services/adapters/trust-gateway/trust.gateway.client.spec.ts new file mode 100644 index 0000000000..02346e6b67 --- /dev/null +++ b/src/services/adapters/trust-gateway/trust.gateway.client.spec.ts @@ -0,0 +1,304 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { HttpService } from '@nestjs/axios'; +import axios from 'axios'; +import { TrustGatewayClient } from './trust.gateway.client'; + +const readJson = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return JSON.parse(Buffer.concat(chunks).toString()); +}; + +describe('TrustGatewayClient', () => { + type FixtureResponse = { + status?: number; + headers?: Record; + body?: unknown; + }; + const responses: FixtureResponse[] = []; + const requests: { request: IncomingMessage; body: unknown }[] = []; + const server = createServer(async (request, response) => { + const body = + request.method === 'POST' ? await readJson(request) : undefined; + requests.push({ request, body }); + const fixture = responses.shift() ?? {}; + response.statusCode = fixture.status ?? 200; + for (const [name, value] of Object.entries(fixture.headers ?? {})) + response.setHeader(name, value); + if (Buffer.isBuffer(fixture.body)) response.end(fixture.body); + else { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(fixture.body)); + } + }); + let client: TrustGatewayClient; + + beforeAll(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + client = new TrustGatewayClient( + { get: () => ({ url }), getOrThrow: () => url } as any, + new HttpService(axios.create()) + ); + }); + + beforeEach(() => { + responses.length = 0; + requests.length = 0; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())) + ); + }); + + it('posts exact PDF bytes, B-T, raw state and provider subject without authorization', async () => { + responses.push({ + body: { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + expiresAt: '2026-09-05T16:30:00Z', + }, + }); + + await expect( + client.start(Buffer.from('%PDF-exact'), 'PNONL-123', 'raw-client-state') + ).resolves.toEqual({ + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + expiresAt: new Date('2026-09-05T16:30:00Z'), + }); + + expect(requests).toHaveLength(1); + expect(requests[0].request.method).toBe('POST'); + expect(requests[0].request.url).toBe('/v1/sign/start'); + expect(requests[0].request.headers.authorization).toBeUndefined(); + expect(requests[0].body).toEqual({ + document: Buffer.from('%PDF-exact').toString('base64'), + conformanceLevel: 'B-T', + expectedSigner: { + matchOn: 'cleverbase_subject', + value: 'PNONL-123', + }, + clientState: 'raw-client-state', + }); + }); + + it.each([ + {}, + { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + }, + { + redirectUrl: 'javascript:alert(1)', + correlationId: 'correlation-1', + expiresAt: '2026-09-05T16:30:00Z', + }, + { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: '', + expiresAt: '2026-09-05T16:30:00Z', + }, + { + redirectUrl: 'not a URL', + correlationId: 'correlation-1', + expiresAt: '2026-09-05T16:30:00Z', + }, + { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + expiresAt: 'not-a-date', + }, + { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-1', + expiresAt: '2026-13-05T16:30:00Z', + }, + ])('rejects malformed start response %# without exposing it', async response => { + responses.push({ body: response }); + + await expect( + client.start(Buffer.from('%PDF'), 'PNONL-123', 'raw-state') + ).rejects.toThrow(/invalid gateway response/i); + }); + + it.each([ + [{ status: 'pending' }, { status: 'pending' }], + [ + { status: 'failed', reason: 'authorization_expired' }, + { status: 'failed', reason: 'authorization_expired' }, + ], + [ + { status: 'failed', reason: 'future_failure_reason' }, + { status: 'failed', reason: 'future_failure_reason' }, + ], + [{ status: 'future_nonterminal' }, { status: 'future_nonterminal' }], + ])('reads gateway status %# without authorization', async (body, expected) => { + responses.push({ body }); + + await expect(client.getStatus('correlation-1')).resolves.toEqual(expected); + expect(requests[0].request.method).toBe('GET'); + expect(requests[0].request.url).toBe( + '/v1/sign/status?correlationId=correlation-1' + ); + expect(requests[0].request.headers.authorization).toBeUndefined(); + }); + + it('maps an evicted gateway correlation to no status', async () => { + responses.push({ status: 404, body: { error: 'not_found' } }); + + await expect(client.getStatus('evicted')).resolves.toBeUndefined(); + }); + + it.each([ + null, + [], + {}, + { status: 1 }, + { status: 'failed', reason: 1 }, + ])('rejects malformed status response %#', async body => { + responses.push({ body }); + + await expect(client.getStatus('correlation-1')).rejects.toThrow( + /invalid gateway response/i + ); + }); + + it('propagates a status transport failure', async () => { + responses.push({ status: 500, body: { error: 'unavailable' } }); + + await expect(client.getStatus('correlation-1')).rejects.toThrow(); + }); + + it('returns exact completed PDF bytes and decoded JSON evidence', async () => { + const pdf = Buffer.from('%PDF-signed'); + const evidence = { signer: { serial_number: 'ABC', common_name: 'Jane' } }; + responses.push({ + headers: { + 'Content-Type': 'application/pdf', + 'X-Signature-Evidence': Buffer.from(JSON.stringify(evidence)).toString( + 'base64' + ), + }, + body: pdf, + }); + + await expect(client.getResult('correlation-1')).resolves.toEqual({ + pdf, + evidence, + }); + expect(requests[0].request.url).toBe( + '/v1/sign/result?correlationId=correlation-1' + ); + expect(requests[0].request.headers.authorization).toBeUndefined(); + }); + + it.each([ + [409, null], + [404, undefined], + ])('distinguishes result HTTP %s', async (status, expected) => { + responses.push({ status, body: { error: 'not_available' } }); + + await expect(client.getResult('correlation-1')).resolves.toBe(expected); + }); + + it('propagates a result transport failure', async () => { + responses.push({ status: 500, body: { error: 'unavailable' } }); + + await expect(client.getResult('correlation-1')).rejects.toThrow(); + }); + + it.each([ + [Buffer.from('not-pdf'), Buffer.from('{}').toString('base64')], + [Buffer.from('%PDF-signed'), 'not-base64-json'], + [Buffer.from('%PDF-signed'), Buffer.from('[]').toString('base64')], + ])('rejects malformed completed result %#', async (body, evidence) => { + responses.push({ + headers: { + 'Content-Type': 'application/pdf', + 'X-Signature-Evidence': evidence, + }, + body, + }); + + await expect(client.getResult('correlation-1')).rejects.toThrow( + /invalid gateway response/i + ); + }); + + it('does not retry a failed gateway start request', async () => { + responses.push({ status: 500, body: { error: 'begin_failed' } }); + + await expect( + client.start(Buffer.from('%PDF'), 'PNONL-123', 'raw-state') + ).rejects.toThrow(); + expect(requests).toHaveLength(1); + expect(requests[0].request.method).toBe('POST'); + }); + + it('posts exact signed PDF bytes for integrity verification and returns only consumed fields', async () => { + responses.push({ + body: { + integrity: true, + profile: 'B-T', + signer: { serial: 'private-serial', cn: 'Private Name' }, + reasons: [], + }, + }); + + await expect(client.verify(Buffer.from('%PDF-signed'))).resolves.toEqual({ + integrity: true, + reasons: [], + }); + expect(requests).toHaveLength(1); + expect(requests[0].request.method).toBe('POST'); + expect(requests[0].request.url).toBe('/v1/verify'); + expect(requests[0].request.headers.authorization).toBeUndefined(); + expect(requests[0].body).toEqual({ + document: Buffer.from('%PDF-signed').toString('base64'), + }); + }); + + it.each([ + null, + [], + {}, + { integrity: 'true', reasons: [] }, + { integrity: false, reasons: 'message_digest_mismatch' }, + { integrity: false, reasons: [1] }, + ])('rejects malformed verification response %#', async body => { + responses.push({ body }); + + await expect(client.verify(Buffer.from('%PDF-signed'))).rejects.toThrow( + /invalid gateway response/i + ); + }); + + it('does not retry a failed verification request', async () => { + responses.push({ status: 500, body: { error: 'unavailable' } }); + + await expect(client.verify(Buffer.from('%PDF-signed'))).rejects.toThrow(); + expect(requests).toHaveLength(1); + }); + + it('requires the gateway URL when constructed', () => { + const missing = new Error('Missing trustGateway.url'); + + expect( + () => + new TrustGatewayClient( + { + get: () => ({ url: 'http://fallback.invalid' }), + getOrThrow: () => { + throw missing; + }, + } as any, + new HttpService(axios.create()) + ) + ).toThrow(missing); + }); +}); diff --git a/src/services/adapters/trust-gateway/trust.gateway.client.ts b/src/services/adapters/trust-gateway/trust.gateway.client.ts new file mode 100644 index 0000000000..ab2628bfa3 --- /dev/null +++ b/src/services/adapters/trust-gateway/trust.gateway.client.ts @@ -0,0 +1,165 @@ +import { LogContext } from '@common/enums'; +import { ValidationException } from '@common/exceptions'; +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AlkemioConfig } from '@src/types'; +import type { AxiosResponse } from 'axios'; +import { firstValueFrom } from 'rxjs'; + +type StartResponse = { + redirectUrl?: unknown; + correlationId?: unknown; + expiresAt?: unknown; +}; + +type GatewayStatus = { status: string; reason?: string }; +type VerifyResponse = { integrity?: unknown; reasons?: unknown }; + +const RFC3339 = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const REQUEST_TIMEOUT_MS = 30_000; + +@Injectable() +export class TrustGatewayClient { + private readonly baseUrl: string; + + constructor( + configService: ConfigService, + private readonly httpService: HttpService + ) { + this.baseUrl = configService + .getOrThrow('trustGateway.url', { infer: true }) + .replace(/\/$/, ''); + } + + async start(document: Buffer, subject: string, clientState: string) { + const response = await firstValueFrom( + this.httpService.post( + `${this.baseUrl}/v1/sign/start`, + { + document: document.toString('base64'), + conformanceLevel: 'B-T', + expectedSigner: { + matchOn: 'cleverbase_subject', + value: subject, + }, + clientState, + }, + { timeout: REQUEST_TIMEOUT_MS } + ) + ); + const { redirectUrl, correlationId, expiresAt } = response.data; + if ( + typeof redirectUrl !== 'string' || + typeof correlationId !== 'string' || + !correlationId || + typeof expiresAt !== 'string' || + !RFC3339.test(expiresAt) + ) + throw this.invalidResponse(); + try { + const url = new URL(redirectUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw this.invalidResponse(); + } catch { + throw this.invalidResponse(); + } + // Gateway formats a real time.Time with time.RFC3339; Date parsing guards transport corruption. + const expiry = new Date(expiresAt); + if (Number.isNaN(expiry.getTime())) throw this.invalidResponse(); + return { redirectUrl, correlationId, expiresAt: expiry }; + } + + async getStatus(correlationId: string): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}/v1/sign/status`, { + params: { correlationId }, + timeout: REQUEST_TIMEOUT_MS, + }) + ); + const data = response.data; + if (!data || Array.isArray(data) || typeof data !== 'object') + throw this.invalidResponse(); + const { status, reason } = data as Record; + if ( + typeof status !== 'string' || + (reason !== undefined && typeof reason !== 'string') + ) + throw this.invalidResponse(); + return data as GatewayStatus; + } catch (error) { + if ( + (error as { response?: { status?: number } }).response?.status === 404 + ) + return undefined; + throw error; + } + } + + async getResult(correlationId: string) { + let response: AxiosResponse; + try { + response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}/v1/sign/result`, { + params: { correlationId }, + responseType: 'arraybuffer', + timeout: REQUEST_TIMEOUT_MS, + }) + ); + } catch (error) { + const status = (error as { response?: { status?: number } }).response + ?.status; + if (status === 409) return null; + if (status === 404) return undefined; + throw error; + } + const pdf = Buffer.from(response.data); + const encodedEvidence = response.headers['x-signature-evidence']; + if ( + !pdf.subarray(0, 5).equals(Buffer.from('%PDF-')) || + typeof encodedEvidence !== 'string' + ) + throw this.invalidResponse(); + let evidence: unknown; + try { + evidence = JSON.parse( + Buffer.from(encodedEvidence, 'base64').toString('utf8') + ); + } catch { + throw this.invalidResponse(); + } + if (!evidence || Array.isArray(evidence) || typeof evidence !== 'object') + throw this.invalidResponse(); + return { pdf, evidence: evidence as Record }; + } + + async verify(document: Buffer) { + const response = await firstValueFrom( + this.httpService.post( + `${this.baseUrl}/v1/verify`, + { document: document.toString('base64') }, + { timeout: REQUEST_TIMEOUT_MS } + ) + ); + const data = response.data; + if (!data || Array.isArray(data) || typeof data !== 'object') + throw this.invalidResponse(); + const { integrity, reasons } = data; + if ( + typeof integrity !== 'boolean' || + !Array.isArray(reasons) || + !reasons.every(reason => typeof reason === 'string') + ) + throw this.invalidResponse(); + return { integrity, reasons }; + } + + private invalidResponse(): ValidationException { + return new ValidationException( + 'Invalid gateway response', + LogContext.MEMOS + ); + } +} diff --git a/src/services/api-rest/content-signing/content.signing.controller.spec.ts b/src/services/api-rest/content-signing/content.signing.controller.spec.ts new file mode 100644 index 0000000000..088c745263 --- /dev/null +++ b/src/services/api-rest/content-signing/content.signing.controller.spec.ts @@ -0,0 +1,228 @@ +import { LogContext } from '@common/enums'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { RestEndpoint } from '@common/enums/rest.endpoint'; +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { ForbiddenAuthorizationPolicyException } from '@common/exceptions/forbidden.authorization.policy.exception'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { RestGuard } from '@core/authorization/rest.guard'; +import { + EXCEPTION_FILTERS_METADATA, + GUARDS_METADATA, + PATH_METADATA, +} from '@nestjs/common/constants'; +import { ContentSigningController } from './content.signing.controller'; +import { ContentSigningReturnFilter } from './content.signing.return.filter'; + +describe('ContentSigningController', () => { + // trust-gateway 4f0691a produces an opaque hex correlation ID, not a UUID. + const correlationId = '0123456789abcdef0123456789abcdef'; + + it('streams the actor-bound preview as a private inline PDF', async () => { + const pdf = Buffer.from('%PDF-preview'); + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const signingService = { getSnapshot: vi.fn().mockResolvedValue(pdf) }; + const response = { set: vi.fn(), send: vi.fn(), sendStatus: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await controller.getSnapshot('attempt-1', actor, response as any); + + expect(signingService.getSnapshot).toHaveBeenCalledWith('attempt-1', actor); + expect(response.set).toHaveBeenCalledWith({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'inline; filename="memo-signing-preview.pdf"', + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }); + expect(response.send).toHaveBeenCalledWith(pdf); + }); + + it('returns 401 for an anonymous request without calling the service', async () => { + const signingService = { getSnapshot: vi.fn() }; + const response = { sendStatus: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await controller.getSnapshot( + 'attempt-1', + undefined as unknown as ActorContext, + response as any + ); + + expect(response.sendStatus).toHaveBeenCalledWith(401); + expect(signingService.getSnapshot).not.toHaveBeenCalled(); + }); + + it.each([ + [new ForbiddenException('denied', LogContext.MEMOS), 403], + [ + new ForbiddenAuthorizationPolicyException( + 'memo access denied', + AuthorizationPrivilege.CONTRIBUTE, + 'memo-auth', + 'actor-1' + ), + 403, + ], + [new ValidationException('not ready', LogContext.MEMOS), 409], + ])('maps an authenticated domain failure to HTTP %s', async (error, status) => { + const signingService = { getSnapshot: vi.fn().mockRejectedValue(error) }; + const response = { sendStatus: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + + await controller.getSnapshot('attempt-1', actor, response as any); + + expect(response.sendStatus).toHaveBeenCalledWith(status); + }); + + it('does not hide an unexpected service failure', async () => { + const failure = new Error('unexpected'); + const signingService = { + getSnapshot: vi.fn().mockRejectedValue(failure), + }; + const controller = new ContentSigningController(signingService as any); + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + + await expect( + controller.getSnapshot('attempt-1', actor, {} as any) + ).rejects.toBe(failure); + }); + + it('uses the shared private REST route and guard', () => { + expect(RestEndpoint.CONTENT_SIGNING_SNAPSHOT).toBe(':attemptId/snapshot'); + expect( + Reflect.getMetadata( + PATH_METADATA, + ContentSigningController.prototype.getSnapshot + ) + ).toBe(RestEndpoint.CONTENT_SIGNING_SNAPSHOT); + expect( + Reflect.getMetadata( + GUARDS_METADATA, + ContentSigningController.prototype.getSnapshot + ) ?? [] + ).toEqual([RestGuard]); + }); + + it('completes a browser return with no-store/no-referrer and only the attempt ID', async () => { + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const signingService = { + completeMemoSigning: vi.fn().mockResolvedValue({ + memoUrl: '/space/demo/callout/memo', + attemptId: 'attempt-1', + status: 'signed', + }), + }; + const response = { set: vi.fn(), redirect: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await controller.complete( + correlationId, + 'raw-client-state', + actor, + response as any + ); + + expect(signingService.completeMemoSigning).toHaveBeenCalledWith( + correlationId, + 'raw-client-state', + actor + ); + expect(response.set).toHaveBeenCalledWith({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }); + expect(response.redirect).toHaveBeenCalledWith( + 302, + '/space/demo/callout/memo?signingAttemptId=attempt-1' + ); + }); + + it.each([ + [new ForbiddenException('wrong return', LogContext.MEMOS), 403], + [ + new ForbiddenAuthorizationPolicyException( + 'memo access denied', + AuthorizationPrivilege.CONTRIBUTE, + 'memo-auth', + 'actor-1' + ), + 403, + ], + [new ValidationException('still pending', LogContext.MEMOS), 409], + ])('maps an authenticated return failure to HTTP %s', async (error, status) => { + const signingService = { + completeMemoSigning: vi.fn().mockRejectedValue(error), + }; + const response = { set: vi.fn(), sendStatus: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await controller.complete( + correlationId, + 'state', + Object.assign(new ActorContext(), { actorID: 'actor-1' }), + response as any + ); + + expect(response.sendStatus).toHaveBeenCalledWith(status); + }); + + it('routes an absent actor through the signing login-restoration filter', async () => { + const signingService = { completeMemoSigning: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await expect( + controller.complete( + correlationId, + 'state', + undefined as unknown as ActorContext, + {} as any + ) + ).rejects.toThrow(/signing return requires/i); + expect(signingService.completeMemoSigning).not.toHaveBeenCalled(); + }); + + it('declares the public completion route with its guard and filter', () => { + expect(RestEndpoint.CONTENT_SIGNING_COMPLETE).toBe('complete'); + expect( + Reflect.getMetadata( + PATH_METADATA, + ContentSigningController.prototype.complete + ) + ).toBe(RestEndpoint.CONTENT_SIGNING_COMPLETE); + expect( + Reflect.getMetadata( + GUARDS_METADATA, + ContentSigningController.prototype.complete + ) ?? [] + ).toEqual([RestGuard]); + expect( + Reflect.getMetadata( + EXCEPTION_FILTERS_METADATA, + ContentSigningController.prototype.complete + ) ?? [] + ).toEqual([ContentSigningReturnFilter]); + }); + + it.each([ + ['missing correlation ID', undefined, 'state'], + ['duplicated correlation ID', [correlationId, correlationId], 'state'], + ['empty correlation ID', '', 'state'], + ['missing client state', correlationId, undefined], + ['duplicated client state', correlationId, ['state', 'state']], + ['empty client state', correlationId, ''], + ])('rejects %s before calling the signing service', async (_, correlation, state) => { + const signingService = { completeMemoSigning: vi.fn() }; + const response = { set: vi.fn(), sendStatus: vi.fn() }; + const controller = new ContentSigningController(signingService as any); + + await controller.complete( + correlation as unknown as string, + state as unknown as string, + Object.assign(new ActorContext(), { actorID: 'actor-1' }), + response as any + ); + + expect(response.sendStatus).toHaveBeenCalledWith(409); + expect(signingService.completeMemoSigning).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/api-rest/content-signing/content.signing.controller.ts b/src/services/api-rest/content-signing/content.signing.controller.ts new file mode 100644 index 0000000000..e72b1a6831 --- /dev/null +++ b/src/services/api-rest/content-signing/content.signing.controller.ts @@ -0,0 +1,100 @@ +import { CurrentActor } from '@common/decorators'; +import { LogContext } from '@common/enums'; +import { RestEndpoint } from '@common/enums/rest.endpoint'; +import { ForbiddenException, ValidationException } from '@common/exceptions'; +import { UnauthenticatedHttpException } from '@common/exceptions/http'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { RestGuard } from '@core/authorization/rest.guard'; +import { MemoSigningService } from '@domain/common/memo/memo.signing.service'; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ContentSigningReturnFilter } from './content.signing.return.filter'; + +@Controller('rest/content-signing') +export class ContentSigningController { + constructor(private readonly memoSigningService: MemoSigningService) {} + + @Get(RestEndpoint.CONTENT_SIGNING_SNAPSHOT) + @UseGuards(RestGuard) + async getSnapshot( + @Param('attemptId', ParseUUIDPipe) attemptId: string, + @CurrentActor() actor: ActorContext, + @Res() response: Response + ): Promise { + if (!actor?.actorID) return response.sendStatus(401); + try { + const pdf = await this.memoSigningService.getSnapshot(attemptId, actor); + response.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'inline; filename="memo-signing-preview.pdf"', + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }); + return response.send(pdf); + } catch (error) { + return this.sendKnownError(error, response); + } + } + + @Get(RestEndpoint.CONTENT_SIGNING_COMPLETE) + @UseGuards(RestGuard) + @UseFilters(ContentSigningReturnFilter) + async complete( + @Query('correlationId') correlationId: unknown, + @Query('clientState') clientState: unknown, + @CurrentActor() actor: ActorContext, + @Res() response: Response + ): Promise { + if (!actor?.actorID) + throw new UnauthenticatedHttpException( + 'Memo signing return requires an authenticated session', + LogContext.AUTH + ); + response.set({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }); + try { + if ( + !this.isSingleNonEmptyString(correlationId) || + !this.isSingleNonEmptyString(clientState) + ) + throw new ValidationException( + 'Signing return parameters are invalid', + LogContext.MEMOS + ); + const result = await this.memoSigningService.completeMemoSigning( + correlationId, + clientState, + actor + ); + return response.redirect( + 302, + `${result.memoUrl}?signingAttemptId=${encodeURIComponent(result.attemptId)}` + ); + } catch (error) { + return this.sendKnownError(error, response); + } + } + + private isSingleNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; + } + + private sendKnownError(error: unknown, response: Response): Response { + const code = (error as { code?: string }).code; + if (error instanceof ForbiddenException || code === 'FORBIDDEN_POLICY') + return response.sendStatus(403); + if (error instanceof ValidationException) return response.sendStatus(409); + throw error; + } +} diff --git a/src/services/api-rest/content-signing/content.signing.module.ts b/src/services/api-rest/content-signing/content.signing.module.ts new file mode 100644 index 0000000000..25a1ec5331 --- /dev/null +++ b/src/services/api-rest/content-signing/content.signing.module.ts @@ -0,0 +1,11 @@ +import { MemoModule } from '@domain/common/memo/memo.module'; +import { Module } from '@nestjs/common'; +import { ContentSigningController } from './content.signing.controller'; +import { ContentSigningReturnFilter } from './content.signing.return.filter'; + +@Module({ + imports: [MemoModule], + controllers: [ContentSigningController], + providers: [ContentSigningReturnFilter], +}) +export class ContentSigningRestModule {} diff --git a/src/services/api-rest/content-signing/content.signing.return.filter.spec.ts b/src/services/api-rest/content-signing/content.signing.return.filter.spec.ts new file mode 100644 index 0000000000..6da5a397ed --- /dev/null +++ b/src/services/api-rest/content-signing/content.signing.return.filter.spec.ts @@ -0,0 +1,77 @@ +import { LogContext } from '@common/enums'; +import { UnauthenticatedHttpException } from '@common/exceptions/http'; +import { ContentSigningReturnFilter } from './content.signing.return.filter'; + +describe('ContentSigningReturnFilter', () => { + it('restores login to the exact local return URL without caching or a referrer', () => { + const response = { set: vi.fn(), redirect: vi.fn(), headersSent: false }; + const request = { + originalUrl: + '/rest/content-signing/complete?correlationId=corr&clientState=opaque', + }; + const host = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + }; + + new ContentSigningReturnFilter().catch( + new UnauthenticatedHttpException('expired', LogContext.AUTH), + host as any + ); + + expect(response.set).toHaveBeenCalledWith({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }); + expect(response.redirect).toHaveBeenCalledWith( + 302, + `/login?returnUrl=${encodeURIComponent(`/api/public${request.originalUrl}`)}` + ); + }); + + it.each([ + [ + { url: '/rest/content-signing/complete' }, + '/api/public/rest/content-signing/complete', + ], + [{}, '/api/public/'], + ])('uses the available stripped request path %#', (request, returnUrl) => { + const response = { set: vi.fn(), redirect: vi.fn(), headersSent: false }; + const host = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + }; + + new ContentSigningReturnFilter().catch( + new UnauthenticatedHttpException('expired', LogContext.AUTH), + host as any + ); + + expect(response.redirect).toHaveBeenCalledWith( + 302, + `/login?returnUrl=${encodeURIComponent(returnUrl)}` + ); + }); + + it('does not write a second response after headers were sent', () => { + const response = { set: vi.fn(), redirect: vi.fn(), headersSent: true }; + const host = { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: vi.fn(), + }), + }; + + new ContentSigningReturnFilter().catch( + new UnauthenticatedHttpException('expired', LogContext.AUTH), + host as any + ); + + expect(response.set).not.toHaveBeenCalled(); + expect(response.redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/api-rest/content-signing/content.signing.return.filter.ts b/src/services/api-rest/content-signing/content.signing.return.filter.ts new file mode 100644 index 0000000000..ff8e89b411 --- /dev/null +++ b/src/services/api-rest/content-signing/content.signing.return.filter.ts @@ -0,0 +1,19 @@ +import { UnauthenticatedHttpException } from '@common/exceptions/http'; +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch(UnauthenticatedHttpException) +export class ContentSigningReturnFilter implements ExceptionFilter { + catch(_: UnauthenticatedHttpException, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const response = http.getResponse(); + if (response.headersSent) return; + const request = http.getRequest(); + const returnUrl = `/api/public${request.originalUrl ?? request.url ?? '/'}`; + response.set({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }); + response.redirect(302, `/login?returnUrl=${encodeURIComponent(returnUrl)}`); + } +} diff --git a/src/services/infrastructure/kratos/kratos.service.spec.ts b/src/services/infrastructure/kratos/kratos.service.spec.ts index a8161afabf..78f22629ac 100644 --- a/src/services/infrastructure/kratos/kratos.service.spec.ts +++ b/src/services/infrastructure/kratos/kratos.service.spec.ts @@ -508,6 +508,65 @@ describe('KratosService', () => { }); }); + describe('getCleverbaseSubject', () => { + it('reads OIDC credentials and returns only the provider subject', async () => { + const getIdentity = vi + .spyOn(service, 'getIdentityById') + .mockResolvedValue({ + credentials: { + oidc: { + identifiers: [ + 'linkedin:elsewhere', + 'cleverbase:subject-from-provider', + ], + }, + }, + traits: { serialNumber: 'NOT-THE-SUBJECT' }, + } as any); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBe('subject-from-provider'); + expect(getIdentity).toHaveBeenCalledWith('kratos-identity', ['oidc']); + }); + + it.each([ + ['missing credentials', {}], + ['missing OIDC credentials', { credentials: { password: {} } }], + [ + 'another provider', + { credentials: { oidc: { identifiers: ['github:subject'] } } }, + ], + [ + 'an empty provider subject', + { credentials: { oidc: { identifiers: ['cleverbase:'] } } }, + ], + ])('returns undefined for %s', async (_label, identity) => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue(identity as any); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBeUndefined(); + }); + + it('returns undefined when the Kratos identity no longer exists', async () => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue(undefined); + + await expect( + service.getCleverbaseSubject('missing-identity') + ).resolves.toBeUndefined(); + }); + + it('propagates Kratos failures other than a missing identity', async () => { + const unavailable = new Error('Kratos unavailable'); + vi.spyOn(service, 'getIdentityById').mockRejectedValue(unavailable); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).rejects.toBe(unavailable); + }); + }); + describe('getAuthenticatedAt', () => { it('should return undefined when sessions is null', async () => { vi.spyOn( diff --git a/src/services/infrastructure/kratos/kratos.service.ts b/src/services/infrastructure/kratos/kratos.service.ts index cca4d26f86..b84abc1088 100644 --- a/src/services/infrastructure/kratos/kratos.service.ts +++ b/src/services/infrastructure/kratos/kratos.service.ts @@ -11,6 +11,7 @@ import { ConfigService } from '@nestjs/config'; import { Configuration, FrontendApi, + type GetIdentityIncludeCredentialEnum, Identity, IdentityApi, } from '@ory/kratos-client'; @@ -384,11 +385,13 @@ export class KratosService { } public async getIdentityById( - identityId: string + identityId: string, + includeCredential?: GetIdentityIncludeCredentialEnum[] ): Promise { try { const { data: identity } = await this.kratosIdentityClient.getIdentity({ id: identityId, + includeCredential, }); return identity; } catch (error) { @@ -410,6 +413,17 @@ export class KratosService { } } + public async getCleverbaseSubject( + identityId: string + ): Promise { + const identity = await this.getIdentityById(identityId, ['oidc']); + const prefix = `${AuthenticationType.CLEVERBASE}:`; + const identifier = identity?.credentials?.oidc?.identifiers?.find(value => + value.startsWith(prefix) + ); + return identifier?.slice(prefix.length) || undefined; + } + /** * Deletes an identity by email. * diff --git a/src/services/infrastructure/url-generator/url.generator.service.spec.ts b/src/services/infrastructure/url-generator/url.generator.service.spec.ts index 470331e123..19f9642297 100644 --- a/src/services/infrastructure/url-generator/url.generator.service.spec.ts +++ b/src/services/infrastructure/url-generator/url.generator.service.spec.ts @@ -586,6 +586,20 @@ describe('UrlGeneratorService', () => { }); }); + describe('getMemoSigningSnapshotRestUrl', () => { + it('should generate the absolute private snapshot URL', () => { + (service as any).configService = { + get: vi.fn().mockReturnValue({ + path_api_private_rest: '/api/private/rest', + }), + }; + + expect(service.getMemoSigningSnapshotRestUrl('attempt-123')).toBe( + `${ENDPOINT}/api/private/rest/content-signing/attempt-123/snapshot` + ); + }); + }); + describe('createSpaceAdminCommunityURL', () => { it('should generate the admin community URL for a space', async () => { cacheService.getUrlFromCache.mockResolvedValue(undefined); diff --git a/src/services/infrastructure/url-generator/url.generator.service.ts b/src/services/infrastructure/url-generator/url.generator.service.ts index 3b63c445f7..d4d1cef639 100644 --- a/src/services/infrastructure/url-generator/url.generator.service.ts +++ b/src/services/infrastructure/url-generator/url.generator.service.ts @@ -1247,6 +1247,13 @@ export class UrlGeneratorService { return `${this.endpoint_cluster}${path_api_private_rest}/calendar/event/${calendarEventID}/ics`; } + public getMemoSigningSnapshotRestUrl(attemptID: string): string { + const { path_api_private_rest } = this.configService.get('hosting', { + infer: true, + }); + return `${this.endpoint_cluster}${path_api_private_rest}/content-signing/${attemptID}/snapshot`; + } + public async createSpaceAdminCommunityURL(id: string): Promise { const spaceAdminUrl = await this.getSpaceUrlPathByID( id, diff --git a/src/types/alkemio.config.ts b/src/types/alkemio.config.ts index df93bb2f3f..b8f837fffa 100644 --- a/src/types/alkemio.config.ts +++ b/src/types/alkemio.config.ts @@ -26,6 +26,9 @@ export type MessagingDigestTrackConfig = { }; export type AlkemioConfig = { + trustGateway: { + url: string; + }; authorization: { chunk: number; }; diff --git a/test/integration/content-signing/compose.yml b/test/integration/content-signing/compose.yml new file mode 100644 index 0000000000..eb96b1b58c --- /dev/null +++ b/test/integration/content-signing/compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres@sha256:aadf2c0696f5ef357aa7a68da995137f0cf17bad0bf6e1f17de06ae5c769b302 + environment: + POSTGRES_DB: content_signing + POSTGRES_USER: content_signing + POSTGRES_PASSWORD: content_signing + ports: + - "127.0.0.1:${CONTENT_SIGNING_DB_PORT:-55426}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U content_signing -d content_signing"] + interval: 1s + timeout: 3s + retries: 30 + + file-service: + image: ${CONTENT_SIGNING_FILE_SERVICE_IMAGE:?build the accepted file-service source and set CONTENT_SIGNING_FILE_SERVICE_IMAGE} + depends_on: + postgres: + condition: service_healthy + environment: + AUTH_SERVICE_URL: http://127.0.0.1:9 + ALKEMIO_DATABASE_HOST: postgres + ALKEMIO_DATABASE_PORT: 5432 + ALKEMIO_DATABASE_USERNAME: content_signing + ALKEMIO_DATABASE_PASSWORD: content_signing + ALKEMIO_DATABASE_NAME: content_signing + PORT: 4003 + STORAGE_TYPE: local + LOCAL_STORAGE_PATH: /storage + FILE_BACKUP_OUTBOX_ENABLED: "false" + ports: + - "127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT:-44003}:4003" + volumes: + - content_signing_storage:/storage + +volumes: + content_signing_storage: diff --git a/test/integration/content-signing/quickstart.config.spec.ts b/test/integration/content-signing/quickstart.config.spec.ts new file mode 100644 index 0000000000..89eb9f3a1b --- /dev/null +++ b/test/integration/content-signing/quickstart.config.spec.ts @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator/url.generator.service'; +import { vi } from 'vitest'; +import { parse } from 'yaml'; + +const root = process.cwd(); + +const readYaml = (path: string) => + parse(readFileSync(resolve(root, path), 'utf8')) as Record; + +describe('content-signing local quickstart', () => { + const compose = readYaml('quickstart-services.yml'); + const traefik = readYaml('.build/traefik/http.yml'); + const config = readYaml('alkemio.yml'); + + it('pins the released gateway and mock with loopback-only host access', () => { + const gateway = compose.services['trust-gateway']; + const mock = compose.services['cleverbase-refmock']; + + expect(gateway.image).toBe( + 'alkemio/trust-gateway@sha256:68035416db3fdb89aeaf2cc2f9e93a8906415cca0093edaa5dacf5d910382a39' + ); + expect(gateway.ports).toEqual(['127.0.0.1:8080:8080']); + expect(mock.image).toBe( + 'ghcr.io/alkem-io/cleverbase-refmock@sha256:271f70ee82e8114c0fc03f45788512d5d8f54a9a4fb3c3d7b33057781233fee2' + ); + expect(mock.ports).toEqual(['127.0.0.1:9000:9000']); + expect(config.trustGateway.url).toBe( + '${TRUST_GATEWAY_URL}:http://localhost:8080' + ); + }); + + it('reuses the outbound-capable quickstart network', () => { + const gateway = compose.services['trust-gateway']; + + expect(compose.networks.alkemio_dev_net ?? {}).not.toHaveProperty( + 'internal', + true + ); + expect(compose.services.traefik.networks).toContain('alkemio_dev_net'); + expect(gateway.networks).toEqual(['alkemio_dev_net']); + expect(compose.services['cleverbase-refmock'].networks).toEqual([ + 'alkemio_dev_net', + ]); + }); + + it('uses the credential-free B-T fixture contract without an API key', () => { + const environment: string[] = compose.services['trust-gateway'].environment; + + expect(environment).toEqual([ + 'TRUST_GATEWAY_MODE=fixtures', + 'TRUST_GATEWAY_ENV=acceptance', + 'TRUST_GATEWAY_CSC_API=v1_rsa', + 'TRUST_GATEWAY_CLIENT_ID=trust-gateway-fixtures', + 'TRUST_GATEWAY_CLIENT_SECRET=fixtures', + 'TRUST_GATEWAY_REDIRECT_URI=http://localhost:3000/oauth/cleverbase/callback', + 'TRUST_GATEWAY_RETURN_URL=http://localhost:3000/api/public/rest/content-signing/complete', + 'TRUST_GATEWAY_AUTH_DISABLED=true', + 'TRUST_GATEWAY_DEFAULT_CONFORMANCE=B-B', + 'TRUST_GATEWAY_SESSION_TTL=15m', + 'TRUST_GATEWAY_LISTEN=:8080', + 'TRUST_GATEWAY_BASE_URL=http://cleverbase-refmock:9000', + 'TRUST_GATEWAY_PUBLIC_BASE_URL=http://localhost:9000', + 'TRUST_GATEWAY_TSA_URL=http://cleverbase-refmock:9000/tsr', + ]); + expect(environment).toContain('TRUST_GATEWAY_LISTEN=:8080'); + expect(environment.some(value => value.includes('API_KEY'))).toBe(false); + }); + + it('publishes only the exact GET callback through Traefik', () => { + const service = traefik.http.services['trust-gateway']; + const callback = traefik.http.routers['trust-gateway-callback']; + + expect(service.loadBalancer.servers).toEqual([ + { url: 'http://trust-gateway:8080/' }, + ]); + expect(callback).toEqual({ + rule: 'Method(`GET`) && Path(`/oauth/cleverbase/callback`)', + service: 'trust-gateway', + entryPoints: ['web'], + priority: 200, + }); + expect( + Object.values(traefik.http.routers).filter( + (router: any) => router.service === 'trust-gateway' + ) + ).toEqual([callback]); + const publicRules = Object.values(traefik.http.routers) + .map((router: any) => router.rule) + .join(' '); + expect(publicRules).not.toContain('/v1/sign'); + expect(publicRules).not.toContain('/v1/verify'); + }); + + it('routes the generated signing preview to the server private REST endpoint', () => { + const urlGenerator = new UrlGeneratorService( + { + get: vi.fn((key: string) => + key === 'hosting.endpoint_cluster' + ? 'http://localhost:3000' + : { path_api_private_rest: '/api/private/rest' } + ), + } as any, + {} as any, + {} as any, + {} as any + ); + const previewPath = new URL( + urlGenerator.getMemoSigningSnapshotRestUrl('attempt-1') + ).pathname; + + expect(previewPath).toBe( + '/api/private/rest/content-signing/attempt-1/snapshot' + ); + expect(traefik.http.routers['content-signing-snapshot']).toEqual({ + rule: 'Method(`GET`) && PathPrefix(`/api/private/rest/content-signing/`)', + service: 'alkemio-server', + middlewares: ['strip-api-private-prefix'], + entryPoints: ['web'], + priority: 150, + }); + }); +}); diff --git a/test/integration/content-signing/renderer.performance.spec.ts b/test/integration/content-signing/renderer.performance.spec.ts new file mode 100644 index 0000000000..06c408fa34 --- /dev/null +++ b/test/integration/content-signing/renderer.performance.spec.ts @@ -0,0 +1,119 @@ +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { MemoPdfRenderer } from '@domain/common/memo/memo.pdf.renderer'; +import sharp from 'sharp'; + +const describeRealServices = + process.env.CONTENT_SIGNING_REAL_SERVICES === 'true' + ? describe + : describe.skip; + +const imageUrl = (index: number) => + `https://alkem.io/api/private/rest/storage/document/11111111-1111-4111-8111-${index.toString().padStart(12, '0')}`; +const realisticSources = [ + 'docs/images/alkemio-server-design.png', + 'docs/images/login-session-extend-flow.png', + 'docs/images/alkemio-services-networking.png', + 'docs/images/pagination-efficiency.png', + 'docs/images/templates-domain.png', +]; + +const fitAsciiBytes = (prefix: string, bytes: number): string => { + const paragraph = + 'Representative signed memo paragraph with **bold**, *emphasis*, and a [link](https://example.com).\n\n'; + return `${prefix}${paragraph.repeat(Math.ceil(bytes / paragraph.length))}`.slice( + 0, + bytes + ); +}; + +const structuredMarkdown = (imageCount: number, bytes: number) => { + const images = Array.from( + { length: imageCount }, + (_, index) => `![bounded image ${index}](${imageUrl(index)})` + ).join('\n\n'); + return fitAsciiBytes( + [ + '# Bounded signing preview', + '', + '- first list item', + '- second list item', + '', + '| Column A | Column B |', + '| --- | --- |', + '| value A | value B |', + '', + images, + '', + ].join('\n'), + bytes + ); +}; + +const noisyJpeg = () => + sharp(randomBytes(1200 * 1200 * 3), { + raw: { width: 1200, height: 1200, channels: 3 }, + }) + .jpeg({ quality: 65 }) + .toBuffer(); + +const createRenderer = (sources: Buffer[]) => { + const documents = new Map( + sources.map((source, index) => [imageUrl(index), { index, source }]) + ); + return new MemoPdfRenderer( + { + isAlkemioDocumentURL: (url: string) => documents.has(url), + getDocumentFromURL: async (url: string) => ({ + id: url, + authorization: { id: `auth-${url}` }, + storageBucket: { id: 'bucket-1' }, + }), + } as any, + { grantAccessOrFail: () => undefined } as any, + { + getDocumentContent: async (url: string) => documents.get(url)!.source, + } as any + ); +}; + +const renderAndAssert = async ( + label: string, + sources: Buffer[], + markdownBytes: number +) => { + const renderer = createRenderer(sources); + const actor = Object.assign(new ActorContext(), { actorID: 'actor-1' }); + const markdown = structuredMarkdown(sources.length, markdownBytes); + const started = performance.now(); + const pdf = await renderer.render(markdown, 'bucket-1', actor); + const elapsed = performance.now() - started; + const pdfText = pdf.toString('latin1'); + const imageObjects = pdfText.match(/\/Subtype \/Image/g)?.length ?? 0; + const dctImages = pdfText.match(/\/DCTDecode/g)?.length ?? 0; + + process.stdout.write( + `memo-render-ci case=${label} samples=1 markdown=${Buffer.byteLength(markdown)} images=${sources.length} sourceBytes=${sources.reduce((sum, source) => sum + source.length, 0)} pdf=${pdf.length} imageObjects=${imageObjects} dct=${dctImages} ms=${elapsed.toFixed(1)} maxRssMiB=${(process.resourceUsage().maxRSS / 1024).toFixed(1)}\n` + ); + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + expect(imageObjects).toBe(sources.length); + expect(dctImages).toBe(sources.length); + expect(elapsed).toBeLessThan(10_000); +}; + +describeRealServices('MemoPdfRenderer bounded fixture performance', () => { + it('renders a representative structured memo with five repository images', async () => { + const sources = await Promise.all( + realisticSources.map(path => readFile(resolve(process.cwd(), path))) + ); + await renderAndAssert('representative', sources, 50_000); + }, 30_000); + + it('renders the maximum text and image-count fixture inside the target', async () => { + const sources: Buffer[] = []; + for (let index = 0; index < 20; index++) sources.push(await noisyJpeg()); + await renderAndAssert('maximum', sources, 100_000); + }, 60_000); +}); diff --git a/test/integration/content-signing/signing-attempt.postgres.spec.ts b/test/integration/content-signing/signing-attempt.postgres.spec.ts new file mode 100644 index 0000000000..544fe37a53 --- /dev/null +++ b/test/integration/content-signing/signing-attempt.postgres.spec.ts @@ -0,0 +1,1600 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { ValidationException } from '@common/exceptions'; +import { ActorContext } from '@core/actor-context/actor.context'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { SigningAttempt } from '@domain/common/content-signing/signing.attempt.entity'; +import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; +import { SigningAttemptStatus } from '@domain/common/content-signing/signing.attempt.status'; +import { markdownSchema } from '@domain/common/memo/conversion/markdown.schema'; +import { yjsStateToMarkdown } from '@domain/common/memo/conversion/yjs.state.to.markdown'; +import { MemoPdfRenderer } from '@domain/common/memo/memo.pdf.renderer'; +import { MemoService } from '@domain/common/memo/memo.service'; +import { MemoSigningService } from '@domain/common/memo/memo.signing.service'; +import { MemoSigningSweepService } from '@domain/common/memo/memo.signing.sweep.service'; +import { ProfileService } from '@domain/common/profile/profile.service'; +import { DocumentService } from '@domain/storage/document/document.service'; +import { StorageBucketService } from '@domain/storage/storage-bucket/storage.bucket.service'; +import { HttpService } from '@nestjs/axios'; +import { LoggerService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FileServiceAdapter } from '@services/adapters/file-service-adapter/file.service.adapter'; +import { DocumentPurgingError } from '@services/collaboration-client/collaboration-document.session'; +import { CreateSigningAttempt1788609600000 } from '@src/migrations/1788609600000-CreateSigningAttempt'; +import { prosemirrorJSONToYDoc } from '@tiptap/y-tiptap'; +import sharp from 'sharp'; +import { DataSource, EntitySchema, EntitySchemaOptions } from 'typeorm'; +import { vi } from 'vitest'; +import * as Y from 'yjs'; + +const describeRealServices = + process.env.CONTENT_SIGNING_REAL_SERVICES === 'true' + ? describe + : describe.skip; + +const UUIDS = { + memo: '11111111-1111-4111-8111-111111111111', + actor: '22222222-2222-4222-8222-222222222222', + snapshot: '33333333-3333-4333-8333-333333333333', + snapshot2: '33333333-3333-4333-8333-333333333334', + bucket: '44444444-4444-4444-8444-444444444444', + profile: '55555555-5555-4555-8555-555555555555', + authorization: '66666666-6666-4666-8666-666666666666', +}; + +const fixtureSchema = ( + name: string, + tableName: string, + columns: EntitySchemaOptions>['columns'] +) => + new EntitySchema>({ + name, + tableName, + columns, + }); + +const idColumn = { type: 'uuid' as const, primary: true }; +const MemoFixture = fixtureSchema('MemoFixture', 'memo', { id: idColumn }); +const ProfileFixture = fixtureSchema('ProfileFixture', 'profile', { + id: idColumn, +}); +const StorageBucketFixture = fixtureSchema( + 'StorageBucketFixture', + 'storage_bucket', + { id: idColumn } +); +const AuthorizationFixture = fixtureSchema( + 'AuthorizationFixture', + 'authorization_policy', + { id: idColumn } +); +const DocumentFixture = fixtureSchema('DocumentFixture', 'file', { + id: idColumn, + externalID: { type: String, length: 128 }, + mimeType: { type: String }, + size: { type: Number }, + displayName: { type: String }, + createdBy: { type: 'uuid', nullable: true }, + temporaryLocation: { type: Boolean }, + storageBucketId: { type: 'uuid' }, + authorizationId: { type: 'uuid', nullable: true }, + tagsetId: { type: 'uuid', nullable: true }, + createdDate: { type: Date }, + updatedDate: { type: Date }, + version: { type: Number }, + content_metadata: { type: 'jsonb' }, +}); + +const logger: LoggerService = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +}; + +const createBarrier = () => { + let release!: () => void; + const waiting = new Promise(resolve => { + release = resolve; + }); + return { waiting, release }; +}; + +describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { + let dataSource: DataSource; + let migration: CreateSigningAttempt1788609600000; + let attemptService: SigningAttemptService; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + host: process.env.CONTENT_SIGNING_DB_HOST, + port: Number(process.env.CONTENT_SIGNING_DB_PORT), + username: process.env.CONTENT_SIGNING_DB_USER, + password: process.env.CONTENT_SIGNING_DB_PASSWORD, + database: process.env.CONTENT_SIGNING_DB_NAME, + entities: [ + SigningAttempt, + MemoFixture, + ProfileFixture, + StorageBucketFixture, + AuthorizationFixture, + DocumentFixture, + ], + }); + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await dataSource.query('DROP TABLE IF EXISTS "signing_attempt" CASCADE'); + await dataSource.query( + 'DROP TYPE IF EXISTS "signing_attempt_status_enum" CASCADE' + ); + await dataSource.query('DROP TABLE IF EXISTS "file" CASCADE'); + await dataSource.query('DROP TABLE IF EXISTS "memo" CASCADE'); + await dataSource.query('DROP TABLE IF EXISTS "profile" CASCADE'); + await dataSource.query('DROP TABLE IF EXISTS "storage_bucket" CASCADE'); + await dataSource.query( + 'DROP TABLE IF EXISTS "authorization_policy" CASCADE' + ); + await dataSource.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + await dataSource.query('CREATE TABLE "memo" (id uuid PRIMARY KEY)'); + await dataSource.query('CREATE TABLE "profile" (id uuid PRIMARY KEY)'); + await dataSource.query( + 'CREATE TABLE "storage_bucket" (id uuid PRIMARY KEY)' + ); + await dataSource.query( + 'CREATE TABLE "authorization_policy" (id uuid PRIMARY KEY)' + ); + await dataSource.query(`CREATE TABLE "file" ( + id uuid PRIMARY KEY, + "externalID" varchar(128) NOT NULL, + "mimeType" varchar(128) NOT NULL, + size integer NOT NULL, + "displayName" varchar(512) NOT NULL, + "createdBy" uuid NULL, + "temporaryLocation" boolean NOT NULL DEFAULT false, + "storageBucketId" uuid NOT NULL, + "authorizationId" uuid NULL, + "tagsetId" uuid NULL, + "createdDate" timestamptz NOT NULL DEFAULT now(), + "updatedDate" timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + content_metadata jsonb NOT NULL DEFAULT '{}'::jsonb + )`); + + migration = new CreateSigningAttempt1788609600000(); + const queryRunner = dataSource.createQueryRunner(); + try { + await migration.up(queryRunner); + } finally { + await queryRunner.release(); + } + attemptService = new SigningAttemptService( + dataSource.getRepository(SigningAttempt) + ); + }); + + it('creates the one table with the exact enum, columns, indexes, unique keys, and FK deletion rules', async () => { + const rerun = dataSource.createQueryRunner(); + try { + await expect(migration.up(rerun)).resolves.toBeUndefined(); + } finally { + await rerun.release(); + } + const columns: Array<{ + column_name: string; + is_nullable: string; + data_type: string; + column_default: string | null; + }> = await dataSource.query(` + SELECT column_name, is_nullable, data_type, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'signing_attempt' + ORDER BY ordinal_position + `); + expect(columns.map(column => column.column_name)).toEqual([ + 'id', + 'createdDate', + 'updatedDate', + 'version', + 'memoId', + 'actorId', + 'contentSha256', + 'snapshotDocumentId', + 'correlationId', + 'expiresAt', + 'clientStateHash', + 'status', + 'signedDocumentId', + 'signerEvidence', + ]); + expect(columns.find(column => column.column_name === 'id')).toMatchObject({ + is_nullable: 'NO', + data_type: 'uuid', + }); + expect( + columns.find(column => column.column_name === 'id')?.column_default + ).toContain('uuid_generate_v4'); + expect( + columns.find(column => column.column_name === 'contentSha256') + ).toMatchObject({ is_nullable: 'YES', data_type: 'character varying' }); + expect( + columns.find(column => column.column_name === 'signerEvidence') + ).toMatchObject({ is_nullable: 'YES', data_type: 'jsonb' }); + + const enumValues: Array<{ enumlabel: string }> = await dataSource.query(` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = 'signing_attempt_status_enum' + ORDER BY enumsortorder + `); + expect(enumValues.map(value => value.enumlabel)).toEqual([ + 'pending', + 'signed', + 'cancelled', + 'failed', + 'expired', + ]); + + const indexes: Array<{ indexname: string }> = await dataSource.query(` + SELECT indexname FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'signing_attempt' + `); + expect(indexes.map(index => index.indexname)).toEqual( + expect.arrayContaining([ + 'IDX_signing_attempt_memo_status', + 'IDX_signing_attempt_status_expiresAt', + 'IDX_signing_attempt_status_createdDate', + 'IDX_signing_attempt_snapshotDocumentId', + 'IDX_signing_attempt_signedDocumentId', + 'UQ_signing_attempt_correlationId', + 'UQ_signing_attempt_clientStateHash', + ]) + ); + + const constraints: Array<{ + column_name: string; + foreign_table: string; + delete_rule: string; + }> = await dataSource.query(` + SELECT kcu.column_name, + ccu.table_name AS foreign_table, + rc.delete_rule + FROM information_schema.referential_constraints rc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = rc.unique_constraint_name + AND ccu.constraint_schema = rc.unique_constraint_schema + WHERE rc.constraint_schema = 'public' + AND kcu.table_name = 'signing_attempt' + ORDER BY kcu.column_name + `); + expect(constraints).toEqual([ + { column_name: 'memoId', foreign_table: 'memo', delete_rule: 'CASCADE' }, + { + column_name: 'signedDocumentId', + foreign_table: 'file', + delete_rule: 'RESTRICT', + }, + { + column_name: 'snapshotDocumentId', + foreign_table: 'file', + delete_rule: 'RESTRICT', + }, + ]); + }); + + it('reverts the table, indexes, and enum cleanly', async () => { + const queryRunner = dataSource.createQueryRunner(); + try { + await migration.down(queryRunner); + await expect(migration.down(queryRunner)).resolves.toBeUndefined(); + } finally { + await queryRunner.release(); + } + + const [{ table_exists: tableExists }] = await dataSource.query( + `SELECT to_regclass('public.signing_attempt') IS NOT NULL AS table_exists` + ); + const [{ type_exists: typeExists }] = await dataSource.query( + `SELECT to_regtype('public.signing_attempt_status_enum') IS NOT NULL AS type_exists` + ); + expect(tableExists).toBe(false); + expect(typeExists).toBe(false); + }); + + it('keeps actor attribution without a user FK and cascades attempts only with their memo', async () => { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + + expect(attempt.actorId).toBe(UUIDS.actor); + await dataSource.query('DELETE FROM memo WHERE id = $1', [UUIDS.memo]); + + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + }); + + it('allows multiple null gateway keys but rejects duplicate correlation and client-state hashes', async () => { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + const first = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + const second = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + const repository = dataSource.getRepository(SigningAttempt); + + await expect(repository.count()).resolves.toBe(2); + await repository.update(first.id, { + correlationId: 'correlation-1', + clientStateHash: 'cd'.repeat(32), + }); + await expect( + repository.update(second.id, { correlationId: 'correlation-1' }) + ).rejects.toThrow(); + await expect( + repository.update(second.id, { clientStateHash: 'cd'.repeat(32) }) + ).rejects.toThrow(); + }); + + it('returns false after release and on double-finalize without recreating or overwriting a row', async () => { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + await dataSource.query('INSERT INTO storage_bucket (id) VALUES ($1)', [ + UUIDS.bucket, + ]); + for (const id of [UUIDS.snapshot, UUIDS.snapshot2]) { + await dataSource.query( + `INSERT INTO "file" ( + id, "externalID", "mimeType", size, "displayName", "temporaryLocation", + "storageBucketId", "createdDate", "updatedDate", version, content_metadata + ) VALUES ($1, $2, 'application/pdf', 1, 'snapshot.pdf', false, $3, now(), now(), 1, '{}')`, + [id, id, UUIDS.bucket] + ); + } + + const released = await attemptService.createUnready( + UUIDS.memo, + UUIDS.actor + ); + await attemptService.deleteForMemo(UUIDS.memo); + await expect( + attemptService.finalizePrepared( + released.id, + UUIDS.snapshot, + 'ab'.repeat(32) + ) + ).resolves.toBe(false); + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + + const finalized = await attemptService.createUnready( + UUIDS.memo, + UUIDS.actor + ); + await expect( + attemptService.finalizePrepared( + finalized.id, + UUIDS.snapshot, + 'ab'.repeat(32) + ) + ).resolves.toBe(true); + await expect( + attemptService.finalizePrepared( + finalized.id, + UUIDS.snapshot2, + 'cd'.repeat(32) + ) + ).resolves.toBe(false); + await expect( + dataSource.getRepository(SigningAttempt).findOneByOrFail({ + id: finalized.id, + }) + ).resolves.toMatchObject({ + snapshotDocumentId: UUIDS.snapshot, + contentSha256: 'ab'.repeat(32), + }); + }); + + it('allows exactly one concurrent start claim and retains its state hash', async () => { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + await dataSource.query('INSERT INTO storage_bucket (id) VALUES ($1)', [ + UUIDS.bucket, + ]); + await dataSource.query( + `INSERT INTO "file" ( + id, "externalID", "mimeType", size, "displayName", "temporaryLocation", + "storageBucketId", "createdDate", "updatedDate", version, content_metadata + ) VALUES ($1, $2, 'application/pdf', 1, 'snapshot.pdf', false, $3, now(), now(), 1, '{}')`, + [UUIDS.snapshot, 'snapshot-external-id', UUIDS.bucket] + ); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + await attemptService.finalizePrepared( + attempt.id, + UUIDS.snapshot, + 'ab'.repeat(32) + ); + const hashes = ['cd'.repeat(32), 'ef'.repeat(32)]; + + const claims = await Promise.all( + hashes.map(hash => attemptService.claimStart(attempt.id, hash)) + ); + + expect(claims.filter(Boolean)).toHaveLength(1); + const stored = await dataSource + .getRepository(SigningAttempt) + .findOneByOrFail({ id: attempt.id }); + expect(stored.clientStateHash).toBe(hashes[claims.indexOf(true)]); + await expect( + attemptService.claimStart(attempt.id, '12'.repeat(32)) + ).resolves.toBe(false); + }); + + it.each([ + 'snapshotDocumentId', + 'signedDocumentId', + ] as const)('makes the actual file-service preserve row and bytes while %s retains them, then allows retry after release', async retainedField => { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + await dataSource.query('INSERT INTO storage_bucket (id) VALUES ($1)', [ + UUIDS.bucket, + ]); + const { fileAdapter } = createActualDeletionServices( + dataSource, + attemptService + ); + const content = Buffer.from(retainedField); + const document = await fileAdapter.createDocument(content, { + displayName: `${retainedField}.txt`, + mimeType: 'text/plain', + storageBucketId: UUIDS.bucket, + authorizationId: '77777777-7777-4777-8777-777777777777', + allowedMimeTypes: 'text/plain', + maxFileSize: 1_024, + skipDedup: true, + }); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + await dataSource.getRepository(SigningAttempt).update(attempt.id, { + [retainedField]: document.id, + status: + retainedField === 'signedDocumentId' + ? SigningAttemptStatus.SIGNED + : SigningAttemptStatus.PENDING, + }); + + await expect(fileAdapter.deleteDocument(document.id)).rejects.toThrow(); + await expect(fileAdapter.getDocumentContent(document.id)).resolves.toEqual( + content + ); + + await attemptService.deleteForMemo(UUIDS.memo); + await expect(fileAdapter.deleteDocument(document.id)).resolves.toEqual({ + authorizationId: '77777777-7777-4777-8777-777777777777', + }); + await expect(fileAdapter.getDocumentContent(document.id)).rejects.toThrow(); + }); + + it('runs the actual account-deletion bucket path: preflight retains DB and bytes, then release and retry complete', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + const content = Buffer.from('account-owned signed copy'); + const document = await services.fileAdapter.createDocument(content, { + displayName: 'account-copy.txt', + mimeType: 'text/plain', + storageBucketId: UUIDS.bucket, + authorizationId: UUIDS.authorization, + allowedMimeTypes: 'text/plain', + maxFileSize: 1_024, + skipDedup: true, + }); + services.bucketEntity.authorization = { id: UUIDS.authorization }; + services.bucketEntity.documents = [{ id: document.id }]; + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockResolvedValue( + services.bucketEntity as any + ); + vi.mocked(services.authorization.delete).mockImplementation(async () => { + await dataSource.manager.delete(AuthorizationFixture, { + id: UUIDS.authorization, + }); + return { id: UUIDS.authorization } as any; + }); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + await attemptService.finalizePrepared( + attempt.id, + document.id, + 'cd'.repeat(32) + ); + + await expect( + services.bucket.deleteStorageBucketForAccountDeletion( + UUIDS.bucket, + dataSource.manager + ) + ).rejects.toThrow(ValidationException); + + expect(services.authorization.delete).not.toHaveBeenCalled(); + await expect( + rowExists('authorization_policy', UUIDS.authorization) + ).resolves.toBe(true); + await expect(rowExists('storage_bucket', UUIDS.bucket)).resolves.toBe(true); + await expect(rowExists('file', document.id)).resolves.toBe(true); + await expect( + services.fileAdapter.getDocumentContent(document.id) + ).resolves.toEqual(content); + + await attemptService.deleteForMemo(UUIDS.memo); + await expect( + services.bucket.deleteStorageBucketForAccountDeletion( + UUIDS.bucket, + dataSource.manager + ) + ).resolves.toEqual({ + storageBucketID: UUIDS.bucket, + documentIDs: [document.id], + }); + + await expect( + rowExists('authorization_policy', UUIDS.authorization) + ).resolves.toBe(false); + await expect(rowExists('storage_bucket', UUIDS.bucket)).resolves.toBe(true); + await expect(rowExists('file', document.id)).resolves.toBe(true); + await expect( + services.fileAdapter.getDocumentContent(document.id) + ).resolves.toEqual(content); + + await services.fileAdapter.deleteDocument(document.id); + await services.bucket.removeStorageBucketRowForAccountDeletion( + UUIDS.bucket + ); + await expect(rowExists('file', document.id)).resolves.toBe(false); + await expect(rowExists('storage_bucket', UUIDS.bucket)).resolves.toBe( + false + ); + await expect( + services.fileAdapter.getDocumentContent(document.id) + ).rejects.toThrow(); + }); + + it('runs the actual concurrent prepare/delete path: the bucket preflight fails without side effects, then MemoService retry releases the attempt before file cleanup', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + const content = Buffer.from('signed copy'); + const snapshot = await services.fileAdapter.createDocument(content, { + displayName: 'signed-copy.txt', + mimeType: 'text/plain', + storageBucketId: UUIDS.bucket, + authorizationId: '77777777-7777-4777-8777-777777777777', + allowedMimeTypes: 'text/plain', + maxFileSize: 1_024, + skipDedup: true, + }); + services.bucketEntity.documents = [ + { + id: snapshot.id, + externalID: snapshot.externalID, + authorization: undefined, + tagset: undefined, + }, + ]; + const profileRead = createBarrier(); + const profileEntered = createBarrier(); + let blockProfileRead = true; + + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + vi.spyOn(services.profile, 'getProfileOrFail').mockImplementation( + async () => { + if (blockProfileRead) { + profileEntered.release(); + await profileRead.waiting; + } + return services.profileEntity as any; + } + ); + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockResolvedValue( + services.bucketEntity as any + ); + + const firstDelete = services.memo.deleteMemo(UUIDS.memo); + await profileEntered.waiting; + + const concurrentAttempt = await attemptService.createUnready( + UUIDS.memo, + UUIDS.actor + ); + await expect( + attemptService.finalizePrepared( + concurrentAttempt.id, + snapshot.id, + 'ab'.repeat(32) + ) + ).resolves.toBe(true); + + await expect( + services.fileAdapter.getDocumentContent(snapshot.id) + ).resolves.toEqual(content); + + await expect( + services.document.deleteDocument({ ID: snapshot.id }) + ).rejects.toThrow(); + await expect( + services.fileAdapter.getDocumentContent(snapshot.id) + ).resolves.toEqual(content); + + profileRead.release(); + await expect(firstDelete).rejects.toThrow(ValidationException); + expect(services.authorization.delete).not.toHaveBeenCalled(); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(true); + await expect(rowExists('file', snapshot.id)).resolves.toBe(true); + + blockProfileRead = false; + await expect(services.memo.deleteMemo(UUIDS.memo)).resolves.toMatchObject({ + id: UUIDS.memo, + }); + + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + await expect( + services.fileAdapter.getDocumentContent(snapshot.id) + ).rejects.toThrow(); + await expect(rowExists('file', snapshot.id)).resolves.toBe(false); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(false); + }); + + it('runs actual prepare row-first while MemoService deletion wins, leaving no attempt or uploaded file', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + vi.spyOn(services.profile, 'getProfileOrFail').mockResolvedValue( + services.profileEntity as any + ); + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockResolvedValue( + services.bucketEntity as any + ); + const renderEntered = createBarrier(); + const renderRelease = createBarrier(); + const uploaded: Array<{ id: string }> = []; + const upload = services.fileAdapter.createInternalDocumentInBucket.bind( + services.fileAdapter + ); + vi.spyOn( + services.fileAdapter, + 'createInternalDocumentInBucket' + ).mockImplementation(async (...args) => { + const document = await upload(...args); + uploaded.push(document); + return document; + }); + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('subject') } as any, + { read: vi.fn().mockResolvedValue('# memo') } as any, + { + render: vi.fn(async () => { + renderEntered.release(); + await renderRelease.waiting; + return Buffer.from('%PDF-delete-wins'); + }), + } as any, + services.fileAdapter, + { start: vi.fn() } as any, + { + getMemoSigningSnapshotRestUrl: vi + .fn() + .mockReturnValue('https://alkem.io/private/signing/preview'), + } as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + const preparation = signing.prepareMemoSigning(UUIDS.memo, actor); + await renderEntered.waiting; + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(1); + + await expect(services.memo.deleteMemo(UUIDS.memo)).resolves.toMatchObject({ + id: UUIDS.memo, + }); + renderRelease.release(); + + await expect(preparation).rejects.toThrow(); + expect(uploaded).toHaveLength(1); + await expect( + services.fileAdapter.getDocumentContent(uploaded[0].id) + ).rejects.toThrow(); + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(0); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(false); + }); + + it('compensates an owned upload when MemoService deletion wins before finalize', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + vi.spyOn(services.profile, 'getProfileOrFail').mockResolvedValue( + services.profileEntity as any + ); + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockResolvedValue( + services.bucketEntity as any + ); + const uploaded: Array<{ id: string }> = []; + const upload = services.fileAdapter.createInternalDocumentInBucket.bind( + services.fileAdapter + ); + vi.spyOn( + services.fileAdapter, + 'createInternalDocumentInBucket' + ).mockImplementation(async (...args) => { + const document = await upload(...args); + uploaded.push(document); + return document; + }); + const finalizeEntered = createBarrier(); + const finalizeRelease = createBarrier(); + const finalize = attemptService.finalizePrepared.bind(attemptService); + vi.spyOn(attemptService, 'finalizePrepared').mockImplementation( + async (...args) => { + finalizeEntered.release(); + await finalizeRelease.waiting; + return finalize(...args); + } + ); + const pdf = Buffer.from('%PDF-delete-before-finalize'); + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('subject') } as any, + { read: vi.fn().mockResolvedValue('# memo') } as any, + { render: vi.fn().mockResolvedValue(pdf) } as any, + services.fileAdapter, + { start: vi.fn() } as any, + { + getMemoSigningSnapshotRestUrl: vi + .fn() + .mockReturnValue('https://alkem.io/private/signing/preview'), + } as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + const preparation = signing.prepareMemoSigning(UUIDS.memo, actor); + await finalizeEntered.waiting; + expect(uploaded).toHaveLength(1); + await expect( + services.fileAdapter.getDocumentContent(uploaded[0].id) + ).resolves.toEqual(pdf); + + await expect(services.memo.deleteMemo(UUIDS.memo)).resolves.toMatchObject({ + id: UUIDS.memo, + }); + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + finalizeRelease.release(); + + await expect(preparation).rejects.toThrow( + 'The memo was deleted while preparing the signing copy' + ); + await expect( + services.fileAdapter.getDocumentContent(uploaded[0].id) + ).rejects.toThrow(); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(0); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(false); + }); + + it('models the collaboration read failure when deletion wins after attempt insert', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + vi.spyOn(services.profile, 'getProfileOrFail').mockResolvedValue( + services.profileEntity as any + ); + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockResolvedValue( + services.bucketEntity as any + ); + const attemptInserted = createBarrier(); + const returnAttempt = createBarrier(); + const createUnready = attemptService.createUnready.bind(attemptService); + vi.spyOn(attemptService, 'createUnready').mockImplementation( + async (...args) => { + const attempt = await createUnready(...args); + attemptInserted.release(); + await returnAttempt.waiting; + return attempt; + } + ); + const liveReadFailure = new DocumentPurgingError(UUIDS.memo); + // Modelled collaboration boundary: the real document.deleted path purges the + // room. collaboration-document.session.ts:186-189 and :499-501 translate + // WS close 1008 "document deleted" or session-end "document-deleted" into + // DocumentPurgingError. This PG/file-service harness has no collaboration + // service or RabbitMQ; checkpoint 6 verifies that live path through quickstart. + const read = vi.fn(async () => { + if (!(await rowExists('memo', UUIDS.memo))) throw liveReadFailure; + return '# memo still available'; + }); + const upload = vi.spyOn( + services.fileAdapter, + 'createInternalDocumentInBucket' + ); + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('subject') } as any, + { read } as any, + { + render: vi.fn().mockResolvedValue(Buffer.from('%PDF-live-read')), + } as any, + services.fileAdapter, + { start: vi.fn() } as any, + { + getMemoSigningSnapshotRestUrl: vi + .fn() + .mockReturnValue('https://alkem.io/private/signing/preview'), + } as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + const preparation = signing.prepareMemoSigning(UUIDS.memo, actor); + await attemptInserted.waiting; + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(1); + + await expect(services.memo.deleteMemo(UUIDS.memo)).resolves.toMatchObject({ + id: UUIDS.memo, + }); + returnAttempt.release(); + + await expect(preparation).rejects.toBe(liveReadFailure); + expect(read).toHaveBeenCalledOnce(); + expect(upload).not.toHaveBeenCalled(); + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(0); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(false); + }); + + it('gives same-byte concurrent preparations independent file IDs so losing cleanup preserves the winner', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + const created: SigningAttempt[] = []; + const uploaded: Array<{ id: string }> = []; + const createUnready = attemptService.createUnready.bind(attemptService); + vi.spyOn(attemptService, 'createUnready').mockImplementation( + async (...args) => { + const attempt = await createUnready(...args); + created.push(attempt); + return attempt; + } + ); + const upload = services.fileAdapter.createInternalDocumentInBucket.bind( + services.fileAdapter + ); + vi.spyOn( + services.fileAdapter, + 'createInternalDocumentInBucket' + ).mockImplementation(async (...args) => { + const document = await upload(...args); + uploaded.push(document); + return document; + }); + const finalize = attemptService.finalizePrepared.bind(attemptService); + const entered = [createBarrier(), createBarrier()]; + const release = [createBarrier(), createBarrier()]; + let finalization = 0; + vi.spyOn(attemptService, 'finalizePrepared').mockImplementation( + async (...args) => { + const index = finalization++; + entered[index].release(); + await release[index].waiting; + return finalize(...args); + } + ); + const pdf = Buffer.from('%PDF-identical-preparations'); + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('subject') } as any, + { read: vi.fn().mockResolvedValue('# memo') } as any, + { render: vi.fn().mockResolvedValue(pdf) } as any, + services.fileAdapter, + { start: vi.fn() } as any, + { + getMemoSigningSnapshotRestUrl: vi + .fn() + .mockReturnValue('https://alkem.io/private/signing/preview'), + } as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + const winner = signing.prepareMemoSigning(UUIDS.memo, actor); + await entered[0].waiting; + const loser = signing.prepareMemoSigning(UUIDS.memo, actor); + await entered[1].waiting; + expect.soft(uploaded[0].id).not.toBe(uploaded[1].id); + + await dataSource.getRepository(SigningAttempt).delete(created[1].id); + release[1].release(); + await expect(loser).rejects.toThrow(/deleted while preparing/i); + release[0].release(); + await expect(winner).resolves.toMatchObject({ attemptId: created[0].id }); + + await expect( + services.fileAdapter.getDocumentContent(uploaded[0].id) + ).resolves.toEqual(pdf); + await expect( + services.fileAdapter.getDocumentContent(uploaded[1].id) + ).rejects.toThrow(); + }); + + it('lets one actual continuation claim start the gateway and persist its returned correlation and expiry', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + const pdf = Buffer.from('%PDF-concurrent-continue'); + const snapshot = await services.fileAdapter.createInternalDocumentInBucket( + pdf, + UUIDS.bucket, + 'memo-signing-preview.pdf', + 'application/pdf', + { skipDedup: true } + ); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + await attemptService.finalizePrepared( + attempt.id, + snapshot.id, + createHash('sha256').update(pdf).digest('hex') + ); + const gatewayEntered = createBarrier(); + const gatewayRelease = createBarrier(); + const expiresAt = new Date('2026-09-05T21:00:00Z'); + const gateway = { + start: vi.fn(async () => { + gatewayEntered.release(); + await gatewayRelease.waiting; + return { + redirectUrl: 'https://connect.acc.cleverbase.com/authorize', + correlationId: 'correlation-winner', + expiresAt, + }; + }), + }; + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('PNONL-123') } as any, + {} as any, + {} as any, + services.fileAdapter, + gateway as any, + {} as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + const first = signing.continueMemoSigning(attempt.id, actor); + await gatewayEntered.waiting; + const second = signing.continueMemoSigning(attempt.id, actor); + gatewayRelease.release(); + const settled = await Promise.allSettled([first, second]); + + expect( + settled.filter(result => result.status === 'fulfilled') + ).toHaveLength(1); + expect(settled.filter(result => result.status === 'rejected')).toHaveLength( + 1 + ); + expect(gateway.start).toHaveBeenCalledOnce(); + await expect( + dataSource + .getRepository(SigningAttempt) + .findOneByOrFail({ id: attempt.id }) + ).resolves.toMatchObject({ + correlationId: 'correlation-winner', + expiresAt, + }); + }); + + it('lets one of two concurrent returns attach its signed copy and removes the losing upload', async () => { + const signedPdf = Buffer.from('%PDF-concurrent-return'); + const gateway = completedGateway(signedPdf); + const fixture = await createActualReturnFixture(gateway); + const uploaded: string[] = []; + const upload = fixture.services.bucket.uploadFileAsDocumentFromBuffer.bind( + fixture.services.bucket + ); + vi.spyOn( + fixture.services.bucket, + 'uploadFileAsDocumentFromBuffer' + ).mockImplementation(async (...args) => { + const document = await upload(...args); + uploaded.push(document.id); + return document; + }); + const finishEntered = createBarrier(); + const finishRelease = createBarrier(); + const finish = attemptService.finish.bind(attemptService); + let finishCount = 0; + vi.spyOn(attemptService, 'finish').mockImplementation(async (...args) => { + finishCount += 1; + if (finishCount === 2) finishEntered.release(); + await finishRelease.waiting; + return finish(...args); + }); + + const returns = [ + fixture.signing.completeMemoSigning( + fixture.correlationId, + fixture.clientState, + fixture.actor + ), + fixture.signing.completeMemoSigning( + fixture.correlationId, + fixture.clientState, + fixture.actor + ), + ]; + await finishEntered.waiting; + expect(uploaded).toHaveLength(2); + expect(uploaded[0]).not.toBe(uploaded[1]); + finishRelease.release(); + + await expect(Promise.all(returns)).resolves.toEqual([ + expect.objectContaining({ status: SigningAttemptStatus.SIGNED }), + expect.objectContaining({ status: SigningAttemptStatus.SIGNED }), + ]); + expect(gateway.getStatus).toHaveBeenCalledTimes(2); + expect(gateway.getResult).toHaveBeenCalledTimes(2); + const saved = await dataSource + .getRepository(SigningAttempt) + .findOneByOrFail({ id: fixture.attempt.id }); + expect(saved).toMatchObject({ + status: SigningAttemptStatus.SIGNED, + snapshotDocumentId: null, + }); + expect(uploaded).toContain(saved.signedDocumentId); + await expect( + fixture.services.fileAdapter.getDocumentContent(saved.signedDocumentId!) + ).resolves.toEqual(signedPdf); + await expect( + fixture.services.fileAdapter.getDocumentContent(fixture.snapshot.id) + ).rejects.toThrow(); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(1); + }); + + it('lets the expiry sweep win against a concurrent completed return and removes both owned files', async () => { + const gateway = completedGateway(Buffer.from('%PDF-return-sweep')); + const fixture = await createActualReturnFixture( + gateway, + new Date(Date.now() - 2 * 60 * 1000) + ); + const finishEntered = createBarrier(); + const finishRelease = createBarrier(); + const finish = attemptService.finish.bind(attemptService); + vi.spyOn(attemptService, 'finish').mockImplementation(async (...args) => { + finishEntered.release(); + await finishRelease.waiting; + return finish(...args); + }); + const sweep = new MemoSigningSweepService(attemptService, fixture.signing); + + const completion = fixture.signing.completeMemoSigning( + fixture.correlationId, + fixture.clientState, + fixture.actor + ); + await finishEntered.waiting; + await sweep.sweep(); + finishRelease.release(); + + await expect(completion).resolves.toMatchObject({ + status: SigningAttemptStatus.EXPIRED, + }); + await expect( + dataSource + .getRepository(SigningAttempt) + .findOneByOrFail({ id: fixture.attempt.id }) + ).resolves.toMatchObject({ + status: SigningAttemptStatus.EXPIRED, + snapshotDocumentId: null, + signedDocumentId: null, + }); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(0); + }); + + it('lets MemoService deletion win against a concurrent completed return without attaching its upload', async () => { + const gateway = completedGateway(Buffer.from('%PDF-return-delete')); + const fixture = await createActualReturnFixture(gateway); + const finishEntered = createBarrier(); + const finishRelease = createBarrier(); + const finish = attemptService.finish.bind(attemptService); + vi.spyOn(attemptService, 'finish').mockImplementation(async (...args) => { + finishEntered.release(); + await finishRelease.waiting; + return finish(...args); + }); + + const completion = fixture.signing.completeMemoSigning( + fixture.correlationId, + fixture.clientState, + fixture.actor + ); + await finishEntered.waiting; + await expect( + fixture.services.memo.deleteMemo(UUIDS.memo) + ).resolves.toMatchObject({ id: UUIDS.memo }); + finishRelease.release(); + + await expect(completion).resolves.toMatchObject({ + status: SigningAttemptStatus.EXPIRED, + }); + await expect( + dataSource.getRepository(SigningAttempt).count() + ).resolves.toBe(0); + await expect( + dataSource.getRepository(DocumentFixture).count() + ).resolves.toBe(0); + await expect(rowExists('memo', UUIDS.memo)).resolves.toBe(false); + await expect(rowExists('profile', UUIDS.profile)).resolves.toBe(false); + await expect(rowExists('storage_bucket', UUIDS.bucket)).resolves.toBe( + false + ); + }); + + it('stores and previews the exact image-containing PDF produced from the current projection', async () => { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + const sourceImage = + await services.fileAdapter.createInternalDocumentInBucket( + await sharp({ + create: { + width: 12, + height: 12, + channels: 3, + background: { r: 220, g: 10, b: 10 }, + }, + }) + .png() + .toBuffer(), + UUIDS.bucket, + 'source.png', + 'image/png', + { skipDedup: true } + ); + const imageUrl = `https://alkem.io/api/private/rest/storage/document/${sourceImage.id}`; + const liveDocument = prosemirrorJSONToYDoc( + markdownSchema, + { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Signed image witness ' }, + { + type: 'image', + attrs: { src: imageUrl, alt: 'red fixture', title: null }, + }, + ], + }, + ], + }, + 'default' + ); + const authorization = { grantAccessOrFail: vi.fn() }; + const renderer = new MemoPdfRenderer( + { + isAlkemioDocumentURL: vi.fn((url: string) => url === imageUrl), + getDocumentFromURL: vi.fn().mockResolvedValue({ + id: sourceImage.id, + authorization: { id: 'image-authorization' }, + storageBucket: { id: UUIDS.bucket }, + }), + } as any, + authorization as any, + services.fileAdapter + ); + const urlGenerator = { + getMemoSigningSnapshotRestUrl: vi + .fn() + .mockReturnValue('https://alkem.io/private/signing/preview'), + }; + const signing = new MemoSigningService( + authorization as any, + services.memo, + attemptService, + { getCleverbaseSubject: vi.fn().mockResolvedValue('subject') } as any, + { + read: vi.fn(async (_id, _type, _actor, project) => + project(liveDocument) + ), + } as any, + renderer, + services.fileAdapter, + { start: vi.fn() } as any, + urlGenerator as any, + {} as any, + {} as any, + {} as any, + { error: vi.fn() } as any + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + + try { + const result = await signing.prepareMemoSigning(UUIDS.memo, actor); + const attempt = await dataSource + .getRepository(SigningAttempt) + .findOneByOrFail({ id: result.attemptId }); + const stored = await services.fileAdapter.getDocumentContent( + attempt.snapshotDocumentId! + ); + const preview = await signing.getSnapshot(result.attemptId, actor); + + expect( + yjsStateToMarkdown(Buffer.from(Y.encodeStateAsUpdateV2(liveDocument))) + ).toContain(`![red fixture](${imageUrl})`); + expect(stored.toString('latin1')).toContain('/Subtype /Image'); + expect(preview).toEqual(stored); + expect(attempt.contentSha256).toBe( + createHash('sha256').update(stored).digest('hex') + ); + expect(result.previewUrl).toBe( + 'https://alkem.io/private/signing/preview' + ); + } finally { + liveDocument.destroy(); + } + }); + + async function seedDeletionGraph(): Promise { + await dataSource.query('INSERT INTO memo (id) VALUES ($1)', [UUIDS.memo]); + await dataSource.query('INSERT INTO profile (id) VALUES ($1)', [ + UUIDS.profile, + ]); + await dataSource.query('INSERT INTO storage_bucket (id) VALUES ($1)', [ + UUIDS.bucket, + ]); + await dataSource.query( + 'INSERT INTO authorization_policy (id) VALUES ($1)', + [UUIDS.authorization] + ); + } + + function completedGateway(pdf: Buffer) { + return { + getStatus: vi.fn().mockResolvedValue({ status: 'completed' }), + getResult: vi.fn().mockResolvedValue({ + pdf, + evidence: { profile: 'B-T' }, + }), + }; + } + + async function createActualReturnFixture( + gateway: ReturnType, + expiresAt = new Date(Date.now() + 10 * 60 * 1000) + ) { + await seedDeletionGraph(); + const services = createActualDeletionServices(dataSource, attemptService); + services.bucketEntity.authorization = { + id: UUIDS.authorization, + credentialRules: [], + privilegeRules: [], + }; + services.bucketEntity.allowedMimeTypes = ['application/pdf']; + services.bucketEntity.maxFileSize = 15 * 1024 * 1024; + services.memoEntity.nameID = 'memo'; + vi.spyOn(services.memo, 'getMemoOrFail').mockResolvedValue( + services.memoEntity as any + ); + vi.spyOn(services.profile, 'getProfileOrFail').mockResolvedValue( + services.profileEntity as any + ); + vi.spyOn(services.authorization, 'save').mockImplementation( + async policy => { + policy.id = randomUUID(); + await dataSource.query( + 'INSERT INTO authorization_policy (id) VALUES ($1)', + [policy.id] + ); + return policy; + } + ); + const documents = dataSource.getRepository(DocumentFixture); + const loadDocument = async (id: string) => { + const row = await documents.findOneByOrFail({ id }); + return { + ...row, + authorization: row.authorizationId + ? { + id: row.authorizationId, + credentialRules: [], + privilegeRules: [], + } + : undefined, + tagset: row.tagsetId + ? { + id: row.tagsetId, + authorization: { + id: randomUUID(), + credentialRules: [], + privilegeRules: [], + }, + } + : undefined, + storageBucket: services.bucketEntity, + } as any; + }; + vi.spyOn(services.document, 'getDocumentOrFail').mockImplementation( + loadDocument + ); + vi.spyOn(services.bucket, 'getStorageBucketOrFail').mockImplementation( + async () => ({ + ...services.bucketEntity, + documents: await Promise.all( + (await documents.find()).map(document => + loadDocument(document.id as string) + ) + ), + }) + ); + const clientState = 'return-state'; + const correlationId = 'correlation-return'; + const snapshotPdf = Buffer.from('%PDF-return-snapshot'); + const snapshot = await services.fileAdapter.createInternalDocumentInBucket( + snapshotPdf, + UUIDS.bucket, + 'memo-signing-preview.pdf', + 'application/pdf', + { skipDedup: true } + ); + const attempt = await attemptService.createUnready(UUIDS.memo, UUIDS.actor); + await attemptService.finalizePrepared( + attempt.id, + snapshot.id, + createHash('sha256').update(snapshotPdf).digest('hex') + ); + const clientStateHash = createHash('sha256') + .update(clientState) + .digest('hex'); + await attemptService.claimStart(attempt.id, clientStateHash); + await attemptService.recordGatewayStart( + attempt.id, + clientStateHash, + correlationId, + expiresAt + ); + const actor = Object.assign(new ActorContext(), { + actorID: UUIDS.actor, + authenticationID: 'kratos-1', + }); + const signing = new MemoSigningService( + { grantAccessOrFail: vi.fn() } as any, + services.memo, + attemptService, + {} as any, + {} as any, + {} as any, + services.fileAdapter, + gateway as any, + { getMemoUrlPath: vi.fn().mockResolvedValue('/memo') } as any, + services.bucket, + services.documentAuthorization, + services.document, + logger + ); + return { + actor, + attempt, + clientState, + correlationId, + services, + signing, + snapshot, + }; + } + + function createActualDeletionServices( + source: DataSource, + signingAttempts: SigningAttemptService + ) { + const config = new ConfigService({ + authorization: { chunk: 100 }, + storage: { + file_service: { + enabled: true, + url: process.env.CONTENT_SIGNING_FILE_SERVICE_URL, + timeout: 2_000, + retries: 0, + }, + }, + }) as any; + const fileAdapter = new FileServiceAdapter( + new HttpService(), + config, + logger + ); + const authorization = new AuthorizationPolicyService( + source.getRepository(AuthorizationFixture) as any, + {} as any, + logger, + config + ); + vi.spyOn(authorization, 'delete'); + const tagsets = { + createTagset: vi.fn(() => ({ id: randomUUID() })), + save: vi.fn(async tagset => tagset), + removeTagset: vi.fn(), + } as any; + const documentAuthorization = { + applyAuthorizationPolicy: vi.fn().mockResolvedValue([]), + } as any; + const document = new DocumentService( + config, + authorization, + tagsets, + source.getRepository(DocumentFixture) as any, + logger, + fileAdapter + ); + const unused = {} as any; + const bucket = new StorageBucketService( + document, + documentAuthorization, + unused, + authorization, + unused, + unused, + source.getRepository(StorageBucketFixture) as any, + source.getRepository(DocumentFixture) as any, + logger, + source.getRepository(ProfileFixture) as any, + config, + fileAdapter, + tagsets, + signingAttempts + ); + const profile = new ProfileService( + authorization, + bucket, + tagsets, + unused, + unused, + unused, + unused, + source.getRepository(ProfileFixture) as any, + logger + ); + const lifecycle = { + publishDocumentDeleted: vi.fn().mockResolvedValue(undefined), + }; + const memo = new MemoService( + logger, + source.getRepository(MemoFixture) as any, + authorization, + profile, + unused, + unused, + unused, + lifecycle as any, + fileAdapter, + unused, + signingAttempts + ); + const bucketEntity: any = { + id: UUIDS.bucket, + authorization: undefined, + documents: [], + }; + const profileEntity = { + id: UUIDS.profile, + authorization: undefined, + storageBucket: bucketEntity, + tagsets: [], + references: [], + visuals: [], + location: undefined, + }; + const memoEntity = { + id: UUIDS.memo, + nameID: 'memo', + authorization: { id: UUIDS.authorization }, + profile: profileEntity, + }; + return { + authorization, + bucket, + bucketEntity, + document, + documentAuthorization, + fileAdapter, + memo, + memoEntity, + profile, + profileEntity, + tagsets, + }; + } + + async function rowExists(table: string, id: string): Promise { + const [{ exists }] = await dataSource.query( + `SELECT EXISTS(SELECT 1 FROM "${table}" WHERE id = $1) AS exists`, + [id] + ); + return exists; + } +}); diff --git a/test/integration/content-signing/vitest.coverage.config.ts b/test/integration/content-signing/vitest.coverage.config.ts new file mode 100644 index 0000000000..e5454d3db9 --- /dev/null +++ b/test/integration/content-signing/vitest.coverage.config.ts @@ -0,0 +1,103 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../../vitest.config'; + +const base = baseConfig; + +export default defineConfig({ + ...base, + test: { + ...base.test, + coverage: { + provider: 'v8', + reportsDirectory: './coverage-ci/content-signing', + exclude: ['**/*.spec.ts'], + include: [ + 'src/domain/common/content-signing/content.signing.module.ts', + 'src/domain/common/content-signing/signing.attempt.entity.ts', + 'src/domain/common/content-signing/signing.attempt.interface.ts', + 'src/domain/common/content-signing/signing.attempt.service.ts', + 'src/domain/common/content-signing/signing.attempt.status.ts', + 'src/domain/common/memo/dto/memo.signing.continue.input.ts', + 'src/domain/common/memo/dto/memo.signing.continue.result.ts', + 'src/domain/common/memo/dto/memo.signing.prepare.input.ts', + 'src/domain/common/memo/dto/memo.signing.prepare.result.ts', + 'src/domain/common/memo/dto/memo.signature.verify.input.ts', + 'src/domain/common/memo/memo.module.ts', + 'src/domain/common/memo/memo.pdf.renderer.ts', + 'src/domain/common/memo/memo.resolver.fields.ts', + 'src/domain/common/memo/memo.resolver.mutations.ts', + 'src/domain/common/memo/memo.signature.resolver.fields.ts', + 'src/domain/common/memo/memo.signature.verification.status.ts', + 'src/services/api-rest/content-signing/*.ts', + 'src/domain/common/memo/memo.signing.service.ts', + 'src/domain/common/memo/memo.signing.sweep.service.ts', + 'src/common/enums/rest.endpoint.ts', + 'src/domain/common/memo/memo.service.ts', + 'src/domain/storage/storage-bucket/storage.bucket.module.ts', + 'src/domain/storage/storage-bucket/storage.bucket.service.ts', + 'src/migrations/1788609600000-CreateSigningAttempt.ts', + 'src/services/adapters/file-service-adapter/file.service.adapter.ts', + 'src/services/adapters/trust-gateway/trust.gateway.client.ts', + 'src/services/infrastructure/{kratos,url-generator}/*.service.ts', + ], + reporter: ['text', 'json'], + thresholds: { + 'src/domain/common/content-signing/**': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/migrations/1788609600000-CreateSigningAttempt.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/domain/common/memo/memo.pdf.renderer.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/services/api-rest/content-signing/content.signing.controller.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/domain/common/memo/memo.signing.service.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/domain/common/memo/memo.signature.resolver.fields.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/domain/common/memo/memo.signing.sweep.service.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/services/adapters/trust-gateway/trust.gateway.client.ts': { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + 'src/services/api-rest/content-signing/content.signing.return.filter.ts': + { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + }, + }, + }, +}); From b77cc9bcf19bdaf86c9c133a70d4da85ed8faf67 Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Wed, 9 Sep 2026 12:44:32 +0200 Subject: [PATCH 05/11] ci(content-signing): use docker-capable runner (#6477) Move the real-service suite out of the trusted ARC test job, which has no Docker daemon, into an unconditional Ubuntu job. Keep unit LCOV and Sonar wiring unchanged while retaining scoped integration coverage as a separate artifact. --- .github/workflows/ci-tests.yml | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 9daf7a52ae..985015f9ce 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -38,6 +38,37 @@ jobs: timeout-minutes: 10 run: pnpm run test:ci + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage-ci/ + retention-days: 1 + + content-signing-integration: + runs-on: ubuntu-latest + permissions: + contents: read + env: + NODE_OPTIONS: "--max-old-space-size=4096" + steps: + - uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.22.0' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Checkout pinned file-service for content-signing integration uses: actions/checkout@v7 with: @@ -50,12 +81,12 @@ jobs: docker build --label org.opencontainers.image.source=https://github.com/alkem-io/file-service --label org.opencontainers.image.revision=0a3995b235ef427c9d7cfd1092e7945e5244c137 --tag aiai2025-file-service-pr1:0a3995 .content-signing-file-service CONTENT_SIGNING_FILE_SERVICE_IMAGE=aiai2025-file-service-pr1:0a3995 pnpm run test:content-signing:coverage - - name: Upload coverage artifact + - name: Upload content-signing coverage artifact if: always() uses: actions/upload-artifact@v7 with: - name: coverage - path: coverage-ci/ + name: coverage-content-signing + path: coverage-ci/content-signing/ retention-days: 1 sonarqube: From 4d074eb723fbbe9fa28a7e9d6a04810863d1ddb9 Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Wed, 9 Sep 2026 12:45:02 +0200 Subject: [PATCH 06/11] feat(signing): gate memo signing by space entitlement (#6478) * test(signing): define space gating behavior Capture the RED expectations for default-off license propagation, endpoint enforcement, and the entitlement migrations before the implementation. * feat(signing): gate signing by space license Add a default-off memo-signing space entitlement and license plan, inherit it through collaboration licenses, and enforce it before prepare and continue side effects. Existing signed-copy reads and verification remain available. * docs(migrations): clarify signing rollback scope * test(signing): pin space gating boundaries * docs(signing): record entitlement boundaries --- schema-lite.graphql | 3 + schema.graphql | 3 + src/common/enums/license.entitlement.type.ts | 1 + ...ensing.credential.based.credential.type.ts | 1 + .../license-plan/license-plans.json | 14 ++ .../collaboration.service.license.spec.ts | 12 +- .../collaboration.service.license.ts | 1 + .../collaboration.service.spec.ts | 11 ++ .../collaboration/collaboration.service.ts | 6 + .../common/memo/memo.signing.service.spec.ts | 87 +++++++++++- .../common/memo/memo.signing.service.ts | 22 ++- .../space/space/space.service.license.spec.ts | 76 +++++++++++ .../space/space/space.service.license.ts | 13 ++ src/domain/space/space/space.service.spec.ts | 5 + src/domain/space/space/space.service.ts | 6 + .../template.content.space.service.spec.ts | 5 + .../template.content.space.service.ts | 6 + ...1788947200000-AddMemoSigningEntitlement.ts | 125 ++++++++++++++++++ ...47200100-BackfillMemoSigningEntitlement.ts | 47 +++++++ ...47200000-AddMemoSigningEntitlement.spec.ts | 97 ++++++++++++++ ...100-BackfillMemoSigningEntitlement.spec.ts | 35 +++++ .../signing-attempt.postgres.spec.ts | 36 ++++- 22 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 src/migrations/1788947200000-AddMemoSigningEntitlement.ts create mode 100644 src/migrations/1788947200100-BackfillMemoSigningEntitlement.ts create mode 100644 src/migrations/__tests__/1788947200000-AddMemoSigningEntitlement.spec.ts create mode 100644 src/migrations/__tests__/1788947200100-BackfillMemoSigningEntitlement.spec.ts diff --git a/schema-lite.graphql b/schema-lite.graphql index 6a33a27545..7a46e9c2b2 100644 --- a/schema-lite.graphql +++ b/schema-lite.graphql @@ -2012,6 +2012,7 @@ enum CredentialType { ORGANIZATION_OWNER SPACE_ADMIN SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS @@ -2871,6 +2872,7 @@ enum LicenseEntitlementType { ACCOUNT_SPACE_PREMIUM ACCOUNT_VIRTUAL_CONTRIBUTOR SPACE_FLAG_MEMO_MULTI_USER + SPACE_FLAG_MEMO_SIGNING SPACE_FLAG_OFFICE_DOCUMENTS SPACE_FLAG_SAVE_AS_TEMPLATE SPACE_FLAG_VIRTUAL_CONTRIBUTOR_ACCESS @@ -2953,6 +2955,7 @@ type Licensing { enum LicensingCredentialBasedCredentialType { ACCOUNT_LICENSE_PLUS SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS diff --git a/schema.graphql b/schema.graphql index b78d2df827..d5f21e2386 100644 --- a/schema.graphql +++ b/schema.graphql @@ -397,6 +397,7 @@ enum CredentialType { PLATFORM_OPERATIONS_ADMIN SPACE_ADMIN SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS @@ -475,6 +476,7 @@ enum LicenseEntitlementType { ACCOUNT_SPACE_PREMIUM ACCOUNT_VIRTUAL_CONTRIBUTOR SPACE_FLAG_MEMO_MULTI_USER + SPACE_FLAG_MEMO_SIGNING SPACE_FLAG_OFFICE_DOCUMENTS SPACE_FLAG_SAVE_AS_TEMPLATE SPACE_FLAG_VIRTUAL_CONTRIBUTOR_ACCESS @@ -496,6 +498,7 @@ enum LicenseType { enum LicensingCredentialBasedCredentialType { ACCOUNT_LICENSE_PLUS SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS diff --git a/src/common/enums/license.entitlement.type.ts b/src/common/enums/license.entitlement.type.ts index e29b811586..9d88214361 100644 --- a/src/common/enums/license.entitlement.type.ts +++ b/src/common/enums/license.entitlement.type.ts @@ -15,6 +15,7 @@ export enum LicenseEntitlementType { SPACE_FLAG_WHITEBOARD_MULTI_USER = 'space-flag-whiteboard-multi-user', SPACE_FLAG_MEMO_MULTI_USER = 'space-flag-memo-multi-user', SPACE_FLAG_OFFICE_DOCUMENTS = 'space-flag-office-documents', + SPACE_FLAG_MEMO_SIGNING = 'space-flag-memo-signing', // 004-web-ai-assistant (FR-027b, Increment B): the acting user's Account // monthly weighted-token allowance for the web AI assistant. A LIMIT // entitlement resolved by the credential-based licensing engine; the per-tier diff --git a/src/common/enums/licensing.credential.based.credential.type.ts b/src/common/enums/licensing.credential.based.credential.type.ts index 8ff446d040..99856bd698 100644 --- a/src/common/enums/licensing.credential.based.credential.type.ts +++ b/src/common/enums/licensing.credential.based.credential.type.ts @@ -11,6 +11,7 @@ export enum LicensingCredentialBasedCredentialType { SPACE_FEATURE_WHITEBOARD_MULTI_USER = 'space-feature-whiteboard-multi-user', SPACE_FEATURE_MEMO_MULTI_USER = 'space-feature-memo-multi-user', SPACE_FEATURE_OFFICE_DOCUMENTS = 'space-feature-office-documents', + SPACE_FEATURE_MEMO_SIGNING = 'space-feature-memo-signing', ACCOUNT_LICENSE_PLUS = 'account-license-plus', } diff --git a/src/core/bootstrap/platform-template-definitions/license-plan/license-plans.json b/src/core/bootstrap/platform-template-definitions/license-plan/license-plans.json index d7190bfa02..85fb36de30 100644 --- a/src/core/bootstrap/platform-template-definitions/license-plan/license-plans.json +++ b/src/core/bootstrap/platform-template-definitions/license-plan/license-plans.json @@ -139,6 +139,20 @@ "assignToNewOrganizationAccounts": "0", "assignToNewUserAccounts": "0", "type": "space-feature-flag" + }, + { + "name": "SPACE_FEATURE_MEMO_SIGNING", + "enabled": "1", + "sortOrder": "110", + "pricePerMonth": "0.00", + "isFree": "1", + "trialEnabled": "0", + "requiresPaymentMethod": "0", + "requiresContactSupport": "1", + "licenseCredential": "space-feature-memo-signing", + "assignToNewOrganizationAccounts": "0", + "assignToNewUserAccounts": "0", + "type": "space-feature-flag" } ] } diff --git a/src/domain/collaboration/collaboration/collaboration.service.license.spec.ts b/src/domain/collaboration/collaboration/collaboration.service.license.spec.ts index 9d0fc431b6..dcb2813496 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.license.spec.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.license.spec.ts @@ -98,6 +98,10 @@ describe('CollaborationLicenseService', () => { type: LicenseEntitlementType.SPACE_FLAG_OFFICE_DOCUMENTS, enabled: false, }, + { + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + enabled: false, + }, ], } as any; const collaboration = { @@ -127,7 +131,13 @@ describe('CollaborationLicenseService', () => { expect(licenseService.reset).toHaveBeenCalledWith(license); expect(licenseService.findAndCopyParentEntitlement).toHaveBeenCalledTimes( - 4 + 5 + ); + expect(licenseService.findAndCopyParentEntitlement).toHaveBeenCalledWith( + expect.objectContaining({ + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + }), + parentLicense.entitlements ); expect(result).toContain(license); }); diff --git a/src/domain/collaboration/collaboration/collaboration.service.license.ts b/src/domain/collaboration/collaboration/collaboration.service.license.ts index 38f4ec65e2..e66601ff23 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.license.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.license.ts @@ -97,6 +97,7 @@ export class CollaborationLicenseService { case LicenseEntitlementType.SPACE_FLAG_WHITEBOARD_MULTI_USER: case LicenseEntitlementType.SPACE_FLAG_MEMO_MULTI_USER: case LicenseEntitlementType.SPACE_FLAG_OFFICE_DOCUMENTS: + case LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING: this.licenseService.findAndCopyParentEntitlement( entitlement, parentEntitlements diff --git a/src/domain/collaboration/collaboration/collaboration.service.spec.ts b/src/domain/collaboration/collaboration/collaboration.service.spec.ts index 874bb9971d..1d3254935c 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.spec.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.spec.ts @@ -157,6 +157,17 @@ describe('CollaborationService', () => { expect(result.timeline).toBeDefined(); expect(result.isTemplate).toBe(false); expect(timelineService.createTimeline).toHaveBeenCalled(); + expect(licenseService.createLicense).toHaveBeenCalledWith( + expect.objectContaining({ + entitlements: expect.arrayContaining([ + expect.objectContaining({ + type: 'space-flag-memo-signing', + enabled: false, + limit: 0, + }), + ]), + }) + ); }); it('should create a template collaboration without timeline', async () => { diff --git a/src/domain/collaboration/collaboration/collaboration.service.ts b/src/domain/collaboration/collaboration/collaboration.service.ts index dc4132c289..9a8e218ec7 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.ts @@ -120,6 +120,12 @@ export class CollaborationService { limit: 0, enabled: false, }, + { + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + dataType: LicenseEntitlementDataType.FLAG, + limit: 0, + enabled: false, + }, ], }); diff --git a/src/domain/common/memo/memo.signing.service.spec.ts b/src/domain/common/memo/memo.signing.service.spec.ts index 0c8b5d4620..b9fd52a39b 100644 --- a/src/domain/common/memo/memo.signing.service.spec.ts +++ b/src/domain/common/memo/memo.signing.service.spec.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { AlkemioErrorStatus } from '@common/enums'; import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { LicenseEntitlementType } from '@common/enums/license.entitlement.type'; import { LogContext } from '@common/enums/logging.context'; import { ForbiddenException, ValidationException } from '@common/exceptions'; import { ForbiddenAuthorizationPolicyException } from '@common/exceptions/forbidden.authorization.policy.exception'; @@ -32,6 +33,8 @@ describe('MemoSigningService', () => { }, }; const pdf = Buffer.from('%PDF-fixed-preview'); + const memoLicense = { id: 'memo-license' }; + const unrelatedLicense = { id: 'unrelated-license' }; const calls: string[] = []; const authorizationService = { grantAccessOrFail: vi.fn(() => calls.push('authorize')), @@ -119,6 +122,17 @@ describe('MemoSigningService', () => { (document: { id: string }) => `https://alkem.io/document/${document.id}` ), }; + const communityResolverService = { + getCollaborationLicenseFromMemoOrFail: vi.fn(async (memoId: string) => { + calls.push('resolve-license'); + return memoId === memo.id ? memoLicense : unrelatedLicense; + }), + }; + const licenseService = { + isEntitlementEnabledOrFail: vi.fn((_license: unknown) => { + calls.push('check-entitlement'); + }), + }; const service = new MemoSigningService( authorizationService as any, memoService as any, @@ -132,7 +146,9 @@ describe('MemoSigningService', () => { storageBucketService as any, documentAuthorizationService as any, documentService as any, - logger as any + logger as any, + communityResolverService as any, + licenseService as any ); beforeEach(() => { calls.length = 0; @@ -142,6 +158,15 @@ describe('MemoSigningService', () => { calls.push('identity'); return 'linked-subject'; }); + communityResolverService.getCollaborationLicenseFromMemoOrFail.mockImplementation( + async memoId => { + calls.push('resolve-license'); + return memoId === memo.id ? memoLicense : unrelatedLicense; + } + ); + licenseService.isEntitlementEnabledOrFail.mockImplementation(() => { + calls.push('check-entitlement'); + }); attemptService.createUnready.mockImplementation(async () => { calls.push('insert'); return { id: 'attempt-1' }; @@ -190,6 +215,8 @@ describe('MemoSigningService', () => { expect(calls).toEqual([ 'authorize', + 'resolve-license', + 'check-entitlement', 'identity', 'insert', 'live-read', @@ -203,6 +230,13 @@ describe('MemoSigningService', () => { AuthorizationPrivilege.CONTRIBUTE, 'sign memo' ); + expect( + communityResolverService.getCollaborationLicenseFromMemoOrFail + ).toHaveBeenCalledWith(memo.id); + expect(licenseService.isEntitlementEnabledOrFail).toHaveBeenCalledWith( + memoLicense, + LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); expect(collaborationDocumentService.read).toHaveBeenCalledWith( memo.id, 'memo', @@ -344,6 +378,47 @@ describe('MemoSigningService', () => { expect(renderer.render).not.toHaveBeenCalled(); }); + it.each([ + 'prepare', + 'continue', + ] as const)('does not let an enabled unrelated space authorize %s', async operation => { + const snapshot = Buffer.from('%PDF-exact-preview'); + attemptService.getForActorOrFail.mockResolvedValue({ + id: 'attempt-1', + memoId: memo.id, + status: SigningAttemptStatus.PENDING, + snapshotDocumentId: 'snapshot-1', + contentSha256: createHash('sha256').update(snapshot).digest('hex'), + createdDate: new Date(), + }); + fileServiceAdapter.getDocumentContent.mockResolvedValue(snapshot); + const enabledLicenses = new Set([unrelatedLicense]); + licenseService.isEntitlementEnabledOrFail.mockImplementation(license => { + if (!enabledLicenses.has(license as { id: string })) { + throw new Error('memo signing disabled'); + } + }); + + const result = + operation === 'prepare' + ? service.prepareMemoSigning(memo.id, actor) + : service.continueMemoSigning('attempt-1', actor); + + await expect(result).rejects.toThrow('memo signing disabled'); + expect( + communityResolverService.getCollaborationLicenseFromMemoOrFail + ).toHaveBeenCalledWith(memo.id); + expect(licenseService.isEntitlementEnabledOrFail).toHaveBeenCalledWith( + memoLicense, + LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); + expect(kratosService.getCleverbaseSubject).not.toHaveBeenCalled(); + expect(attemptService.createUnready).not.toHaveBeenCalled(); + expect(fileServiceAdapter.getDocumentContent).not.toHaveBeenCalled(); + expect(attemptService.claimStart).not.toHaveBeenCalled(); + expect(trustGatewayClient.start).not.toHaveBeenCalled(); + }); + it('fails a session without a Kratos identity before rendering', async () => { const unlinkedActor = Object.assign(new ActorContext(), { actorID: actor.actorID, @@ -432,6 +507,7 @@ describe('MemoSigningService', () => { expect(fileServiceAdapter.getDocumentContent).toHaveBeenCalledWith( 'snapshot-1' ); + expect(licenseService.isEntitlementEnabledOrFail).not.toHaveBeenCalled(); }); it('rejects an unrelated actor before reading the memo or snapshot', async () => { @@ -488,6 +564,13 @@ describe('MemoSigningService', () => { authorizeUrl: 'https://connect.acc.cleverbase.com/authorize', }); expect(Object.keys(result)).toEqual(['authorizeUrl']); + expect( + communityResolverService.getCollaborationLicenseFromMemoOrFail + ).toHaveBeenCalledWith(memo.id); + expect(licenseService.isEntitlementEnabledOrFail).toHaveBeenCalledWith( + memoLicense, + LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); expect(trustGatewayClient.start).toHaveBeenCalledWith( snapshot, 'linked-subject', @@ -732,6 +815,7 @@ describe('MemoSigningService', () => { actor.actorID, createHash('sha256').update(state).digest('hex') ); + expect(licenseService.isEntitlementEnabledOrFail).not.toHaveBeenCalled(); expect( storageBucketService.uploadFileAsDocumentFromBuffer ).toHaveBeenCalledWith( @@ -1190,6 +1274,7 @@ describe('MemoSigningService', () => { ); expect(trustGatewayClient.verify).toHaveBeenCalledWith(signedPdf); expect(attemptService.finish).not.toHaveBeenCalled(); + expect(licenseService.isEntitlementEnabledOrFail).not.toHaveBeenCalled(); }); it('reports invalid integrity without exposing gateway reason codes', async () => { diff --git a/src/domain/common/memo/memo.signing.service.ts b/src/domain/common/memo/memo.signing.service.ts index 77edd3ad7c..336be61e28 100644 --- a/src/domain/common/memo/memo.signing.service.ts +++ b/src/domain/common/memo/memo.signing.service.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from 'node:crypto'; import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { LicenseEntitlementType } from '@common/enums/license.entitlement.type'; import { LogContext } from '@common/enums/logging.context'; import { ForbiddenException, ValidationException } from '@common/exceptions'; import { ActorContext } from '@core/actor-context/actor.context'; @@ -7,6 +8,7 @@ import { AuthorizationService } from '@core/authorization/authorization.service' import { SigningAttempt } from '@domain/common/content-signing/signing.attempt.entity'; import { SigningAttemptService } from '@domain/common/content-signing/signing.attempt.service'; import { SigningAttemptStatus } from '@domain/common/content-signing/signing.attempt.status'; +import { LicenseService } from '@domain/common/license/license.service'; import { DocumentService } from '@domain/storage/document/document.service'; import { DocumentAuthorizationService } from '@domain/storage/document/document.service.authorization'; import { StorageBucketService } from '@domain/storage/storage-bucket/storage.bucket.service'; @@ -14,6 +16,7 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { FileServiceAdapter } from '@services/adapters/file-service-adapter/file.service.adapter'; import { TrustGatewayClient } from '@services/adapters/trust-gateway/trust.gateway.client'; import { CollaborationDocumentService } from '@services/collaboration-client/collaboration-document.service'; +import { CommunityResolverService } from '@services/infrastructure/entity-resolver/community.resolver.service'; import { KratosService } from '@services/infrastructure/kratos/kratos.service'; import { UrlGeneratorService } from '@services/infrastructure/url-generator'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; @@ -39,11 +42,15 @@ export class MemoSigningService { private readonly storageBucketService: StorageBucketService, private readonly documentAuthorizationService: DocumentAuthorizationService, private readonly documentService: DocumentService, - @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private readonly logger: LoggerService, + private readonly communityResolverService: CommunityResolverService, + private readonly licenseService: LicenseService ) {} async prepareMemoSigning(memoId: string, actor: ActorContext) { const memo = await this.getAuthorizedMemo(memoId, actor); + await this.requireMemoSigningEntitlement(memoId); await this.requireCleverbaseSubject(actor); const storageBucketId = this.requireBucket(memo).id; @@ -137,6 +144,7 @@ export class MemoSigningService { LogContext.MEMOS ); await this.getAuthorizedMemo(attempt.memoId, actor); + await this.requireMemoSigningEntitlement(attempt.memoId); const subject = await this.requireCleverbaseSubject(actor); const snapshot = await this.fileServiceAdapter.getDocumentContent( attempt.snapshotDocumentId @@ -299,6 +307,18 @@ export class MemoSigningService { ); } + private async requireMemoSigningEntitlement(memoId: string): Promise { + // Standalone memos have no collaboration license and therefore fail closed. + const license = + await this.communityResolverService.getCollaborationLicenseFromMemoOrFail( + memoId + ); + this.licenseService.isEntitlementEnabledOrFail( + license, + LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); + } + private async finish( attempt: SigningAttempt, status: Exclude, diff --git a/src/domain/space/space/space.service.license.spec.ts b/src/domain/space/space/space.service.license.spec.ts index 0f38155163..49b68cf95d 100644 --- a/src/domain/space/space/space.service.license.spec.ts +++ b/src/domain/space/space/space.service.license.spec.ts @@ -90,6 +90,11 @@ describe('SpaceLicenseService', () => { limit: 0, enabled: false, }, + { + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + limit: 0, + enabled: false, + }, ], }, community: { @@ -338,5 +343,76 @@ describe('SpaceLicenseService', () => { expect(subspaceOfficeEntitlement!.limit).toBe(0); }); }); + + describe('SPACE_FLAG_MEMO_SIGNING', () => { + it('is disabled before the L0 license policy grants it', () => { + const entitlement = createMockSpace().license.entitlements.find( + (entry: any) => + entry.type === LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); + + expect(entitlement).toMatchObject({ enabled: false, limit: 0 }); + }); + + it('is enabled only when the L0 space agent holds the credential', async () => { + const mockSpace = createMockSpace(); + (spaceService.getSpaceOrFail as any).mockResolvedValue( + mockSpace as any + ); + (licenseService.reset as any).mockReturnValue(mockSpace.license as any); + (licenseEngineService.isEntitlementGranted as any).mockImplementation( + async (type: LicenseEntitlementType) => + type === LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ); + (roleSetLicenseService.applyLicensePolicy as any).mockResolvedValue([]); + ( + collaborationLicenseService.applyLicensePolicy as any + ).mockResolvedValue([]); + + await service.applyLicensePolicy('space-1'); + + expect( + mockSpace.license.entitlements.find( + (entry: any) => + entry.type === LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ) + ).toMatchObject({ enabled: true, limit: 1 }); + }); + + it('remains disabled on a sub-space when only an intermediate non-L0 agent holds the credential', async () => { + const subspace = { id: 'subspace-1' }; + const parentSpace = createMockSpace({ subspaces: [subspace] }); + const subspaceMock = createMockSpace({ + id: 'subspace-1', + subspaces: [], + }); + + (spaceService.getSpaceOrFail as any).mockImplementation( + async (id: string) => + id === 'space-1' ? (parentSpace as any) : (subspaceMock as any) + ); + (licenseService.reset as any).mockImplementation( + (license: any) => license as any + ); + (licenseEngineService.isEntitlementGranted as any).mockImplementation( + async (type: LicenseEntitlementType, agent: { id?: string }) => + type === LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING && + agent?.id === 'subspace-1' + ); + (roleSetLicenseService.applyLicensePolicy as any).mockResolvedValue([]); + ( + collaborationLicenseService.applyLicensePolicy as any + ).mockResolvedValue([]); + + await service.applyLicensePolicy('space-1'); + + expect( + subspaceMock.license.entitlements.find( + (entry: any) => + entry.type === LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING + ) + ).toMatchObject({ enabled: false, limit: 0 }); + }); + }); }); }); diff --git a/src/domain/space/space/space.service.license.ts b/src/domain/space/space/space.service.license.ts index 883adfc36a..82346bdbf4 100644 --- a/src/domain/space/space/space.service.license.ts +++ b/src/domain/space/space/space.service.license.ts @@ -210,6 +210,19 @@ export class SpaceLicenseService { break; } + case LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING: { + const memoSigning = + await this.licenseEngineService.isEntitlementGranted( + LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + levelZeroSpaceAgent + ); + if (memoSigning) { + entitlement.limit = 1; + entitlement.enabled = true; + } + break; + } + default: throw new EntityNotInitializedException( `Unknown entitlement type for Space: ${entitlement.type}`, diff --git a/src/domain/space/space/space.service.spec.ts b/src/domain/space/space/space.service.spec.ts index d861e05cfc..65ce45985f 100644 --- a/src/domain/space/space/space.service.spec.ts +++ b/src/domain/space/space/space.service.spec.ts @@ -1063,6 +1063,11 @@ describe('SpaceService', () => { expect.objectContaining({ type: 'space-free' }), expect.objectContaining({ type: 'space-plus' }), expect.objectContaining({ type: 'space-premium' }), + expect.objectContaining({ + type: 'space-flag-memo-signing', + enabled: false, + limit: 0, + }), ]), }) ); diff --git a/src/domain/space/space/space.service.ts b/src/domain/space/space/space.service.ts index d9ba48e818..9cf6aae21c 100644 --- a/src/domain/space/space/space.service.ts +++ b/src/domain/space/space/space.service.ts @@ -474,6 +474,12 @@ export class SpaceService { limit: 0, enabled: true, }, + { + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + dataType: LicenseEntitlementDataType.FLAG, + limit: 0, + enabled: false, + }, ], }); } diff --git a/src/domain/template/template-content-space/template.content.space.service.spec.ts b/src/domain/template/template-content-space/template.content.space.service.spec.ts index 1dc22ec81c..00094d8299 100644 --- a/src/domain/template/template-content-space/template.content.space.service.spec.ts +++ b/src/domain/template/template-content-space/template.content.space.service.spec.ts @@ -480,6 +480,11 @@ describe('TemplateContentSpaceService', () => { enabled: true, limit: 0, }), + expect.objectContaining({ + type: 'space-flag-memo-signing', + enabled: false, + limit: 0, + }), ]), }) ); diff --git a/src/domain/template/template-content-space/template.content.space.service.ts b/src/domain/template/template-content-space/template.content.space.service.ts index af7902c64b..1025cd8f35 100644 --- a/src/domain/template/template-content-space/template.content.space.service.ts +++ b/src/domain/template/template-content-space/template.content.space.service.ts @@ -436,6 +436,12 @@ export class TemplateContentSpaceService { limit: 0, enabled: true, }, + { + type: LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING, + dataType: LicenseEntitlementDataType.FLAG, + limit: 0, + enabled: false, + }, ], }); } diff --git a/src/migrations/1788947200000-AddMemoSigningEntitlement.ts b/src/migrations/1788947200000-AddMemoSigningEntitlement.ts new file mode 100644 index 0000000000..d8e7b9625c --- /dev/null +++ b/src/migrations/1788947200000-AddMemoSigningEntitlement.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// Self-contained enum copies — the migration must not import from +// src/common/enums so future enum edits never change history. +enum LicensingCredentialBasedCredentialType { + SPACE_FEATURE_MEMO_SIGNING = 'space-feature-memo-signing', +} + +enum LicenseEntitlementType { + SPACE_FLAG_MEMO_SIGNING = 'space-flag-memo-signing', +} + +const CREDENTIAL_TYPE = + LicensingCredentialBasedCredentialType.SPACE_FEATURE_MEMO_SIGNING; +const ENTITLEMENT_TYPE = LicenseEntitlementType.SPACE_FLAG_MEMO_SIGNING; +const LICENSE_PLAN_NAME = 'SPACE_FEATURE_MEMO_SIGNING'; +const LICENSE_PLAN_SORT_ORDER = 110; +const CREDENTIAL_RULE_NAME = 'Space Memo Signing'; + +export class AddMemoSigningEntitlement1788947200000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Idempotent INSERT into license_plan. + const existingPlan = await queryRunner.query( + `SELECT id FROM license_plan WHERE name = $1`, + [LICENSE_PLAN_NAME] + ); + + if (existingPlan.length === 0) { + const frameworkResult = await queryRunner.query( + `SELECT id FROM licensing_framework LIMIT 1` + ); + if (frameworkResult.length === 0) { + throw new Error( + 'No licensing_framework row found; cannot insert license_plan' + ); + } + const licensingFrameworkId = frameworkResult[0].id; + + await queryRunner.query( + `INSERT INTO license_plan ( + id, "createdDate", "updatedDate", version, + name, enabled, "sortOrder", "pricePerMonth", + "isFree", "trialEnabled", "requiresPaymentMethod", + "requiresContactSupport", "licenseCredential", type, + "assignToNewOrganizationAccounts", "assignToNewUserAccounts", + "licensingFrameworkId" + ) + VALUES ( + $1, NOW(), NOW(), 1, + $2, true, $3, 0, + true, false, false, + true, $4, 'space-feature-flag', + false, false, + $5 + )`, + [ + randomUUID(), + LICENSE_PLAN_NAME, + LICENSE_PLAN_SORT_ORDER, + CREDENTIAL_TYPE, + licensingFrameworkId, + ] + ); + } + + // Idempotent append to license_policy.credentialRules. + const policyRows = await queryRunner.query( + `SELECT id, "credentialRules" FROM license_policy` + ); + for (const row of policyRows) { + const rules: Array<{ credentialType?: string }> = Array.isArray( + row.credentialRules + ) + ? row.credentialRules + : []; + if (rules.some(rule => rule?.credentialType === CREDENTIAL_TYPE)) + continue; + + const newRule = { + id: randomUUID(), + credentialType: CREDENTIAL_TYPE, + grantedEntitlements: [{ type: ENTITLEMENT_TYPE, limit: 1 }], + name: CREDENTIAL_RULE_NAME, + }; + + await queryRunner.query( + `UPDATE license_policy + SET "credentialRules" = COALESCE("credentialRules", '[]'::jsonb) || $1::jsonb + WHERE id = $2`, + [JSON.stringify([newRule]), row.id] + ); + } + } + + // Rollback removes the feature-owned plan and credential rules. Unrelated + // plans, rules, and credential rows are left untouched. Safe to run repeatedly. + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM license_plan WHERE name = $1`, [ + LICENSE_PLAN_NAME, + ]); + + const policyRows = await queryRunner.query( + `SELECT id, "credentialRules" FROM license_policy` + ); + for (const row of policyRows) { + const rules: Array<{ credentialType?: string }> = Array.isArray( + row.credentialRules + ) + ? row.credentialRules + : []; + const filtered = rules.filter( + rule => rule?.credentialType !== CREDENTIAL_TYPE + ); + if (filtered.length === rules.length) continue; + + await queryRunner.query( + `UPDATE license_policy SET "credentialRules" = $1::jsonb WHERE id = $2`, + [JSON.stringify(filtered), row.id] + ); + } + } +} diff --git a/src/migrations/1788947200100-BackfillMemoSigningEntitlement.ts b/src/migrations/1788947200100-BackfillMemoSigningEntitlement.ts new file mode 100644 index 0000000000..7a9e084259 --- /dev/null +++ b/src/migrations/1788947200100-BackfillMemoSigningEntitlement.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// Self-contained constants — the migration must not import from +// src/common/enums so future enum edits never change history. +const ENTITLEMENT_TYPE = 'space-flag-memo-signing'; +const ENTITLEMENT_DATA_TYPE = 'flag'; + +// Space and collaboration licenses persist entitlement rows. Template-content- +// space licenses are transient and rebuilt from code, so there is no row to backfill. +const BACKFILL_TARGETS: Array<{ licenseType: string; enabled: boolean }> = [ + { licenseType: 'space', enabled: false }, + { licenseType: 'collaboration', enabled: false }, +]; + +export class BackfillMemoSigningEntitlement1788947200100 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const { licenseType, enabled } of BACKFILL_TARGETS) { + await queryRunner.query( + `INSERT INTO license_entitlement + (id, "createdDate", "updatedDate", version, + type, "dataType", "limit", enabled, "licenseId") + SELECT + uuid_generate_v4(), NOW(), NOW(), 1, + $1::varchar, $2::varchar, 0, $3, l.id + FROM license l + WHERE l.type = $4::varchar + AND NOT EXISTS ( + SELECT 1 FROM license_entitlement le + WHERE le."licenseId" = l.id + AND le.type = $1::varchar + )`, + [ENTITLEMENT_TYPE, ENTITLEMENT_DATA_TYPE, enabled, licenseType] + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Removes every row of this newly introduced, feature-owned entitlement + // type. Transient template-content-space licenses have no persisted rows. + await queryRunner.query( + `DELETE FROM license_entitlement WHERE type = $1`, + [ENTITLEMENT_TYPE] + ); + } +} diff --git a/src/migrations/__tests__/1788947200000-AddMemoSigningEntitlement.spec.ts b/src/migrations/__tests__/1788947200000-AddMemoSigningEntitlement.spec.ts new file mode 100644 index 0000000000..fb09da5aab --- /dev/null +++ b/src/migrations/__tests__/1788947200000-AddMemoSigningEntitlement.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AddMemoSigningEntitlement1788947200000 } from '../1788947200000-AddMemoSigningEntitlement'; + +describe('AddMemoSigningEntitlement migration', () => { + it('adds the admin plan and policy rule once', async () => { + const queryRunner = { + query: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'framework-1' }]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([{ id: 'policy-1', credentialRules: [] }]) + .mockResolvedValueOnce(undefined), + }; + + await new AddMemoSigningEntitlement1788947200000().up( + queryRunner as any + ); + + expect(queryRunner.query).toHaveBeenCalledTimes(5); + expect(queryRunner.query.mock.calls[2][0]).toMatch( + /INSERT INTO license_plan/ + ); + expect(queryRunner.query.mock.calls[2][1]).toEqual([ + expect.any(String), + 'SPACE_FEATURE_MEMO_SIGNING', + 110, + 'space-feature-memo-signing', + 'framework-1', + ]); + expect(queryRunner.query.mock.calls[4][0]).toMatch( + /UPDATE license_policy/ + ); + expect(JSON.parse(queryRunner.query.mock.calls[4][1][0])).toEqual([ + { + id: expect.any(String), + credentialType: 'space-feature-memo-signing', + grantedEntitlements: [ + { type: 'space-flag-memo-signing', limit: 1 }, + ], + name: 'Space Memo Signing', + }, + ]); + }); + + it('does not duplicate an existing plan or policy rule', async () => { + const queryRunner = { + query: vi + .fn() + .mockResolvedValueOnce([{ id: 'plan-1' }]) + .mockResolvedValueOnce([ + { + id: 'policy-1', + credentialRules: [ + { credentialType: 'space-feature-memo-signing' }, + ], + }, + ]), + }; + + await new AddMemoSigningEntitlement1788947200000().up( + queryRunner as any + ); + + expect(queryRunner.query).toHaveBeenCalledTimes(2); + }); + + it('removes only the memo-signing plan and policy rule', async () => { + const queryRunner = { + query: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([ + { + id: 'policy-1', + credentialRules: [ + { credentialType: 'space-feature-save-as-template' }, + { credentialType: 'space-feature-memo-signing' }, + ], + }, + ]) + .mockResolvedValueOnce(undefined), + }; + + await new AddMemoSigningEntitlement1788947200000().down( + queryRunner as any + ); + + expect(queryRunner.query.mock.calls[0]).toEqual([ + expect.stringMatching(/DELETE FROM license_plan/), + ['SPACE_FEATURE_MEMO_SIGNING'], + ]); + expect(JSON.parse(queryRunner.query.mock.calls[2][1][0])).toEqual([ + { credentialType: 'space-feature-save-as-template' }, + ]); + }); +}); diff --git a/src/migrations/__tests__/1788947200100-BackfillMemoSigningEntitlement.spec.ts b/src/migrations/__tests__/1788947200100-BackfillMemoSigningEntitlement.spec.ts new file mode 100644 index 0000000000..460c5ecf94 --- /dev/null +++ b/src/migrations/__tests__/1788947200100-BackfillMemoSigningEntitlement.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BackfillMemoSigningEntitlement1788947200100 } from '../1788947200100-BackfillMemoSigningEntitlement'; + +describe('BackfillMemoSigningEntitlement migration', () => { + it('adds a disabled flag to existing space and collaboration licenses', async () => { + const queryRunner = { query: vi.fn().mockResolvedValue(undefined) }; + + await new BackfillMemoSigningEntitlement1788947200100().up( + queryRunner as any + ); + + expect(queryRunner.query).toHaveBeenCalledTimes(2); + expect(queryRunner.query.mock.calls.map(call => call[1])).toEqual([ + ['space-flag-memo-signing', 'flag', false, 'space'], + ['space-flag-memo-signing', 'flag', false, 'collaboration'], + ]); + for (const [sql] of queryRunner.query.mock.calls) { + expect(sql).toMatch(/INSERT INTO license_entitlement/); + expect(sql).toMatch(/NOT EXISTS/); + } + }); + + it('removes only the memo-signing entitlement', async () => { + const queryRunner = { query: vi.fn().mockResolvedValue(undefined) }; + + await new BackfillMemoSigningEntitlement1788947200100().down( + queryRunner as any + ); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringMatching(/DELETE FROM license_entitlement/), + ['space-flag-memo-signing'] + ); + }); +}); diff --git a/test/integration/content-signing/signing-attempt.postgres.spec.ts b/test/integration/content-signing/signing-attempt.postgres.spec.ts index 544fe37a53..4e41730f27 100644 --- a/test/integration/content-signing/signing-attempt.postgres.spec.ts +++ b/test/integration/content-signing/signing-attempt.postgres.spec.ts @@ -694,7 +694,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -778,7 +780,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -868,7 +872,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -955,7 +961,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -1029,7 +1037,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -1287,7 +1297,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { {} as any, {} as any, {} as any, - { error: vi.fn() } as any + { error: vi.fn() } as any, + services.communityResolver, + services.license ); const actor = Object.assign(new ActorContext(), { actorID: UUIDS.actor, @@ -1455,7 +1467,9 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { services.bucket, services.documentAuthorization, services.document, - logger + logger, + services.communityResolver, + services.license ); return { actor, @@ -1542,6 +1556,12 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { const lifecycle = { publishDocumentDeleted: vi.fn().mockResolvedValue(undefined), }; + const communityResolver = { + getCollaborationLicenseFromMemoOrFail: vi.fn().mockResolvedValue({}), + } as any; + const license = { + isEntitlementEnabledOrFail: vi.fn(), + } as any; const memo = new MemoService( logger, source.getRepository(MemoFixture) as any, @@ -1579,9 +1599,11 @@ describeRealServices('SigningAttempt — PostgreSQL and file-service', () => { authorization, bucket, bucketEntity, + communityResolver, document, documentAuthorization, fileAdapter, + license, memo, memoEntity, profile, From 3a17566f3af45432ef36558bc95ca4a280919ea7 Mon Sep 17 00:00:00 2001 From: Alkemio Infrastructure Bot Date: Wed, 9 Sep 2026 14:00:00 +0300 Subject: [PATCH 07/11] Merge pull request #6479 from alkem-io/schema-baseline/34341849628 chore: update schema baseline --- schema-baseline.graphql | 68 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/schema-baseline.graphql b/schema-baseline.graphql index 46d0f8c206..2ef0ae14a8 100644 --- a/schema-baseline.graphql +++ b/schema-baseline.graphql @@ -2705,7 +2705,7 @@ type CreateInnovationFlowStateSettingsData { """ showPublishDetails: Boolean """ - Optional. Ordered sidebar widgets; defaults to [INTENT, CREATE_POST, APPLICATION_BUTTON, INDEX] when omitted. + Optional. Ordered sidebar widgets; defaults to [INTENT, CREATE_POST, APPLICATION_BUTTON, SEARCH, INDEX] when omitted. """ sidebar: [SidebarWidget!] """ @@ -2726,7 +2726,7 @@ input CreateInnovationFlowStateSettingsInput { """ showPublishDetails: Boolean """ - Optional. Ordered sidebar widgets; defaults to [INTENT, CREATE_POST, APPLICATION_BUTTON, INDEX] when omitted. + Optional. Ordered sidebar widgets; defaults to [INTENT, CREATE_POST, APPLICATION_BUTTON, SEARCH, INDEX] when omitted. """ sidebar: [SidebarWidget!] """ @@ -4927,10 +4927,57 @@ type Memo { nameID: NameID! """The Profile for this Memo.""" profile: Profile! + """Signed copies of this Memo visible to readers of the Memo.""" + signatures: [MemoSignature!]! """The date at which the entity was last updated.""" updatedDate: DateTime! } +type MemoSignature { + """The Alkemio user who initiated this signed copy.""" + actor: User + """The date at which the entity was created.""" + createdDate: DateTime! + """The immutable PDF produced for this signed copy.""" + document: Document + """The ID of the entity""" + id: UUID! + """The terminal outcome of this Memo signing attempt.""" + status: SigningAttemptStatus! + """The date at which the entity was last updated.""" + updatedDate: DateTime! +} + +enum MemoSignatureVerificationStatus { + INVALID + UNAVAILABLE + VERIFIED +} + +input MemoSignatureVerifyInput { + """The signed Memo attempt to verify.""" + attemptID: UUID! +} + +input MemoSigningContinueInput { + """The prepared signing attempt to start.""" + attemptID: UUID! +} + +type MemoSigningContinueResult { + authorizeUrl: String! +} + +input MemoSigningPrepareInput { + """The Memo to prepare for signing.""" + memoID: UUID! +} + +type MemoSigningPrepareResult { + attemptId: UUID! + previewUrl: String! +} + """A message that was sent in a chat room""" type Message { """The id for the message event.""" @@ -5260,6 +5307,8 @@ type Mutation { castPollVote(voteData: CastPollVoteInput!): Poll! """Deletes collections nameID-...""" cleanupCollections: MigrateEmbeddings! + """Starts signing the prepared Memo copy.""" + continueMemoSigning(signingData: MemoSigningContinueInput!): MemoSigningContinueResult! """Move an L1 Space up in the hierarchy, to be a L0 Space.""" convertSpaceL1ToSpaceL0(convertData: ConvertSpaceL1ToSpaceL0Input!): Space! """ @@ -5478,6 +5527,8 @@ type Mutation { Moves a task to another column on its Tasks board. Authorized as MOVE_TASK on the parent Callout, so a board member can move any task. """ moveTaskToColumn(moveData: MoveTaskToColumnInput!): CalloutContribution! + """Prepares an exact PDF preview for signing the specified Memo.""" + prepareMemoSigning(signingData: MemoSigningPrepareInput!): MemoSigningPrepareResult! """Refresh the Bodies of Knowledge on All VCs""" refreshAllBodiesOfKnowledge: Boolean! """ @@ -6920,6 +6971,8 @@ type Query { rolesVirtualContributor(rolesData: RolesActorInput!): ActorRoles! """Search the platform for terms supplied""" search(searchData: SearchInput!): ISearchResults! + """A Memo signing attempt belonging to the current actor.""" + signingAttempt(ID: UUID!): MemoSignature! """ The Spaces on this platform; If accessed through an Innovation Hub will return ONLY the Spaces defined in it. """ @@ -6982,6 +7035,8 @@ type Query { Returns the VAPID public key needed by clients to subscribe to push notifications. Returns null if push notifications are not enabled on this server. """ vapidPublicKey: String + """Checks the stored integrity of a signed Memo copy.""" + verifyMemoSignature(verificationData: MemoSignatureVerifyInput!): MemoSignatureVerificationStatus! """A particular VirtualContributor""" virtualContributor(ID: UUID!): VirtualContributor! """ @@ -7942,11 +7997,20 @@ enum SidebarWidget { GUIDELINES INDEX INTENT + SEARCH SUBSPACE_LINKS UPDATES VIRTUAL_CONTRIBUTORS } +enum SigningAttemptStatus { + CANCELLED + EXPIRED + FAILED + PENDING + SIGNED +} + type Space implements ActorFull { """About this space.""" about: SpaceAbout! From 90ccfa8bb8f59044a474f5fe19cd237e99a8b698 Mon Sep 17 00:00:00 2001 From: Alkemio Infrastructure Bot Date: Wed, 9 Sep 2026 14:00:47 +0300 Subject: [PATCH 08/11] chore: update schema baseline (#6480) Co-authored-by: Bobby Kolev --- schema-baseline.graphql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schema-baseline.graphql b/schema-baseline.graphql index 2ef0ae14a8..567c83c7e0 100644 --- a/schema-baseline.graphql +++ b/schema-baseline.graphql @@ -3290,6 +3290,7 @@ enum CredentialType { PLATFORM_OPERATIONS_ADMIN SPACE_ADMIN SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS @@ -4422,6 +4423,7 @@ enum LicenseEntitlementType { ACCOUNT_SPACE_PREMIUM ACCOUNT_VIRTUAL_CONTRIBUTOR SPACE_FLAG_MEMO_MULTI_USER + SPACE_FLAG_MEMO_SIGNING SPACE_FLAG_OFFICE_DOCUMENTS SPACE_FLAG_SAVE_AS_TEMPLATE SPACE_FLAG_VIRTUAL_CONTRIBUTOR_ACCESS @@ -4504,6 +4506,7 @@ type Licensing { enum LicensingCredentialBasedCredentialType { ACCOUNT_LICENSE_PLUS SPACE_FEATURE_MEMO_MULTI_USER + SPACE_FEATURE_MEMO_SIGNING SPACE_FEATURE_OFFICE_DOCUMENTS SPACE_FEATURE_SAVE_AS_TEMPLATE SPACE_FEATURE_VIRTUAL_CONTRIBUTORS From e79e7ec97ff514d5564fb5c4f97bd81bfdbe636a Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Wed, 9 Sep 2026 14:01:22 +0200 Subject: [PATCH 09/11] feat(signing): map Cleverbase certificate subject (#6482) * test(signing): cover certificate subject mapping RED: the existing reader still returns the OIDC provider subject when certificate metadata is present, including malformed or unusable claims. * feat(signing): map Cleverbase certificate subject Prefer the subject serialNumber RDN from the mapped signing-certificate PEM while retaining the linked-provider prerequisite. Fall back to the provider subject only when the metadata claim is absent; present invalid claims fail closed. * test(signing): cover stored ID token mapping RED: the existing metadata_admin implementation falls back for seven token-path cases. The approved design reads the already declassified initial ID token from the same Kratos response. * feat(signing): map stored Cleverbase ID token Kratos validates and stores the initial ID token when the OIDC provider is linked, then declassifies it on the existing admin identity response. Read the signing certificate from that token payload without a second Kratos call; preserve fallback only for absent token or claim and fail closed for malformed present data. * test(signing): cover token fallback diagnostics * fix(signing): decode stored Cleverbase ID token safely --- .../kratos/kratos.service.spec.ts | 218 +++++++++++++++++- .../infrastructure/kratos/kratos.service.ts | 64 ++++- 2 files changed, 269 insertions(+), 13 deletions(-) diff --git a/src/services/infrastructure/kratos/kratos.service.spec.ts b/src/services/infrastructure/kratos/kratos.service.spec.ts index 78f22629ac..be560c13da 100644 --- a/src/services/infrastructure/kratos/kratos.service.spec.ts +++ b/src/services/infrastructure/kratos/kratos.service.spec.ts @@ -1,16 +1,60 @@ +import { X509Certificate } from 'node:crypto'; +import { LogContext } from '@common/enums'; import { AuthenticationType } from '@common/enums/authentication.type'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import type { Identity } from '@ory/kratos-client'; import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; -import { KratosService } from './kratos.service'; +import { + CLEVERBASE_SIGNING_CERTIFICATE_CLAIM, + KratosService, +} from './kratos.service'; + +const SYNTHETIC_SIGNING_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIDWjCCAkICAwZ5MjANBgkqhkiG9w0BAQsFADByMQswCQYDVQQGEwJOTDEVMBMG +A1UECgwMQWxrZW1pbyBUZXN0MR0wGwYDVQQFExRIQi1TWU5USEVUSUMtTUFQUElO +RzEtMCsGA1UEAwwkQ2xldmVyYmFzZSBTeW50aGV0aWMgU2lnbmluZyBGaXh0dXJl +MB4XDTI2MDkwOTExMjgyN1oXDTM2MDkwNjExMjgyN1owcjELMAkGA1UEBhMCTkwx +FTATBgNVBAoMDEFsa2VtaW8gVGVzdDEdMBsGA1UEBRMUSEItU1lOVEhFVElDLU1B +UFBJTkcxLTArBgNVBAMMJENsZXZlcmJhc2UgU3ludGhldGljIFNpZ25pbmcgRml4 +dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALNWCz0VvupVRssl +Ie+gtx1JXPgYoOvjLLDiidfVBg4OmRlsmbUCreIU7I3f2V6aTMZMu+V7zsAiMfhk +SDbQ1MlYdTOurozOsaDStKXc6U0hyHdmmfgLrHyS7AtUdf1C1X7NkVfx6WcOTxPN +gJ6ETEAKCgVhI/AVUB2PodYohfgWMzg2Q6WlxlJuppY/BM4Yt4ak6kctWznOlgbY +TpZ1cOEctwKkjzeHfSFOJ9W9jrXPbjeLNnrpUqqbnTGqdJrS1RFVOBkXKbPN4rec +GDS5jswIi7sycR2VDuXz5Bc6QOMYUhSO6AJbekbls4894aawiK66PVePv7cLGk/a +r19IvwECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAIwXspPD6MtwaRydrPNAm4ptp +OT1JTPI6/gO93V+rINJw0pUNul7bdbrY0V0J3YQeEOdj/NaZmCMFPCoMy2zRJNWa +jHAmckA8pRxGDLHSxG+r/YnToTCIL/U+23J1mA09+WziJ2gfyjcFgPO54IPxFd9p +CWxwC+UrF0us1oSwr2bVeSEMVJuCDRVwrTkCxfVxcHH1FvIVYqJ5bIF3ZMDgbsYj +qiYHn/rrKDOL6ZmZqsNHUXXa1pQMb5c5zdXfeSWhbVFKBljJDMi323tCPkn0i5VJ +Fl690EDNrENZPXRU2sgWhVsc2L8Pjpu2+9NeTem+pdNrLRt+XGrLwBIjM++3hQ== +-----END CERTIFICATE-----`; + +const createSyntheticIDToken = (claims: Record): string => + [ + Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString( + 'base64url' + ), + Buffer.from(JSON.stringify(claims)).toString('base64url'), + 'synthetic-signature', + ].join('.'); describe('KratosService', () => { let service: KratosService; + const warn = MockWinstonProvider.useValue.warn as ReturnType; + const expectRedactedSigningIdentityWarning = () => { + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'Stored Cleverbase signing identity is unavailable for identity kratos-identity.', + LogContext.KRATOS + ); + }; beforeEach(async () => { vi.restoreAllMocks(); + warn.mockClear(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -509,25 +553,175 @@ describe('KratosService', () => { }); describe('getCleverbaseSubject', () => { - it('reads OIDC credentials and returns only the provider subject', async () => { + const createIdentity = (initialIDToken?: unknown) => + ({ + credentials: { + oidc: { + identifiers: [ + 'linkedin:elsewhere', + 'cleverbase:subject-from-provider', + ], + config: { + providers: [ + { + provider: 'linkedin', + subject: 'elsewhere', + initial_id_token: createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: + SYNTHETIC_SIGNING_CERTIFICATE, + }), + }, + { + provider: 'cleverbase', + subject: 'subject-from-provider', + ...(initialIDToken === undefined + ? {} + : { initial_id_token: initialIDToken }), + }, + ], + }, + }, + }, + }) as any; + + it('returns the signing certificate subject serialNumber instead of its certificate serial or provider subject', async () => { const getIdentity = vi .spyOn(service, 'getIdentityById') - .mockResolvedValue({ - credentials: { - oidc: { - identifiers: [ - 'linkedin:elsewhere', - 'cleverbase:subject-from-provider', + .mockResolvedValue( + createIdentity( + createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: + SYNTHETIC_SIGNING_CERTIFICATE, + }) + ) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBe('HB-SYNTHETIC-MAPPING'); + expect(getIdentity).toHaveBeenCalledWith('kratos-identity', ['oidc']); + }); + + it.each([ + ['the Cleverbase provider has no initial ID token', undefined], + [ + 'the initial ID token has no signing certificate claim', + createSyntheticIDToken({ sub: 'subject-from-provider' }), + ], + ])('falls back to the provider subject when %s', async (_label, token) => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue( + createIdentity(token) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBe('subject-from-provider'); + }); + + it.each([ + ['empty', ''], + ['whitespace-only', ' '], + ])('falls back and logs a redacted warning when the initial ID token is %s', async (_label, token) => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue( + createIdentity(token) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBe('subject-from-provider'); + expectRedactedSigningIdentityWarning(); + }); + + it.each([ + ['is malformed', 'not-a-jwt'], + [ + 'has a malformed payload', + ['synthetic-header', 'not-base64url!', 'synthetic-signature'].join('.'), + ], + [ + 'has a non-JSON payload', + [ + 'synthetic-header', + Buffer.from('not-json').toString('base64url'), + 'synthetic-signature', + ].join('.'), + ], + [ + 'has a non-string signing certificate claim', + createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: 123, + }), + ], + [ + 'has a malformed signing certificate claim', + createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: 'not-a-certificate', + }), + ], + ])('does not fall back when the initial ID token %s', async (_label, token) => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue( + createIdentity(token) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBeUndefined(); + expectRedactedSigningIdentityWarning(); + }); + + it('does not fall back when the signing certificate has no subject serialNumber', async () => { + vi.spyOn(X509Certificate.prototype, 'toLegacyObject').mockReturnValue({ + subject: { CN: 'Synthetic fixture without subject serialNumber' }, + } as any); + vi.spyOn(service, 'getIdentityById').mockResolvedValue( + createIdentity( + createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: + SYNTHETIC_SIGNING_CERTIFICATE, + }) + ) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBeUndefined(); + expectRedactedSigningIdentityWarning(); + }); + + it('does not use a signing certificate from another OIDC provider', async () => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue( + createIdentity(undefined) + ); + + await expect( + service.getCleverbaseSubject('kratos-identity') + ).resolves.toBe('subject-from-provider'); + }); + + it('requires the linked Cleverbase provider even when an OIDC token contains the signing certificate', async () => { + vi.spyOn(service, 'getIdentityById').mockResolvedValue({ + credentials: { + oidc: { + identifiers: ['github:subject'], + config: { + providers: [ + { + provider: 'github', + subject: 'subject', + initial_id_token: createSyntheticIDToken({ + [CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]: + SYNTHETIC_SIGNING_CERTIFICATE, + }), + }, ], }, }, - traits: { serialNumber: 'NOT-THE-SUBJECT' }, - } as any); + }, + } as any); await expect( service.getCleverbaseSubject('kratos-identity') - ).resolves.toBe('subject-from-provider'); - expect(getIdentity).toHaveBeenCalledWith('kratos-identity', ['oidc']); + ).resolves.toBeUndefined(); }); it.each([ diff --git a/src/services/infrastructure/kratos/kratos.service.ts b/src/services/infrastructure/kratos/kratos.service.ts index b84abc1088..158a37bc24 100644 --- a/src/services/infrastructure/kratos/kratos.service.ts +++ b/src/services/infrastructure/kratos/kratos.service.ts @@ -1,3 +1,4 @@ +import { X509Certificate } from 'node:crypto'; import { LogContext } from '@common/enums'; import { AuthenticationType } from '@common/enums/authentication.type'; import { @@ -14,11 +15,16 @@ import { type GetIdentityIncludeCredentialEnum, Identity, IdentityApi, + type IdentityCredentialsOidc, } from '@ory/kratos-client'; import { AlkemioConfig } from '@src/types'; +import { decodeJwt } from 'jose'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { OryDefaultIdentitySchema } from './types/ory.default.identity.schema'; +export const CLEVERBASE_SIGNING_CERTIFICATE_CLAIM = + 'com.cleverbase.signing_certificate'; + /** * The `KratosService` class provides methods to interact with the Ory Kratos identity management system: * identity lookup/mutation, admin session invalidation, and the admin service-account login flow. @@ -421,7 +427,63 @@ export class KratosService { const identifier = identity?.credentials?.oidc?.identifiers?.find(value => value.startsWith(prefix) ); - return identifier?.slice(prefix.length) || undefined; + const providerSubject = identifier?.slice(prefix.length) || undefined; + if (!providerSubject) { + return undefined; + } + + const oidcConfig = identity?.credentials?.oidc?.config as + | IdentityCredentialsOidc + | undefined; + const initialIDToken = oidcConfig?.providers?.find( + provider => provider.provider === AuthenticationType.CLEVERBASE + )?.initial_id_token; + if (initialIDToken === undefined) { + return providerSubject; + } + if (initialIDToken.trim().length === 0) { + this.warnUnavailableCleverbaseSigningIdentity(identityId); + return providerSubject; + } + + let payload: ReturnType; + try { + payload = decodeJwt(initialIDToken); + } catch { + this.warnUnavailableCleverbaseSigningIdentity(identityId); + return undefined; + } + + const signingCertificate = payload[CLEVERBASE_SIGNING_CERTIFICATE_CLAIM]; + if (signingCertificate === undefined) { + return providerSubject; + } + if (typeof signingCertificate !== 'string') { + this.warnUnavailableCleverbaseSigningIdentity(identityId); + return undefined; + } + + try { + const subject = new X509Certificate(signingCertificate).toLegacyObject() + .subject as unknown as Record; + const serialNumber = subject.serialNumber; + if (typeof serialNumber === 'string' && serialNumber.length > 0) { + return serialNumber; + } + } catch { + this.warnUnavailableCleverbaseSigningIdentity(identityId); + return undefined; + } + + this.warnUnavailableCleverbaseSigningIdentity(identityId); + return undefined; + } + + private warnUnavailableCleverbaseSigningIdentity(identityId: string): void { + this.logger.warn( + `Stored Cleverbase signing identity is unavailable for identity ${identityId}.`, + LogContext.KRATOS + ); } /** From 4ed49fc6599172439c6bd84a3d975b2e56e9da22 Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Fri, 11 Sep 2026 08:35:35 +0200 Subject: [PATCH 10/11] fix(memo): preserve block structure in signing PDFs (#6490) * test(memo): reproduce signing PDF structure loss * fix(memo): preserve block structure in signing PDFs * fix(memo): preserve blank paragraph representation * fix(memo): preserve mixed nested list hierarchy --- src/domain/common/memo/conversion/const.ts | 3 +- .../memo/conversion/yjs.state.to.markdown.ts | 97 ++++--- .../common/memo/memo.pdf.renderer.spec.ts | 255 ++++++++++++++++++ src/domain/common/memo/memo.pdf.renderer.ts | 5 + 4 files changed, 308 insertions(+), 52 deletions(-) diff --git a/src/domain/common/memo/conversion/const.ts b/src/domain/common/memo/conversion/const.ts index e9bc153c15..6c20312811 100644 --- a/src/domain/common/memo/conversion/const.ts +++ b/src/domain/common/memo/conversion/const.ts @@ -1 +1,2 @@ -export const newLineReplacement = '\n\n\u00A0\n\n'; +export const blankLineReplacement = '\u00A0'; +export const newLineReplacement = `\n\n${blankLineReplacement}\n\n`; diff --git a/src/domain/common/memo/conversion/yjs.state.to.markdown.ts b/src/domain/common/memo/conversion/yjs.state.to.markdown.ts index f0911bd718..2c824ccafa 100644 --- a/src/domain/common/memo/conversion/yjs.state.to.markdown.ts +++ b/src/domain/common/memo/conversion/yjs.state.to.markdown.ts @@ -3,9 +3,9 @@ import Highlight from '@tiptap/extension-highlight'; import StarterKit from '@tiptap/starter-kit'; import { renderToMarkdown } from '@tiptap/static-renderer'; import { yXmlFragmentToProseMirrorRootNode } from '@tiptap/y-tiptap'; -import { Fragment, Node as ProseMirrorNode } from 'prosemirror-model'; +import { Node as ProseMirrorNode } from 'prosemirror-model'; import * as Y from 'yjs'; -import { newLineReplacement } from './const'; +import { blankLineReplacement } from './const'; import { Iframe } from './Iframe'; import { ImageExtension } from './image.extension'; @@ -32,54 +32,33 @@ export const yjsStateToMarkdown = (state: Buffer) => { } finally { doc.destroy(); } - // Build a new array of child nodes, replacing empty paragraphs with paragraphs containing a non-breaking space - // This ensures that empty paragraphs are preserved in the markdown output - // (ProseMirror and TipTap tend to ignore completely empty paragraphs) - const newContent: ProseMirrorNode[] = []; - // use the built-in forEach method of the ProseMirror Fragment - pmDoc.content.forEach(child => { - // we only target paragraphs without content - if (child.type.name !== 'paragraph') { - newContent.push(child); - return; - } - - if (child.content.size > 0) { - newContent.push(child); - return; - } - // Overwrite this paragraph with a new one, containing a non-breaking space - const newNode = markdownSchema.nodes.paragraph.create( - null, - markdownSchema.text(newLineReplacement) - ); - newContent.push(newNode); - }); - const newDoc = pmDoc.copy(Fragment.fromArray(newContent)); + if ( + pmDoc.childCount === 1 && + pmDoc.firstChild?.type.name === 'paragraph' && + pmDoc.firstChild.content.size === 0 + ) + return ''; // Manually serialize with proper indentation by traversing the tree const serializeNode = ( node: ProseMirrorNode, - depth = 0, + indent = '', parentType = '' ): string => { - // Ordered lists need 4 spaces per level, bullet lists need 2 - const indentSize = parentType === 'orderedList' ? 4 : 2; - const indent = ' '.repeat(depth * indentSize); - switch (node.type.name) { case 'bulletList': case 'orderedList': { - let listOutput = ''; + const items: string[] = []; node.content.forEach(child => { - listOutput += serializeNode(child, depth, node.type.name); + items.push(serializeNode(child, indent, node.type.name)); }); - return listOutput; + return items.join('\n'); } case 'listItem': { - let itemText = ''; - let nestedLists = ''; + const bullet = parentType === 'orderedList' ? '1.' : '-'; + const continuationIndent = `${indent}${' '.repeat(bullet.length + 1)}`; + const blocks: { nested: boolean; value: string }[] = []; node.content.forEach(child => { if (child.type.name === 'paragraph') { @@ -87,27 +66,42 @@ export const yjsStateToMarkdown = (state: Buffer) => { extensions: [StarterKit, ImageExtension, Highlight, Iframe], content: child, }).trim(); - itemText += paragraphMarkdown; + blocks.push({ + nested: false, + value: paragraphMarkdown || blankLineReplacement, + }); } else if ( child.type.name === 'bulletList' || child.type.name === 'orderedList' ) { - nestedLists += serializeNode(child, depth + 1, child.type.name); + blocks.push({ + nested: true, + value: serializeNode(child, continuationIndent, child.type.name), + }); } else { // Handle other node types (images, code blocks, etc.) using renderToMarkdown const otherContent = renderToMarkdown({ extensions: [StarterKit, ImageExtension, Highlight, Iframe], content: child, }).trim(); - itemText += otherContent; + blocks.push({ nested: false, value: otherContent }); } }); - const bullet = parentType === 'orderedList' ? '1.' : '-'; - const mainLine = itemText - ? `${indent}${bullet} ${itemText}\n` - : `${indent}${bullet}\n`; - return mainLine + nestedLists; + const indentContinuation = (value: string) => + value + .split('\n') + .map(line => `${continuationIndent}${line}`) + .join('\n'); + const [firstBlock, ...remainingBlocks] = blocks; + let itemOutput = `${indent}${bullet}`; + if (firstBlock) + itemOutput += firstBlock.nested + ? `\n${firstBlock.value}` + : ` ${firstBlock.value.replaceAll('\n', `\n${continuationIndent}`)}`; + for (const block of remainingBlocks) + itemOutput += `\n\n${block.nested ? block.value : indentContinuation(block.value)}`; + return itemOutput; } case 'table': { @@ -164,10 +158,12 @@ export const yjsStateToMarkdown = (state: Buffer) => { } }); - return tableOutput + '\n'; + return tableOutput.trimEnd(); } case 'paragraph': { + if (node.content.size === 0) return blankLineReplacement; + // Check if paragraph only contains literal "
" text (empty line placeholder) const isLiteralBrPlaceholder = node.content.childCount === 1 && @@ -175,8 +171,7 @@ export const yjsStateToMarkdown = (state: Buffer) => { node.content.firstChild?.text === '
'; if (isLiteralBrPlaceholder) { - // Convert to an empty line in markdown - return '\n'; + return blankLineReplacement; } // Use TipTap's default for regular paragraphs @@ -195,10 +190,10 @@ export const yjsStateToMarkdown = (state: Buffer) => { } }; - let result = ''; - newDoc.content.forEach(child => { - result += serializeNode(child); + const blocks: string[] = []; + pmDoc.content.forEach(child => { + blocks.push(serializeNode(child)); }); - return result.trim(); + return blocks.join('\n\n').trim(); }; diff --git a/src/domain/common/memo/memo.pdf.renderer.spec.ts b/src/domain/common/memo/memo.pdf.renderer.spec.ts index c6c3b8784b..fb2bc0df7c 100644 --- a/src/domain/common/memo/memo.pdf.renderer.spec.ts +++ b/src/domain/common/memo/memo.pdf.renderer.spec.ts @@ -4,12 +4,16 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; import { ActorContext } from '@core/actor-context/actor.context'; +import { prosemirrorToYDoc } from '@tiptap/y-tiptap'; import { JSDOM } from 'jsdom'; import MarkdownIt from 'markdown-it'; import { parseOffice } from 'officeparser'; import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import sharp from 'sharp'; +import * as Y from 'yjs'; import { markdownToYjsV2State, yjsStateToMarkdown } from './conversion'; +import { markdownSchema } from './conversion/markdown.schema'; import { MemoPdfRenderer } from './memo.pdf.renderer'; const pdfjsRequire = createRequire(require.resolve('pdfjs-dist/package.json')); @@ -29,6 +33,123 @@ const fonts = require('pdfmake/fonts/Roboto') as { Roboto: Record; }; +type PdfMakeNode = Record; + +const collectPdfMakeNodes = ( + value: unknown, + predicate: (node: PdfMakeNode) => boolean +): PdfMakeNode[] => { + if (!value || typeof value !== 'object') return []; + const node = value as PdfMakeNode; + const matches = predicate(node) ? [node] : []; + return [ + ...matches, + ...Object.values(node).flatMap(child => + collectPdfMakeNodes(child, predicate) + ), + ]; +}; + +const structuredMemoState = (): Buffer => { + const paragraph = (text = '') => + markdownSchema.nodes.paragraph.create( + null, + text ? markdownSchema.text(text) : undefined + ); + const listItem = markdownSchema.nodes.listItem.create(null, [ + paragraph('First paragraph inside item.'), + paragraph('Second paragraph inside same item.'), + paragraph('Third paragraph inside same item.'), + markdownSchema.nodes.bulletList.create(null, [ + markdownSchema.nodes.listItem.create(null, [ + paragraph('Nested child'), + markdownSchema.nodes.bulletList.create(null, [ + markdownSchema.nodes.listItem.create(null, [paragraph('Grandchild')]), + ]), + ]), + ]), + ]); + const tableRows = Array.from({ length: 8 }, (_, rowIndex) => { + const cellType = + rowIndex === 0 + ? markdownSchema.nodes.tableHeader + : markdownSchema.nodes.tableCell; + return markdownSchema.nodes.tableRow.create( + null, + Array.from({ length: 3 }, (_, columnIndex) => + cellType.create( + null, + paragraph( + rowIndex === 0 + ? `Column ${columnIndex + 1}` + : `${rowIndex}${columnIndex + 1}` + ) + ) + ) + ); + }); + const document = markdownSchema.nodes.doc.create(null, [ + markdownSchema.nodes.heading.create( + { level: 1 }, + paragraph('Structure probe').content + ), + paragraph(), + markdownSchema.nodes.bulletList.create(null, [listItem]), + markdownSchema.nodes.table.create(null, tableRows), + markdownSchema.nodes.heading.create( + { level: 2 }, + paragraph('Heading after table').content + ), + ]) as ProseMirrorNode; + const ydoc = prosemirrorToYDoc(document, 'default'); + try { + return Buffer.from(Y.encodeStateAsUpdateV2(ydoc)); + } finally { + ydoc.destroy(); + } +}; + +const mixedNestedListsState = (): Buffer => { + const paragraph = (text: string) => + markdownSchema.nodes.paragraph.create(null, markdownSchema.text(text)); + const listItem = (...content: ProseMirrorNode[]) => + markdownSchema.nodes.listItem.create(null, content); + const bulletList = (...items: ProseMirrorNode[]) => + markdownSchema.nodes.bulletList.create(null, items); + const orderedList = (...items: ProseMirrorNode[]) => + markdownSchema.nodes.orderedList.create(null, items); + const document = markdownSchema.nodes.doc.create(null, [ + bulletList( + listItem( + paragraph('Bullet top'), + orderedList( + listItem( + paragraph('Ordered child'), + bulletList(listItem(paragraph('Bullet grandchild'))) + ) + ) + ) + ), + orderedList( + listItem( + paragraph('Ordered top'), + bulletList( + listItem( + paragraph('Bullet child'), + orderedList(listItem(paragraph('Ordered grandchild'))) + ) + ) + ) + ), + ]) as ProseMirrorNode; + const ydoc = prosemirrorToYDoc(document, 'default'); + try { + return Buffer.from(Y.encodeStateAsUpdateV2(ydoc)); + } finally { + ydoc.destroy(); + } +}; + const extractText = async (pdf: Buffer): Promise => { const document = await parseOffice(pdf, { fileType: 'pdf', ocr: false }); return document.toText(); @@ -115,6 +236,140 @@ describe('MemoPdfRenderer', () => { expect(text).toContain('Γειά σου'); }); + it('preserves editor block structure through the current Yjs projection', async () => { + const projectedMarkdown = yjsStateToMarkdown(structuredMemoState()); + const convertHtml = vi.spyOn(renderer as any, 'convertHtml'); + + try { + await renderer.render(projectedMarkdown, 'bucket-1', actor); + const converterHtml = convertHtml.mock.calls[0][0] as string; + const definition = convertHtml.mock.results[0].value; + + expect + .soft(projectedMarkdown) + .toContain( + '- First paragraph inside item.\n\n Second paragraph inside same item.\n\n Third paragraph inside same item.' + ); + expect + .soft(projectedMarkdown) + .toContain('\n\n| Column 1 | Column 2 | Column 3 |'); + expect.soft(projectedMarkdown).toContain('\n\n## Heading after table'); + expect.soft(projectedMarkdown).toContain('\n\n\u00a0\n\n'); + expect.soft(projectedMarkdown).not.toContain(' '); + expect.soft(converterHtml).toContain(''); + + const listItems = collectPdfMakeNodes( + definition, + node => node.nodeName === 'LI' + ); + const topListItem = listItems.find(node => { + const stack = node.stack; + return ( + Array.isArray(stack) && + stack.some( + child => + typeof child === 'object' && + child !== null && + (child as PdfMakeNode).text === 'First paragraph inside item.' + ) + ); + }); + expect.soft(topListItem).toBeDefined(); + expect + .soft( + (topListItem?.stack as PdfMakeNode[] | undefined) + ?.filter(node => node.nodeName === 'P') + .map(node => node.text) + ) + .toEqual([ + 'First paragraph inside item.', + 'Second paragraph inside same item.', + 'Third paragraph inside same item.', + ]); + expect + .soft(collectPdfMakeNodes(topListItem, node => node.nodeName === 'UL')) + .toHaveLength(2); + + const tables = collectPdfMakeNodes( + definition, + node => node.nodeName === 'TABLE' + ); + expect.soft(tables).toHaveLength(1); + const body = (tables[0]?.table as { body?: unknown[][] } | undefined) + ?.body; + expect.soft(body).toHaveLength(8); + expect.soft(body?.every(row => row.length === 3)).toBe(true); + expect + .soft( + collectPdfMakeNodes( + definition, + node => node.nodeName === 'H1' || node.nodeName === 'H2' + ).map(node => node.text) + ) + .toEqual(['Structure probe', 'Heading after table']); + expect + .soft( + collectPdfMakeNodes( + definition, + node => node.nodeName === 'P' && node.text === '\u00a0' + ) + ) + .toHaveLength(1); + } finally { + convertHtml.mockRestore(); + } + }); + + it('preserves accumulated indentation through mixed nested list types', async () => { + const projectedMarkdown = yjsStateToMarkdown(mixedNestedListsState()); + const convertHtml = vi.spyOn(renderer as any, 'convertHtml'); + + try { + await renderer.render(projectedMarkdown, 'bucket-1', actor); + const definition = convertHtml.mock.results[0].value as PdfMakeNode[]; + const topLevelLists = definition.filter( + node => node.nodeName === 'UL' || node.nodeName === 'OL' + ); + const [bulletRoot, orderedRoot] = topLevelLists; + const bulletTopItem = (bulletRoot?.ul as PdfMakeNode[] | undefined)?.[0]; + const orderedChildList = ( + bulletTopItem?.stack as PdfMakeNode[] | undefined + )?.find(node => node.nodeName === 'OL'); + const orderedChildItem = ( + orderedChildList?.ol as PdfMakeNode[] | undefined + )?.[0]; + const bulletGrandchildList = ( + orderedChildItem?.stack as PdfMakeNode[] | undefined + )?.find(node => node.nodeName === 'UL'); + const orderedTopItem = ( + orderedRoot?.ol as PdfMakeNode[] | undefined + )?.[0]; + const bulletChildList = ( + orderedTopItem?.stack as PdfMakeNode[] | undefined + )?.find(node => node.nodeName === 'UL'); + const bulletChildItem = ( + bulletChildList?.ul as PdfMakeNode[] | undefined + )?.[0]; + const orderedGrandchildList = ( + bulletChildItem?.stack as PdfMakeNode[] | undefined + )?.find(node => node.nodeName === 'OL'); + + expect.soft(topLevelLists).toHaveLength(2); + expect.soft(orderedChildList?.nodeName).toBe('OL'); + expect.soft(bulletGrandchildList?.nodeName).toBe('UL'); + expect + .soft((bulletGrandchildList?.ul as PdfMakeNode[] | undefined)?.[0]) + .toMatchObject({ nodeName: 'LI', text: 'Bullet grandchild' }); + expect.soft(bulletChildList?.nodeName).toBe('UL'); + expect.soft(orderedGrandchildList?.nodeName).toBe('OL'); + expect + .soft((orderedGrandchildList?.ol as PdfMakeNode[] | undefined)?.[0]) + .toMatchObject({ nodeName: 'LI', text: 'Ordered grandchild' }); + } finally { + convertHtml.mockRestore(); + } + }); + it('renders European platform languages and a visible box for an unsupported symbol', async () => { const supported = [ 'Nederlands: officiële beëindiging', diff --git a/src/domain/common/memo/memo.pdf.renderer.ts b/src/domain/common/memo/memo.pdf.renderer.ts index 5f16294066..44758bc048 100644 --- a/src/domain/common/memo/memo.pdf.renderer.ts +++ b/src/domain/common/memo/memo.pdf.renderer.ts @@ -9,6 +9,7 @@ import { FileServiceAdapter } from '@services/adapters/file-service-adapter/file import { JSDOM } from 'jsdom'; import MarkdownIt from 'markdown-it'; import sharp from 'sharp'; +import { blankLineReplacement } from './conversion/const'; // pdfmake and html-to-pdfmake publish CommonJS without TypeScript declarations. const htmlToPdfMake = require('html-to-pdfmake') as ( @@ -185,6 +186,10 @@ export class MemoPdfRenderer { } } + document.querySelectorAll('p:empty').forEach(paragraph => { + paragraph.textContent = blankLineReplacement; + }); + const content = this.convertHtml(document.body.innerHTML, { window: dom.window as unknown as Window, defaultStyles: { mark: { background: '#fff59d' } }, From 79717be37db1d3dd6d9dcc7c5dbe4395394d791c Mon Sep 17 00:00:00 2001 From: bobbykolev Date: Fri, 11 Sep 2026 09:53:47 +0300 Subject: [PATCH 11/11] 0.165.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 76186d380a..c8f984bfc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alkemio-server", - "version": "0.165.0", + "version": "0.165.1", "description": "Alkemio server, responsible for managing the shared Alkemio platform", "author": "Alkemio Foundation", "private": false,