diff --git a/Makefile b/Makefile index e1c50e12..14c6ddb7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint format-check typecheck build test test-backend test-frontend data-quality-test data-quality-blocking data-quality-advisory coverage coverage-auth coverage-integrations coverage-integrations-check coverage-frontend coverage-frontend-core knowledge-provenance-postgres knowledge-provenance-old-app knowledge-conversation-import-postgres knowledge-share-postgres knowledge-share-old-app chat-thread-postgres chat-thread-old-app e2e ui-evidence ui-visual-regression ui-visual-regression-update mobile-regression-log frontend-dev-api podman-smoke pr-comments audit docs-image-links-check docs-test-results-index-check ops-quality design-system-package-check eslint10-readiness-check eslint10-readiness-record dependabot-alerts-check dependabot-alerts-record dependabot-token-readiness-check dependency-watch-record backup-s3-readiness-check backup-s3-readiness-record backup-s3-restore-record backup-s3-profile-test backup-s3-backup backup-s3-upload backup-s3-download backup-s3-check backup-s3-prune-plan backup-s3-prune-apply backup-gdrive-config-check backup-gdrive-list backup-gdrive-freshness backup-gdrive-stat backup-gdrive-download backup-gdrive-prune-plan storage-readiness storage-readiness-record external-csv-artifact-intake-record production-readiness-external-evidence-check production-readiness-external-evidence-test po-migration-input-readiness-check po-migration-record po-migration-run-and-record av-staging-evidence av-staging-gate av-staging-readiness action-policy-callsites-report action-policy-callsites-report-json action-policy-required-action-gaps action-policy-required-action-gaps-json action-policy-fallback-report action-policy-fallback-report-json release-readiness release-readiness-record action-policy-phase3-readiness action-policy-phase3-readiness-json action-policy-phase3-readiness-record action-policy-phase3-cutover-record action-policy-phase3-trial-record action-policy-phase3-target-trial-record sakura-vps-profile-check bounded-context-coverage-check +.PHONY: lint format-check typecheck build test test-backend test-frontend data-quality-test data-quality-blocking data-quality-advisory coverage coverage-auth coverage-integrations coverage-integrations-check coverage-frontend coverage-frontend-core knowledge-provenance-postgres knowledge-provenance-old-app knowledge-conversation-import-postgres knowledge-share-postgres knowledge-share-old-app knowledge-thread-promotion-postgres knowledge-thread-promotion-old-app chat-thread-postgres chat-thread-old-app e2e ui-evidence ui-visual-regression ui-visual-regression-update mobile-regression-log frontend-dev-api podman-smoke pr-comments audit docs-image-links-check docs-test-results-index-check ops-quality design-system-package-check eslint10-readiness-check eslint10-readiness-record dependabot-alerts-check dependabot-alerts-record dependabot-token-readiness-check dependency-watch-record backup-s3-readiness-check backup-s3-readiness-record backup-s3-restore-record backup-s3-profile-test backup-s3-backup backup-s3-upload backup-s3-download backup-s3-check backup-s3-prune-plan backup-s3-prune-apply backup-gdrive-config-check backup-gdrive-list backup-gdrive-freshness backup-gdrive-stat backup-gdrive-download backup-gdrive-prune-plan storage-readiness storage-readiness-record external-csv-artifact-intake-record production-readiness-external-evidence-check production-readiness-external-evidence-test po-migration-input-readiness-check po-migration-record po-migration-run-and-record av-staging-evidence av-staging-gate av-staging-readiness action-policy-callsites-report action-policy-callsites-report-json action-policy-required-action-gaps action-policy-required-action-gaps-json action-policy-fallback-report action-policy-fallback-report-json release-readiness release-readiness-record action-policy-phase3-readiness action-policy-phase3-readiness-json action-policy-phase3-readiness-record action-policy-phase3-cutover-record action-policy-phase3-trial-record action-policy-phase3-target-trial-record sakura-vps-profile-check bounded-context-coverage-check lint: npm run lint --prefix packages/backend @@ -63,6 +63,12 @@ knowledge-share-postgres: knowledge-share-old-app: ./scripts/test-knowledge-share-old-app.sh +knowledge-thread-promotion-postgres: + ./scripts/test-knowledge-thread-promotion-schema-postgres.sh + +knowledge-thread-promotion-old-app: + ./scripts/test-knowledge-thread-promotion-old-app.sh + chat-thread-postgres: ./scripts/test-chat-thread-postgres.sh diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 338d5a0d..36b2bbfc 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -433,7 +433,8 @@ "annotation_revision", "conversation", "conversation_turn", - "synthesis_version" + "synthesis_version", + "thread_promotion" ], "type": "string" }, @@ -11073,6 +11074,1189 @@ ] } }, + "/chat-messages/{rootMessageId}/promote-to-knowledge": { + "post": { + "parameters": [ + { + "in": "path", + "name": "rootMessageId", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "confirmed": { + "enum": [ + true + ] + }, + "destination": { + "additionalProperties": false, + "properties": { + "organizationGroupAccountIds": { + "items": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "type": "array", + "uniqueItems": true + }, + "scope": { + "enum": [ + "personal", + "organization" + ], + "type": "string" + } + }, + "required": [ + "scope", + "organizationGroupAccountIds" + ], + "type": "object" + }, + "includeSharedCard": { + "type": "boolean" + }, + "organizationAudienceConfirmed": { + "type": "boolean" + }, + "previewToken": { + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "requestKey": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "selectedReplyMessageIds": { + "items": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "synthesis": { + "additionalProperties": false, + "properties": { + "confidenceBasisPoints": { + "maximum": 10000, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "content": { + "description": "Maximum 262,144 UTF-8 bytes; byte-bound in application.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "unresolvedQuestions": { + "items": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "title", + "content", + "confidenceBasisPoints", + "unresolvedQuestions" + ], + "type": "object" + } + }, + "required": [ + "selectedReplyMessageIds", + "includeSharedCard", + "destination", + "synthesis", + "previewToken", + "requestKey", + "confirmed", + "organizationAudienceConfirmed" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "created": { + "type": "boolean" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "includesSharedCard": { + "type": "boolean" + }, + "promotionId": { + "type": "string" + }, + "reused": { + "type": "boolean" + }, + "scope": { + "enum": [ + "personal", + "organization" + ], + "type": "string" + }, + "selectedMessageCount": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "synthesisId": { + "type": "string" + }, + "synthesisVersion": { + "enum": [ + 1 + ], + "type": "integer" + }, + "synthesisVersionId": { + "type": "string" + } + }, + "required": [ + "promotionId", + "synthesisId", + "synthesisVersionId", + "synthesisVersion", + "scope", + "selectedMessageCount", + "includesSharedCard", + "createdAt", + "created", + "reused" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "created": { + "type": "boolean" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "includesSharedCard": { + "type": "boolean" + }, + "promotionId": { + "type": "string" + }, + "reused": { + "type": "boolean" + }, + "scope": { + "enum": [ + "personal", + "organization" + ], + "type": "string" + }, + "selectedMessageCount": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "synthesisId": { + "type": "string" + }, + "synthesisVersion": { + "enum": [ + 1 + ], + "type": "integer" + }, + "synthesisVersionId": { + "type": "string" + } + }, + "required": [ + "promotionId", + "synthesisId", + "synthesisVersionId", + "synthesisVersion", + "scope", + "selectedMessageCount", + "includesSharedCard", + "createdAt", + "created", + "reused" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "chat", + "knowledge" + ] + } + }, + "/chat-messages/{rootMessageId}/promote-to-knowledge/preview": { + "post": { + "parameters": [ + { + "in": "path", + "name": "rootMessageId", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "destination": { + "additionalProperties": false, + "properties": { + "organizationGroupAccountIds": { + "items": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "type": "array", + "uniqueItems": true + }, + "scope": { + "enum": [ + "personal", + "organization" + ], + "type": "string" + } + }, + "required": [ + "scope", + "organizationGroupAccountIds" + ], + "type": "object" + }, + "includeSharedCard": { + "type": "boolean" + }, + "selectedReplyMessageIds": { + "items": { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "synthesis": { + "additionalProperties": false, + "properties": { + "confidenceBasisPoints": { + "maximum": 10000, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "content": { + "description": "Maximum 262,144 UTF-8 bytes; byte-bound in application.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "unresolvedQuestions": { + "items": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "title", + "content", + "confidenceBasisPoints", + "unresolvedQuestions" + ], + "type": "object" + } + }, + "required": [ + "selectedReplyMessageIds", + "includeSharedCard", + "destination", + "synthesis" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "destination": { + "additionalProperties": false, + "properties": { + "organizationGroupCount": { + "maximum": 20, + "minimum": 0, + "type": "integer" + }, + "scope": { + "enum": [ + "personal", + "organization" + ], + "type": "string" + } + }, + "required": [ + "scope", + "organizationGroupCount" + ], + "type": "object" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "omittedMessageCount": { + "minimum": 0, + "type": "integer" + }, + "previewToken": { + "type": "string" + }, + "requiresConfirmation": { + "enum": [ + true + ] + }, + "requiresOrganizationAudienceConfirmation": { + "type": "boolean" + }, + "selectedMessageCount": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "selectedMessages": { + "items": { + "additionalProperties": false, + "properties": { + "authorCategory": { + "enum": [ + "user" + ], + "type": "string" + }, + "content": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "ordinal": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "ordinal", + "content", + "createdAt", + "authorCategory" + ], + "type": "object" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + }, + "sharedCard": { + "additionalProperties": false, + "nullable": true, + "properties": { + "annotations": { + "items": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "kind": { + "enum": [ + "note", + "question", + "hypothesis", + "quote", + "todo" + ], + "type": "string" + }, + "origin": { + "enum": [ + "user", + "external", + "ai", + "system", + "tool" + ], + "type": "string" + }, + "revision": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "revision", + "kind", + "origin", + "content" + ], + "type": "object" + }, + "maxItems": 20, + "type": "array" + }, + "canonicalUrl": { + "nullable": true, + "type": "string" + }, + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string" + } + }, + "required": [ + "displayName" + ], + "type": "object" + }, + "maxItems": 20, + "type": "array" + }, + "omittedCategories": { + "items": { + "enum": [ + "title", + "source_type", + "canonical_url", + "snapshot_provenance", + "snapshot_excerpt", + "label", + "annotation", + "conversation_turn", + "synthesis", + "sharer_note" + ], + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "schemaVersion": { + "enum": [ + 1 + ], + "type": "integer" + }, + "selectedCategories": { + "items": { + "enum": [ + "title", + "source_type", + "canonical_url", + "snapshot_provenance", + "snapshot_excerpt", + "label", + "annotation", + "conversation_turn", + "synthesis", + "sharer_note" + ], + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "sharerNote": { + "nullable": true, + "type": "string" + }, + "shareVersion": { + "minimum": 1, + "type": "integer" + }, + "snapshot": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "version", + "sha256" + ], + "type": "object" + }, + { + "required": [ + "excerpt" + ], + "type": "object" + } + ], + "nullable": true, + "properties": { + "excerpt": { + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "version": { + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "sourceType": { + "enum": [ + "x", + "threads", + "news", + "web", + "pdf", + "image", + "manual", + "other" + ], + "nullable": true, + "type": "string" + }, + "syntheses": { + "items": { + "additionalProperties": false, + "properties": { + "confidenceBasisPoints": { + "maximum": 10000, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "content": { + "type": "string" + }, + "title": { + "type": "string" + }, + "unresolvedQuestions": { + "items": { + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + "version": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "title", + "content", + "confidenceBasisPoints", + "unresolvedQuestions" + ], + "type": "object" + }, + "maxItems": 10, + "type": "array" + }, + "title": { + "nullable": true, + "type": "string" + }, + "turns": { + "items": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "name": { + "nullable": true, + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "origin": { + "enum": [ + "user", + "external", + "ai", + "system", + "tool" + ], + "type": "string" + }, + "role": { + "enum": [ + "user", + "assistant", + "system", + "tool" + ], + "type": "string" + } + }, + "required": [ + "role", + "origin", + "content", + "name", + "occurredAt" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "schemaVersion", + "title", + "sourceType", + "canonicalUrl", + "snapshot", + "sharerNote", + "labels", + "annotations", + "turns", + "syntheses", + "selectedCategories", + "omittedCategories", + "shareVersion" + ], + "type": "object" + }, + "sourceThread": { + "additionalProperties": false, + "properties": { + "replyCount": { + "minimum": 0, + "type": "integer" + }, + "roomName": { + "type": "string" + }, + "roomType": { + "type": "string" + } + }, + "required": [ + "roomName", + "roomType", + "replyCount" + ], + "type": "object" + }, + "synthesis": { + "additionalProperties": false, + "properties": { + "confidenceBasisPoints": { + "maximum": 10000, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "content": { + "description": "Maximum 262,144 UTF-8 bytes; byte-bound in application.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "unresolvedQuestions": { + "items": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "title", + "content", + "confidenceBasisPoints", + "unresolvedQuestions" + ], + "type": "object" + } + }, + "required": [ + "sourceThread", + "selectedMessages", + "selectedMessageCount", + "omittedMessageCount", + "sharedCard", + "destination", + "synthesis", + "previewToken", + "expiresAt", + "requiresConfirmation", + "requiresOrganizationAudienceConfirmation" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "chat", + "knowledge" + ] + } + }, "/chat-messages/search": { "get": { "parameters": [ diff --git a/docs/architecture/knowledge-hub-boundary.md b/docs/architecture/knowledge-hub-boundary.md index 011407dc..c73e23b5 100644 --- a/docs/architecture/knowledge-hub-boundary.md +++ b/docs/architecture/knowledge-hub-boundary.md @@ -201,6 +201,19 @@ typed immutable snapshot row だけから表示する。旧 client は relation - break-glass access、export、外部 AI 送信、認可済み binary download のように実アクセス監査が必須の操作は、監査 write 失敗時に操作を開始しないか応答を成功させない。通常 read を監査対象に追加する場合は、fail-open/fail-closed と可用性影響を対象 Issue で明示する。 - Chat share は share snapshot の DB 確定後に Chat application port を呼ぶ。Chat 側失敗時は share を `pending|failed` とし、元 item を organization 化したり成功表示したりしない。 - thread から synthesis への promote は対象 thread snapshot と選択 message を固定し、元 message の live body を synthesis へ暗黙連結しない。 +- promote はChat messageを`KnowledgeConversation`へ変換せず、独立した + `KnowledgeThreadPromotion` aggregateとimmutable selected-message child rowで表現する。これにより + manual/JSON/Markdown conversation importのowner/effective ACLと、Chat room ACLを混同しない。 +- preview/commitはKnowledge share root、share version/content hash、ordered direct reply集合と各content + hash/activity boundary、destination scope/group grants、利用者が入力したsynthesis version 1本文を束縛する。 + commitはcurrent Chat room readとcurrent Knowledge destination writeを別々に再検査し、どちらかが失効した + 場合はfail closedとする。 +- synthesis provenanceは`KnowledgeSynthesisSource.sourceThreadPromotionId`のnullable FKを既存 + exactly-one制約へ追加して固定する。promotion後にroom accessまたはshare状態が失効した場合、immutable + synthesis本文はdestination ACLで保持する一方、promotion source IDとlive Chat identityはredactする。 +- promotion request ledgerはraw keyを保存せずcanonical ownerとdomain-separated hashで一意化する。 + selected snapshot、synthesis/version/source、明示organization grants、mandatory auditは同じSerializable + transactionで確定し、同時replayを最大3 attemptで一件へ収束させる。 - retry は read、stat、idempotent reconciliation 等に限定する。結果不明の外部 create、AI request、Chat post を新規操作として自動再実行しない。 #### annotation / conversation / synthesis provenance foundation diff --git a/docs/requirements/knowledge-hub.md b/docs/requirements/knowledge-hub.md index 9f715a96..98cedace 100644 --- a/docs/requirements/knowledge-hub.md +++ b/docs/requirements/knowledge-hub.md @@ -511,6 +511,28 @@ mutationする。 未削除のChat rootへbindされていることを同じsnapshotで再検査し、`canOpenSource`はcurrent Knowledge ACLを独立再検査する。source削除/ACL失効後はfalseとする。canonical URLは保存値を信頼せず response時にもcredential/query/fragment/provider hostを再sanitizeする。 +- thread promote は posted かつ未revokeのKnowledge share rootに限定し、1〜100件のactive direct replyを + 利用者が順序付きで明示選択する。rootの汎用fallback、thread全文、未選択reply、notification/search + snippetはpromotion snapshotへ複製しない。MVPは新規`KnowledgeSynthesis` version 1の作成だけを扱い、 + 既存synthesisへのappend、自動要約、AI実行を行わない。 +- `POST /chat-messages/{rootMessageId}/promote-to-knowledge/preview`はcurrent room read ACL、active project、 + exact share version/hash、replyのroom/root/deleted state/body hash/activity boundary、destination scope/grantを + consistent snapshotで検査し、選択本文、選択/省略件数、destination、synthesis入力のexact previewだけを + 返す。10分の用途分離HMAC tokenにはactor/root/payloadのfingerprintだけを格納し、本文、message ID、 + room ID、request keyを平文で入れない。 +- commitは`confirmed=true`を必須とし、previewと同じ境界を再検査する。personalを既定とし、organizationは + actorのcurrent organizationと1件以上のactive group grantを明示要求する。room accessをKnowledge write + ACLへ昇格させない。同じopaque request keyと同じpayloadは同じpromotion/synthesisへ収束し、異なる + payloadはmutationなしのsanitized 409、Serializable競合は最大3 attemptとする。 +- 選択replyは`KnowledgeThreadPromotionMessage`へimmutable copyとして保存し、順序、本文hash、作成時刻、 + activity boundary、sanitized author categoryを固定する。`KnowledgeSynthesisSource`はpromotion FKを + exactly-one source制約へ追加し、自由文字列source type/idを正本にしない。後からChat ACLまたはshare + 状態が失効してもsynthesis本文はdestination ACLで保持するが、live thread identityはredactする。 + 現行MVPのChat text replyは内部user categoryだけを受け入れる。external-enabled share roomはfail closed、 + system/tool message typeは非対象とし、識別可能なsource discriminatorなしにcategoryを推測しない。 +- organization promotionで作成するsynthesisは明示`KnowledgeSynthesisGroupGrant`を持つ。migration前の + organization synthesisはgrant row 0件の既存organization-wide契約を維持し、new promotionだけを + grant必須にするexpand段階とする。 ### 08. External LLM common boundary / AI dialogue / cost guard diff --git a/packages/backend/bounded-context-registry.cjs b/packages/backend/bounded-context-registry.cjs index c4a749f6..40a669af 100644 --- a/packages/backend/bounded-context-registry.cjs +++ b/packages/backend/bounded-context-registry.cjs @@ -77,7 +77,7 @@ const contexts = [ displayName: 'Knowledge Hub', patterns: [ '^src/application/knowledge/.+\\.ts$', - '^src/routes/knowledge(Items|Labels|Search|SavedViews|Snapshots|Annotations|Conversations|ConversationImports|Syntheses|Shares|ProvenanceSchemas|RouteContext)\\.ts$', + '^src/routes/knowledge(Items|Labels|Search|SavedViews|Snapshots|Annotations|Conversations|ConversationImports|Syntheses|Shares|ThreadPromotions|ProvenanceSchemas|RouteContext)\\.ts$', ], }, { diff --git a/packages/backend/prisma/migrations/20260810130000_add_knowledge_thread_promotion/migration.sql b/packages/backend/prisma/migrations/20260810130000_add_knowledge_thread_promotion/migration.sql new file mode 100644 index 00000000..286ad8f4 --- /dev/null +++ b/packages/backend/prisma/migrations/20260810130000_add_knowledge_thread_promotion/migration.sql @@ -0,0 +1,620 @@ +-- Expand-only foundation for explicitly promoting selected Chat thread replies +-- to a new Knowledge synthesis. Existing Chat and Knowledge rows are neither +-- rewritten nor deleted; old applications can continue to ignore these tables. + +CREATE TYPE "KnowledgeThreadPromotionAuthorCategory" AS ENUM ( + 'user' +); + +ALTER TABLE "KnowledgeSynthesisSource" + ADD COLUMN "sourceThreadPromotionId" TEXT; + +CREATE TABLE "KnowledgeThreadPromotion" ( + "id" TEXT NOT NULL, + "sourceShareId" TEXT NOT NULL, + "sourceShareVersion" INTEGER NOT NULL, + "sourceShareContentHash" TEXT NOT NULL, + "sourceRoomId" TEXT NOT NULL, + "sourceRootMessageId" TEXT NOT NULL, + "promoterUserId" TEXT NOT NULL, + "ownerUserId" TEXT NOT NULL, + "scope" "KnowledgeItemScope" NOT NULL, + "organizationId" TEXT, + "destinationSynthesisId" TEXT NOT NULL, + "destinationSynthesisVersionId" TEXT NOT NULL, + "destinationSynthesisVersionNumber" INTEGER NOT NULL DEFAULT 1, + "previewSchemaVersion" INTEGER NOT NULL DEFAULT 1, + "selectionHash" TEXT NOT NULL, + "contentHash" TEXT NOT NULL, + "includesSharedCard" BOOLEAN NOT NULL DEFAULT false, + "selectedMessageCount" INTEGER NOT NULL, + "destinationGrantCount" INTEGER NOT NULL DEFAULT 0, + "destinationGrantHash" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "KnowledgeThreadPromotion_pkey" PRIMARY KEY ("id"), + CONSTRAINT "KnowledgeThreadPromotion_identity_check" CHECK ( + LENGTH(BTRIM("sourceShareId")) > 0 + AND LENGTH(BTRIM("sourceRoomId")) > 0 + AND LENGTH(BTRIM("sourceRootMessageId")) > 0 + AND LENGTH(BTRIM("promoterUserId")) > 0 + AND LENGTH(BTRIM("ownerUserId")) > 0 + AND LENGTH(BTRIM("createdBy")) > 0 + ), + CONSTRAINT "KnowledgeThreadPromotion_version_hash_check" CHECK ( + "sourceShareVersion" >= 1 + AND "sourceShareContentHash" ~ '^[0-9a-f]{64}$' + AND "previewSchemaVersion" = 1 + AND "version" = 1 + AND "destinationSynthesisVersionNumber" = 1 + ), + CONSTRAINT "KnowledgeThreadPromotion_content_binding_check" CHECK ( + "selectionHash" ~ '^[0-9a-f]{64}$' + AND "contentHash" ~ '^[0-9a-f]{64}$' + AND ( + NOT "includesSharedCard" + OR "sourceShareContentHash" ~ '^[0-9a-f]{64}$' + ) + ), + CONSTRAINT "KnowledgeThreadPromotion_selection_bounds_check" CHECK ( + "selectedMessageCount" BETWEEN 1 AND 100 + ), + CONSTRAINT "KnowledgeThreadPromotion_scope_grants_check" CHECK ( + ( + "scope" = 'personal' + AND "organizationId" IS NULL + AND "destinationGrantCount" = 0 + AND "destinationGrantHash" IS NULL + ) + OR ( + "scope" = 'organization' + AND "organizationId" IS NOT NULL + AND LENGTH(BTRIM("organizationId")) > 0 + AND "destinationGrantCount" BETWEEN 1 AND 20 + AND "destinationGrantHash" ~ '^[0-9a-f]{64}$' + ) + ) +); + +COMMENT ON COLUMN "KnowledgeThreadPromotion"."contentHash" IS + 'SHA-256 of canonical promotion content including includesSharedCard, ordered selected reply hashes, destination scope and grant hash'; + +CREATE TABLE "KnowledgeThreadPromotionMessage" ( + "id" TEXT NOT NULL, + "promotionId" TEXT NOT NULL, + "sourceRoomId" TEXT NOT NULL, + "sourceRootMessageId" TEXT NOT NULL, + "sourceMessageId" TEXT NOT NULL, + "sourceActivitySequence" BIGINT NOT NULL, + "sourceMessageCreatedAt" TIMESTAMP(3) NOT NULL, + "ordinal" INTEGER NOT NULL, + "authorCategory" "KnowledgeThreadPromotionAuthorCategory" NOT NULL, + "content" TEXT NOT NULL, + "contentHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "KnowledgeThreadPromotionMessage_pkey" PRIMARY KEY ("id"), + CONSTRAINT "KnowledgeThreadPromotionMessage_identity_check" CHECK ( + LENGTH(BTRIM("promotionId")) > 0 + AND LENGTH(BTRIM("sourceRoomId")) > 0 + AND LENGTH(BTRIM("sourceRootMessageId")) > 0 + AND LENGTH(BTRIM("sourceMessageId")) > 0 + AND "sourceMessageId" <> "sourceRootMessageId" + AND LENGTH(BTRIM("createdBy")) > 0 + ), + CONSTRAINT "KnowledgeThreadPromotionMessage_snapshot_check" CHECK ( + "sourceActivitySequence" > 0 + AND "ordinal" BETWEEN 0 AND 99 + AND OCTET_LENGTH("content") BETWEEN 1 AND 65536 + AND "contentHash" ~ '^[0-9a-f]{64}$' + AND "createdAt" >= "sourceMessageCreatedAt" + ) +); + +CREATE TABLE "KnowledgeThreadPromotionRequest" ( + "id" TEXT NOT NULL, + "promoterUserId" TEXT NOT NULL, + "requestKeyHash" TEXT NOT NULL, + "requestPayloadHash" TEXT NOT NULL, + "promotionId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + + CONSTRAINT "KnowledgeThreadPromotionRequest_pkey" PRIMARY KEY ("id"), + CONSTRAINT "KnowledgeThreadPromotionRequest_hash_check" CHECK ( + "requestKeyHash" ~ '^[0-9a-f]{64}$' + AND "requestPayloadHash" ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT "KnowledgeThreadPromotionRequest_identity_check" CHECK ( + LENGTH(BTRIM("promoterUserId")) > 0 + AND LENGTH(BTRIM("promotionId")) > 0 + AND LENGTH(BTRIM("createdBy")) > 0 + ) +); + +CREATE TABLE "KnowledgeSynthesisGroupGrant" ( + "id" TEXT NOT NULL, + "synthesisId" TEXT NOT NULL, + "groupAccountId" TEXT NOT NULL, + "revokedAt" TIMESTAMP(3), + "revokedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT NOT NULL, + + CONSTRAINT "KnowledgeSynthesisGroupGrant_pkey" PRIMARY KEY ("id"), + CONSTRAINT "KnowledgeSynthesisGroupGrant_identity_check" CHECK ( + LENGTH(BTRIM("synthesisId")) > 0 + AND LENGTH(BTRIM("groupAccountId")) > 0 + AND LENGTH(BTRIM("createdBy")) > 0 + AND LENGTH(BTRIM("updatedBy")) > 0 + ), + CONSTRAINT "KnowledgeSynthesisGroupGrant_revoke_check" CHECK ( + ("revokedAt" IS NULL AND "revokedBy" IS NULL) + OR ( + "revokedAt" IS NOT NULL + AND "revokedBy" IS NOT NULL + AND LENGTH(BTRIM("revokedBy")) > 0 + AND "revokedAt" >= "createdAt" + ) + ), + CONSTRAINT "KnowledgeSynthesisGroupGrant_timestamp_check" CHECK ( + "updatedAt" >= "createdAt" + ) +); + +CREATE UNIQUE INDEX "KnowledgeThreadPromotion_destinationSynthesisId_key" + ON "KnowledgeThreadPromotion"("destinationSynthesisId"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotion_destinationSynthesisVersionId_key" + ON "KnowledgeThreadPromotion"("destinationSynthesisVersionId"); +CREATE INDEX "KnowledgeThreadPromotion_sourceShareId_sourceShareVersion_c_idx" + ON "KnowledgeThreadPromotion"("sourceShareId", "sourceShareVersion", "createdAt", "id"); +CREATE INDEX "KnowledgeThreadPromotion_sourceRoomId_sourceRootMessageId_c_idx" + ON "KnowledgeThreadPromotion"("sourceRoomId", "sourceRootMessageId", "createdAt", "id"); +CREATE INDEX "KnowledgeThreadPromotion_ownerUserId_scope_createdAt_id_idx" + ON "KnowledgeThreadPromotion"("ownerUserId", "scope", "createdAt", "id"); +CREATE INDEX "KnowledgeThreadPromotion_organizationId_scope_createdAt_id_idx" + ON "KnowledgeThreadPromotion"("organizationId", "scope", "createdAt", "id"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotion_id_promoterUserId_key" + ON "KnowledgeThreadPromotion"("id", "promoterUserId"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotion_id_sourceRoomId_sourceRootMessageI_key" + ON "KnowledgeThreadPromotion"("id", "sourceRoomId", "sourceRootMessageId"); + +CREATE INDEX "KnowledgeThreadPromotionMessage_sourceMessageId_sourceRoomI_idx" + ON "KnowledgeThreadPromotionMessage"("sourceMessageId", "sourceRoomId"); +CREATE INDEX "KnowledgeThreadPromotionMessage_sourceRootMessageId_sourceR_idx" + ON "KnowledgeThreadPromotionMessage"("sourceRootMessageId", "sourceRoomId", "ordinal"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotionMessage_promotionId_ordinal_key" + ON "KnowledgeThreadPromotionMessage"("promotionId", "ordinal"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotionMessage_promotionId_sourceMessageId_key" + ON "KnowledgeThreadPromotionMessage"("promotionId", "sourceMessageId"); + +CREATE UNIQUE INDEX "KnowledgeThreadPromotionRequest_promotionId_key" + ON "KnowledgeThreadPromotionRequest"("promotionId"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotionRequest_promoterUserId_requestKeyHa_key" + ON "KnowledgeThreadPromotionRequest"("promoterUserId", "requestKeyHash"); +CREATE UNIQUE INDEX "KnowledgeThreadPromotionRequest_promotionId_promoterUserId_key" + ON "KnowledgeThreadPromotionRequest"("promotionId", "promoterUserId"); + +CREATE INDEX "KnowledgeSynthesisGroupGrant_groupAccountId_revokedAt_synth_idx" + ON "KnowledgeSynthesisGroupGrant"("groupAccountId", "revokedAt", "synthesisId"); +CREATE INDEX "KnowledgeSynthesisGroupGrant_synthesisId_revokedAt_groupAcc_idx" + ON "KnowledgeSynthesisGroupGrant"("synthesisId", "revokedAt", "groupAccountId"); +CREATE UNIQUE INDEX "KnowledgeSynthesisGroupGrant_synthesisId_groupAccountId_key" + ON "KnowledgeSynthesisGroupGrant"("synthesisId", "groupAccountId"); + +CREATE UNIQUE INDEX "KnowledgeSynthesisSource_sourceThreadPromotionId_key" + ON "KnowledgeSynthesisSource"("sourceThreadPromotionId"); + +ALTER TABLE "KnowledgeThreadPromotion" + ADD CONSTRAINT "KnowledgeThreadPromotion_sourceShareId_fkey" + FOREIGN KEY ("sourceShareId") REFERENCES "KnowledgeShare"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "KnowledgeThreadPromotion" + ADD CONSTRAINT "KnowledgeThreadPromotion_sourceRoomId_fkey" + FOREIGN KEY ("sourceRoomId") REFERENCES "ChatRoom"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "KnowledgeThreadPromotion" + ADD CONSTRAINT "KnowledgeThreadPromotion_sourceRootMessageId_sourceRoomId_fkey" + FOREIGN KEY ("sourceRootMessageId", "sourceRoomId") + REFERENCES "ChatMessage"("id", "roomId") + ON DELETE RESTRICT ON UPDATE NO ACTION; +ALTER TABLE "KnowledgeThreadPromotion" + ADD CONSTRAINT "KnowledgeThreadPromotion_destinationSynthesisId_ownerUserI_fkey" + FOREIGN KEY ("destinationSynthesisId", "ownerUserId") + REFERENCES "KnowledgeSynthesis"("id", "ownerUserId") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "KnowledgeThreadPromotion" + ADD CONSTRAINT "KnowledgeThreadPromotion_destinationSynthesisVersionId_des_fkey" + FOREIGN KEY ( + "destinationSynthesisVersionId", + "destinationSynthesisId", + "destinationSynthesisVersionNumber" + ) + REFERENCES "KnowledgeSynthesisVersion"("id", "synthesisId", "version") + ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE "KnowledgeThreadPromotionMessage" + ADD CONSTRAINT "KnowledgeThreadPromotionMessage_promotionId_sourceRoomId_s_fkey" + FOREIGN KEY ("promotionId", "sourceRoomId", "sourceRootMessageId") + REFERENCES "KnowledgeThreadPromotion"("id", "sourceRoomId", "sourceRootMessageId") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "KnowledgeThreadPromotionMessage" + ADD CONSTRAINT "KnowledgeThreadPromotionMessage_sourceMessageId_sourceRoom_fkey" + FOREIGN KEY ("sourceMessageId", "sourceRoomId") + REFERENCES "ChatMessage"("id", "roomId") + ON DELETE RESTRICT ON UPDATE NO ACTION; + +ALTER TABLE "KnowledgeThreadPromotionRequest" + ADD CONSTRAINT "KnowledgeThreadPromotionRequest_promotionId_promoterUserId_fkey" + FOREIGN KEY ("promotionId", "promoterUserId") + REFERENCES "KnowledgeThreadPromotion"("id", "promoterUserId") + ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE "KnowledgeSynthesisGroupGrant" + ADD CONSTRAINT "KnowledgeSynthesisGroupGrant_synthesisId_fkey" + FOREIGN KEY ("synthesisId") REFERENCES "KnowledgeSynthesis"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "KnowledgeSynthesisGroupGrant" + ADD CONSTRAINT "KnowledgeSynthesisGroupGrant_groupAccountId_fkey" + FOREIGN KEY ("groupAccountId") REFERENCES "GroupAccount"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE "KnowledgeSynthesisSource" + ADD CONSTRAINT "KnowledgeSynthesisSource_sourceThreadPromotionId_fkey" + FOREIGN KEY ("sourceThreadPromotionId") + REFERENCES "KnowledgeThreadPromotion"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; + +-- Keep exactly-one provenance integrity continuously while extending the +-- existing allowlist. The replacement is validated before the old constraint +-- is removed and then takes over the established constraint name. +ALTER TABLE "KnowledgeSynthesisSource" + ADD CONSTRAINT "KnowledgeSynthesisSource_exactly_one_promotion_check" CHECK ( + NUM_NONNULLS( + "sourceKnowledgeItemId", + "sourceSnapshotId", + "sourceAnnotationId", + "sourceAnnotationRevisionId", + "sourceConversationId", + "sourceConversationTurnId", + "sourceSynthesisVersionId", + "sourceThreadPromotionId" + ) = 1 + ) NOT VALID; +ALTER TABLE "KnowledgeSynthesisSource" + VALIDATE CONSTRAINT "KnowledgeSynthesisSource_exactly_one_promotion_check"; +ALTER TABLE "KnowledgeSynthesisSource" + DROP CONSTRAINT "KnowledgeSynthesisSource_exactly_one_check"; +ALTER TABLE "KnowledgeSynthesisSource" + RENAME CONSTRAINT "KnowledgeSynthesisSource_exactly_one_promotion_check" + TO "KnowledgeSynthesisSource_exactly_one_check"; + +CREATE FUNCTION "erp4_validate_knowledge_thread_promotion"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + share_row "KnowledgeShare"%ROWTYPE; + root_row "ChatMessage"%ROWTYPE; + synthesis_row "KnowledgeSynthesis"%ROWTYPE; + version_row "KnowledgeSynthesisVersion"%ROWTYPE; +BEGIN + SELECT * INTO share_row + FROM "KnowledgeShare" + WHERE "id" = NEW."sourceShareId" + FOR SHARE; + IF NOT FOUND + OR share_row."status" <> 'posted' + OR share_row."revokedAt" IS NOT NULL + OR share_row."version" IS DISTINCT FROM NEW."sourceShareVersion" + OR share_row."contentHash" IS DISTINCT FROM NEW."sourceShareContentHash" + OR share_row."destinationRoomId" IS DISTINCT FROM NEW."sourceRoomId" + OR share_row."chatMessageId" IS DISTINCT FROM NEW."sourceRootMessageId" + THEN + RAISE EXCEPTION 'knowledge thread promotion requires an exact posted share' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotion_exact_share_check'; + END IF; + + SELECT * INTO root_row + FROM "ChatMessage" + WHERE "id" = NEW."sourceRootMessageId" + AND "roomId" = NEW."sourceRoomId" + FOR SHARE; + IF NOT FOUND + OR root_row."parentMessageId" IS NOT NULL + OR root_row."threadRootId" IS NOT NULL + OR root_row."deletedAt" IS NOT NULL + OR root_row."messageType" <> 'text' + OR root_row."body" IS DISTINCT FROM 'Knowledge was shared.' + THEN + RAISE EXCEPTION 'knowledge thread promotion requires an active share root' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotion_active_root_check'; + END IF; + + SELECT * INTO synthesis_row + FROM "KnowledgeSynthesis" + WHERE "id" = NEW."destinationSynthesisId" + AND "ownerUserId" = NEW."ownerUserId" + FOR SHARE; + IF NOT FOUND + OR synthesis_row."scope" IS DISTINCT FROM NEW."scope" + OR synthesis_row."organizationId" IS DISTINCT FROM NEW."organizationId" + OR synthesis_row."currentVersion" <> 1 + OR synthesis_row."deletedAt" IS NOT NULL + THEN + RAISE EXCEPTION 'knowledge thread promotion destination is inconsistent' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotion_destination_check'; + END IF; + + SELECT * INTO version_row + FROM "KnowledgeSynthesisVersion" + WHERE "id" = NEW."destinationSynthesisVersionId" + AND "synthesisId" = NEW."destinationSynthesisId" + AND "version" = NEW."destinationSynthesisVersionNumber" + FOR SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'knowledge thread promotion destination version is missing' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotion_destination_version_check'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER "KnowledgeThreadPromotion_validate_trigger" +BEFORE INSERT ON "KnowledgeThreadPromotion" +FOR EACH ROW +EXECUTE FUNCTION "erp4_validate_knowledge_thread_promotion"(); + +CREATE FUNCTION "erp4_validate_knowledge_thread_promotion_message"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + promotion_row "KnowledgeThreadPromotion"%ROWTYPE; + message_row "ChatMessage"%ROWTYPE; +BEGIN + SELECT * INTO promotion_row + FROM "KnowledgeThreadPromotion" + WHERE "id" = NEW."promotionId"; + IF NOT FOUND + OR promotion_row."sourceRoomId" IS DISTINCT FROM NEW."sourceRoomId" + OR promotion_row."sourceRootMessageId" IS DISTINCT FROM NEW."sourceRootMessageId" + THEN + RAISE EXCEPTION 'selected message does not match promotion thread' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotionMessage_thread_check'; + END IF; + + SELECT * INTO message_row + FROM "ChatMessage" + WHERE "id" = NEW."sourceMessageId" + AND "roomId" = NEW."sourceRoomId" + FOR SHARE; + IF NOT FOUND + OR message_row."parentMessageId" IS DISTINCT FROM NEW."sourceRootMessageId" + OR message_row."threadRootId" IS DISTINCT FROM NEW."sourceRootMessageId" + OR message_row."deletedAt" IS NOT NULL + OR message_row."messageType" <> 'text' + OR message_row."activitySequence" IS DISTINCT FROM NEW."sourceActivitySequence" + OR message_row."createdAt" IS DISTINCT FROM NEW."sourceMessageCreatedAt" + OR message_row."body" IS DISTINCT FROM NEW."content" + THEN + RAISE EXCEPTION 'selected reply snapshot is stale or outside the thread' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotionMessage_exact_reply_check'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER "KnowledgeThreadPromotionMessage_validate_trigger" +BEFORE INSERT ON "KnowledgeThreadPromotionMessage" +FOR EACH ROW +EXECUTE FUNCTION "erp4_validate_knowledge_thread_promotion_message"(); + +CREATE FUNCTION "erp4_validate_knowledge_synthesis_group_grant"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + synthesis_row "KnowledgeSynthesis"%ROWTYPE; +BEGIN + SELECT * INTO synthesis_row + FROM "KnowledgeSynthesis" + WHERE "id" = NEW."synthesisId" + FOR SHARE; + IF NOT FOUND + OR synthesis_row."scope" <> 'organization' + OR synthesis_row."organizationId" IS NULL + OR synthesis_row."deletedAt" IS NOT NULL + THEN + RAISE EXCEPTION 'synthesis group grants require an active organization synthesis' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeSynthesisGroupGrant_scope_check'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "KnowledgeSynthesisGroupGrant_validate_trigger" +BEFORE INSERT ON "KnowledgeSynthesisGroupGrant" +FOR EACH ROW +EXECUTE FUNCTION "erp4_validate_knowledge_synthesis_group_grant"(); + +CREATE FUNCTION "erp4_validate_knowledge_thread_promotion_source"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + promotion_row "KnowledgeThreadPromotion"%ROWTYPE; +BEGIN + IF NEW."sourceThreadPromotionId" IS NULL THEN + IF EXISTS ( + SELECT 1 + FROM "KnowledgeThreadPromotion" + WHERE "destinationSynthesisVersionId" = NEW."synthesisVersionId" + ) THEN + RAISE EXCEPTION 'promotion destination version accepts only its immutable promotion source' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeSynthesisSource_thread_promotion_exclusive_check'; + END IF; + RETURN NEW; + END IF; + + SELECT * INTO promotion_row + FROM "KnowledgeThreadPromotion" + WHERE "id" = NEW."sourceThreadPromotionId"; + IF NOT FOUND + OR NEW."synthesisVersionId" IS DISTINCT FROM promotion_row."destinationSynthesisVersionId" + OR NEW."relationType" <> 'primary' + OR NEW."ordinal" <> 0 + THEN + RAISE EXCEPTION 'promotion provenance must be the primary source of its exact destination version' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeSynthesisSource_thread_promotion_check'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "KnowledgeSynthesisSource_thread_promotion_validate_trigger" +BEFORE INSERT OR UPDATE OF + "synthesisVersionId", "relationType", "ordinal", "sourceThreadPromotionId" +ON "KnowledgeSynthesisSource" +FOR EACH ROW +EXECUTE FUNCTION "erp4_validate_knowledge_thread_promotion_source"(); + +CREATE FUNCTION "erp4_enforce_knowledge_thread_promotion_complete"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + selected_count INTEGER; + source_count INTEGER; + destination_source_count INTEGER; + active_grant_count INTEGER; + request_count INTEGER; +BEGIN + SELECT COUNT(*) INTO selected_count + FROM "KnowledgeThreadPromotionMessage" + WHERE "promotionId" = NEW."id"; + + SELECT COUNT(*) INTO source_count + FROM "KnowledgeSynthesisSource" + WHERE "sourceThreadPromotionId" = NEW."id" + AND "synthesisVersionId" = NEW."destinationSynthesisVersionId"; + + SELECT COUNT(*) INTO destination_source_count + FROM "KnowledgeSynthesisSource" + WHERE "synthesisVersionId" = NEW."destinationSynthesisVersionId"; + + SELECT COUNT(*) INTO active_grant_count + FROM "KnowledgeSynthesisGroupGrant" + WHERE "synthesisId" = NEW."destinationSynthesisId" + AND "revokedAt" IS NULL; + + SELECT COUNT(*) INTO request_count + FROM "KnowledgeThreadPromotionRequest" + WHERE "promotionId" = NEW."id" + AND "promoterUserId" = NEW."promoterUserId"; + + IF selected_count <> NEW."selectedMessageCount" + OR source_count <> 1 + OR destination_source_count <> 1 + OR active_grant_count <> NEW."destinationGrantCount" + OR request_count <> 1 + THEN + RAISE EXCEPTION 'knowledge thread promotion aggregate is incomplete' + USING ERRCODE = '23514', + CONSTRAINT = 'KnowledgeThreadPromotion_complete_check'; + END IF; + RETURN NEW; +END; +$$; + +CREATE CONSTRAINT TRIGGER "KnowledgeThreadPromotion_complete_trigger" +AFTER INSERT ON "KnowledgeThreadPromotion" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION "erp4_enforce_knowledge_thread_promotion_complete"(); + +CREATE FUNCTION "erp4_reject_knowledge_thread_promotion_mutation"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'immutable knowledge thread promotion history cannot be updated or deleted' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER "KnowledgeThreadPromotion_immutable_trigger" +BEFORE UPDATE OR DELETE ON "KnowledgeThreadPromotion" +FOR EACH ROW +EXECUTE FUNCTION "erp4_reject_knowledge_thread_promotion_mutation"(); +CREATE TRIGGER "KnowledgeThreadPromotionMessage_immutable_trigger" +BEFORE UPDATE OR DELETE ON "KnowledgeThreadPromotionMessage" +FOR EACH ROW +EXECUTE FUNCTION "erp4_reject_knowledge_thread_promotion_mutation"(); +CREATE TRIGGER "KnowledgeThreadPromotionRequest_immutable_trigger" +BEFORE UPDATE OR DELETE ON "KnowledgeThreadPromotionRequest" +FOR EACH ROW +EXECUTE FUNCTION "erp4_reject_knowledge_thread_promotion_mutation"(); + +CREATE FUNCTION "erp4_guard_knowledge_synthesis_group_grant_history"() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'knowledge synthesis grants must be revoked, not deleted' + USING ERRCODE = '55000'; + END IF; + IF NEW."id" IS DISTINCT FROM OLD."id" + OR NEW."synthesisId" IS DISTINCT FROM OLD."synthesisId" + OR NEW."groupAccountId" IS DISTINCT FROM OLD."groupAccountId" + OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt" + OR NEW."createdBy" IS DISTINCT FROM OLD."createdBy" + OR (OLD."revokedAt" IS NOT NULL AND ( + NEW."revokedAt" IS DISTINCT FROM OLD."revokedAt" + OR NEW."revokedBy" IS DISTINCT FROM OLD."revokedBy" + )) + THEN + RAISE EXCEPTION 'knowledge synthesis grant identity and revocation history are immutable' + USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "KnowledgeSynthesisGroupGrant_history_trigger" +BEFORE UPDATE OR DELETE ON "KnowledgeSynthesisGroupGrant" +FOR EACH ROW +EXECUTE FUNCTION "erp4_guard_knowledge_synthesis_group_grant_history"(); + +-- Mandatory promotion audit actions use only their aggregate target. This is +-- NOT VALID so unrelated historical audit rows are not scanned by deployment. +ALTER TABLE "AuditLog" + ADD CONSTRAINT "AuditLog_knowledge_thread_promotion_target_check" CHECK ( + "action" NOT IN ( + 'knowledge_thread_promote_previewed', + 'knowledge_thread_promoted', + 'knowledge_thread_promote_duplicate_detected', + 'knowledge_thread_promote_rejected' + ) + OR ( + "targetTable" = 'knowledge_thread_promotions' + AND "targetId" IS NOT NULL + ) + ) NOT VALID; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index f817a4fa..3be5a91b 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -233,6 +233,10 @@ enum KnowledgeShareFailureCode { post_rejected } +enum KnowledgeThreadPromotionAuthorCategory { + user +} + model Customer { id String @id @default(uuid()) code String @unique @@ -556,6 +560,7 @@ model ChatRoom { readStates ChatReadState[] notificationSettings ChatRoomNotificationSetting[] knowledgeShares KnowledgeShare[] + knowledgeThreadPromotions KnowledgeThreadPromotion[] @relation("KnowledgeThreadPromotionRoom") @@unique([type, projectId]) @@index([type, createdAt]) @@ -586,30 +591,32 @@ enum ChatMessageType { } model ChatMessage { - id String @id @default(uuid()) - activitySequence BigInt @unique @default(autoincrement()) - room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Restrict) + id String @id @default(uuid()) + activitySequence BigInt @unique @default(autoincrement()) + room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Restrict) roomId String - messageType ChatMessageType @default(text) + messageType ChatMessageType @default(text) parentMessageId String? threadRootId String? - parentMessage ChatMessage? @relation("ChatMessageParent", fields: [parentMessageId, roomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) - replies ChatMessage[] @relation("ChatMessageParent") - threadRoot ChatMessage? @relation("ChatMessageRoot", fields: [threadRootId, roomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) - threadReplies ChatMessage[] @relation("ChatMessageRoot") + parentMessage ChatMessage? @relation("ChatMessageParent", fields: [parentMessageId, roomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) + replies ChatMessage[] @relation("ChatMessageParent") + threadRoot ChatMessage? @relation("ChatMessageRoot", fields: [threadRootId, roomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) + threadReplies ChatMessage[] @relation("ChatMessageRoot") userId String body String tags Json? reactions Json? mentions Json? - mentionsAll Boolean @default(false) + mentionsAll Boolean @default(false) ackRequest ChatAckRequest? ackLinks ChatAckLink[] attachments ChatAttachment[] knowledgeShare KnowledgeShare? - createdAt DateTime @default(now()) + promotionRoots KnowledgeThreadPromotion[] @relation("KnowledgeThreadPromotionRoot") + promotionSources KnowledgeThreadPromotionMessage[] @relation("KnowledgeThreadPromotionSelectedMessage") + createdAt DateTime @default(now()) createdBy String? - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt updatedBy String? deletedAt DateTime? deletedReason String? @@ -2637,20 +2644,22 @@ model KnowledgeConversationTurn { } model KnowledgeSynthesis { - id String @id @default(uuid()) - ownerUserId String - scope KnowledgeItemScope - organizationId String? - title String - currentVersion Int @default(1) - deletedAt DateTime? - deletedBy String? - createdAt DateTime @default(now()) - createdBy String - updatedAt DateTime @updatedAt - updatedBy String - versions KnowledgeSynthesisVersion[] - shareSnapshots KnowledgeShareSynthesisSnapshot[] + id String @id @default(uuid()) + ownerUserId String + scope KnowledgeItemScope + organizationId String? + title String + currentVersion Int @default(1) + deletedAt DateTime? + deletedBy String? + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + versions KnowledgeSynthesisVersion[] + shareSnapshots KnowledgeShareSynthesisSnapshot[] + threadPromotions KnowledgeThreadPromotion[] + groupGrants KnowledgeSynthesisGroupGrant[] @@unique([id, ownerUserId]) @@index([ownerUserId, deletedAt, updatedAt, id]) @@ -2670,6 +2679,7 @@ model KnowledgeSynthesisVersion { sources KnowledgeSynthesisSource[] @relation("KnowledgeSynthesisVersionSources") usedAsSource KnowledgeSynthesisSource[] @relation("KnowledgeSynthesisVersionReferenced") shareSnapshots KnowledgeShareSynthesisSnapshot[] + threadPromotions KnowledgeThreadPromotion[] @@unique([synthesisId, version]) @@unique([id, synthesisId, version]) @@ -2696,6 +2706,8 @@ model KnowledgeSynthesisSource { sourceConversationTurnId String? sourceSynthesisVersion KnowledgeSynthesisVersion? @relation("KnowledgeSynthesisVersionReferenced", fields: [sourceSynthesisVersionId], references: [id], onDelete: Restrict) sourceSynthesisVersionId String? + sourceThreadPromotion KnowledgeThreadPromotion? @relation(fields: [sourceThreadPromotionId], references: [id], onDelete: Restrict, onUpdate: Restrict) + sourceThreadPromotionId String? @unique createdAt DateTime @default(now()) createdBy String @@ -2747,6 +2759,7 @@ model KnowledgeShare { annotations KnowledgeShareAnnotationSnapshot[] turns KnowledgeShareTurnSnapshot[] syntheses KnowledgeShareSynthesisSnapshot[] + threadPromotions KnowledgeThreadPromotion[] @@unique([sharerUserId, requestKeyHash]) @@unique([id, sourceKnowledgeItemId, sourceOwnerUserId]) @@ -2882,6 +2895,102 @@ model KnowledgeShareSynthesisSnapshot { @@index([sourceSynthesisVersionId, sourceSynthesisId, version]) } +model KnowledgeThreadPromotion { + id String @id @default(uuid()) + sourceShare KnowledgeShare @relation(fields: [sourceShareId], references: [id], onDelete: Restrict, onUpdate: Restrict) + sourceShareId String + sourceShareVersion Int + sourceShareContentHash String + sourceRoom ChatRoom @relation("KnowledgeThreadPromotionRoom", fields: [sourceRoomId], references: [id], onDelete: Restrict, onUpdate: Restrict) + sourceRoomId String + sourceRootMessage ChatMessage @relation("KnowledgeThreadPromotionRoot", fields: [sourceRootMessageId, sourceRoomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) + sourceRootMessageId String + promoterUserId String + ownerUserId String + scope KnowledgeItemScope + organizationId String? + destinationSynthesis KnowledgeSynthesis @relation(fields: [destinationSynthesisId, ownerUserId], references: [id, ownerUserId], onDelete: Restrict, onUpdate: Restrict) + destinationSynthesisId String @unique + destinationSynthesisVersion KnowledgeSynthesisVersion @relation(fields: [destinationSynthesisVersionId, destinationSynthesisId, destinationSynthesisVersionNumber], references: [id, synthesisId, version], onDelete: Restrict, onUpdate: Restrict) + destinationSynthesisVersionId String @unique + destinationSynthesisVersionNumber Int @default(1) + previewSchemaVersion Int @default(1) + selectionHash String + contentHash String + includesSharedCard Boolean @default(false) + selectedMessageCount Int + destinationGrantCount Int @default(0) + destinationGrantHash String? + version Int @default(1) + createdAt DateTime @default(now()) + createdBy String + selectedMessages KnowledgeThreadPromotionMessage[] + request KnowledgeThreadPromotionRequest? + synthesisSources KnowledgeSynthesisSource[] + + @@unique([id, promoterUserId]) + @@unique([id, sourceRoomId, sourceRootMessageId]) + @@index([sourceShareId, sourceShareVersion, createdAt, id]) + @@index([sourceRoomId, sourceRootMessageId, createdAt, id]) + @@index([ownerUserId, scope, createdAt, id]) + @@index([organizationId, scope, createdAt, id]) +} + +model KnowledgeThreadPromotionMessage { + id String @id @default(uuid()) + promotion KnowledgeThreadPromotion @relation(fields: [promotionId, sourceRoomId, sourceRootMessageId], references: [id, sourceRoomId, sourceRootMessageId], onDelete: Restrict, onUpdate: Restrict) + promotionId String + sourceRoomId String + sourceRootMessageId String + sourceMessage ChatMessage @relation("KnowledgeThreadPromotionSelectedMessage", fields: [sourceMessageId, sourceRoomId], references: [id, roomId], onDelete: Restrict, onUpdate: NoAction) + sourceMessageId String + sourceActivitySequence BigInt + sourceMessageCreatedAt DateTime + ordinal Int + authorCategory KnowledgeThreadPromotionAuthorCategory + content String @db.Text + contentHash String + createdAt DateTime @default(now()) + createdBy String + + @@unique([promotionId, ordinal]) + @@unique([promotionId, sourceMessageId]) + @@index([sourceMessageId, sourceRoomId]) + @@index([sourceRootMessageId, sourceRoomId, ordinal]) +} + +model KnowledgeThreadPromotionRequest { + id String @id @default(uuid()) + promoterUserId String + requestKeyHash String + requestPayloadHash String + promotion KnowledgeThreadPromotion @relation(fields: [promotionId, promoterUserId], references: [id, promoterUserId], onDelete: Restrict, onUpdate: Restrict) + promotionId String @unique + createdAt DateTime @default(now()) + createdBy String + + @@unique([promoterUserId, requestKeyHash]) + @@unique([promotionId, promoterUserId]) +} + +model KnowledgeSynthesisGroupGrant { + id String @id @default(uuid()) + synthesis KnowledgeSynthesis @relation(fields: [synthesisId], references: [id], onDelete: Restrict, onUpdate: Restrict) + synthesisId String + groupAccount GroupAccount @relation(fields: [groupAccountId], references: [id], onDelete: Restrict, onUpdate: Restrict) + groupAccountId String + revokedAt DateTime? + revokedBy String? + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + + @@unique([synthesisId, groupAccountId]) + @@index([groupAccountId, revokedAt, synthesisId]) + @@index([synthesisId, revokedAt, groupAccountId]) +} + model ReassignmentLog { id String @id @default(uuid()) targetTable String @@ -3089,18 +3198,19 @@ model UserGroup { } model GroupAccount { - id String @id @default(uuid()) - externalId String? @unique - displayName String - scimMeta Json? - active Boolean @default(true) - createdAt DateTime @default(now()) - createdBy String? - updatedAt DateTime @updatedAt - updatedBy String? - memberships UserGroup[] - knowledgeItemGrants KnowledgeItemGroupGrant[] - knowledgeLabelGrants KnowledgeLabelGroupGrant[] + id String @id @default(uuid()) + externalId String? @unique + displayName String + scimMeta Json? + active Boolean @default(true) + createdAt DateTime @default(now()) + createdBy String? + updatedAt DateTime @updatedAt + updatedBy String? + memberships UserGroup[] + knowledgeItemGrants KnowledgeItemGroupGrant[] + knowledgeLabelGrants KnowledgeLabelGroupGrant[] + knowledgeSynthesisGrants KnowledgeSynthesisGroupGrant[] @@index([displayName]) } diff --git a/packages/backend/scripts/knowledge-thread-promotion-application-integration.mjs b/packages/backend/scripts/knowledge-thread-promotion-application-integration.mjs new file mode 100644 index 00000000..0a999f98 --- /dev/null +++ b/packages/backend/scripts/knowledge-thread-promotion-application-integration.mjs @@ -0,0 +1,673 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +import pg from 'pg'; + +if (process.env.KNOWLEDGE_THREAD_PROMOTION_SCHEMA_INTEGRATION_CONFIRM !== '1') { + throw new Error('knowledge_thread_promotion_integration_not_confirmed'); +} +if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required'); + +const [ + { PrismaKnowledgeThreadPromotionAdapter }, + { PrismaKnowledgeSynthesisRepository }, + { createKnowledgeThreadPromotionUseCases }, + { createKnowledgeThreadPromotionTokenCodec }, + { createSynthesisAccessContext }, + { prisma }, +] = await Promise.all([ + import('../dist/adapters/knowledge/prismaKnowledgeThreadPromotionAdapter.js'), + import('../dist/adapters/knowledge/prismaKnowledgeProvenanceAdapter.js'), + import('../dist/application/knowledge/knowledgeThreadPromotionUseCases.js'), + import('../dist/application/knowledge/knowledgeThreadPromotionToken.js'), + import('../dist/application/knowledge/knowledgeSynthesisAccessContext.js'), + import('../dist/services/db.js'), +]); + +const { Client } = pg; +const client = new Client({ connectionString: process.env.DATABASE_URL }); +const ids = Object.fromEntries( + [ + 'actor', + 'viewer', + 'group', + 'actorMembership', + 'viewerMembership', + 'room', + 'actorRoomMember', + 'viewerRoomMember', + 'root', + 'selectedReply', + 'unselectedReply', + 'item', + 'share', + ].map((name) => [name, randomUUID()]), +); +const organizationId = `synthetic-org-${randomUUID()}`; +const now = new Date(); +const hash = (character) => character.repeat(64); +const selectedContent = 'Selected promotion content'; +const unselectedCanary = 'UNSELECTED_PROMOTION_CANARY_DO_NOT_COPY'; +const rollbackCanary = 'ROLLBACK_PROMOTION_CANARY_DO_NOT_PERSIST'; +const auditActor = { + principalUserId: ids.actor, + actorUserId: ids.actor, + requestId: 'knowledge-thread-promotion-integration', + source: 'api', +}; +const actor = { + userId: ids.actor, + organizationId, + groupAccountIds: [ids.group], + chat: { + userId: ids.actor, + roles: ['user'], + projectIds: [], + groupIds: [], + groupAccountIds: [ids.group], + }, +}; +const viewer = { + userId: ids.viewer, + organizationId, + groupAccountIds: [ids.group], + chat: { + userId: ids.viewer, + roles: ['user'], + projectIds: [], + groupIds: [], + groupAccountIds: [ids.group], + }, +}; + +await client.connect(); +try { + await client.query('BEGIN'); + for (const [id, userName] of [ + [ids.actor, `promotion-actor-${ids.actor}`], + [ids.viewer, `promotion-viewer-${ids.viewer}`], + ]) { + await client.query( + `INSERT INTO "UserAccount" ( + "id", "externalId", "userName", "active", "organization", + "createdAt", "updatedAt" + ) VALUES ($1, $1, $2, true, $3, $4, $4)`, + [id, userName, organizationId, now], + ); + } + await client.query( + `INSERT INTO "GroupAccount" ( + "id", "displayName", "active", "createdAt", "updatedAt" + ) VALUES ($1, 'Synthetic promotion group', true, $2, $2)`, + [ids.group, now], + ); + await client.query( + `INSERT INTO "UserGroup" ("id", "userId", "groupId", "createdAt") + VALUES ($1, $2, $3, $4), ($5, $6, $3, $4)`, + [ + ids.actorMembership, + ids.actor, + ids.group, + now, + ids.viewerMembership, + ids.viewer, + ], + ); + await client.query( + `INSERT INTO "ChatRoom" ( + "id", "type", "name", "allowExternalUsers", + "allowExternalIntegrations", "createdAt", "updatedAt" + ) VALUES ($1, 'private_group', 'Synthetic promotion room', false, false, $2, $2)`, + [ids.room, now], + ); + await client.query( + `INSERT INTO "ChatRoomMember" ( + "id", "roomId", "userId", "role", "createdAt", "updatedAt" + ) VALUES ($1, $2, $3, 'member', $4, $4), + ($5, $2, $6, 'member', $4, $4)`, + [ + ids.actorRoomMember, + ids.room, + ids.actor, + now, + ids.viewerRoomMember, + ids.viewer, + ], + ); + await client.query( + `INSERT INTO "ChatMessage" ( + "id", "roomId", "messageType", "userId", "body", + "mentionsAll", "createdAt", "updatedAt" + ) VALUES ($1, $2, 'text', $3, 'Knowledge was shared.', false, $4, $4)`, + [ids.root, ids.room, ids.actor, now], + ); + await client.query( + `INSERT INTO "ChatMessage" ( + "id", "roomId", "messageType", "parentMessageId", "threadRootId", + "userId", "body", "mentionsAll", "createdAt", "updatedAt" + ) VALUES + ($1, $2, 'text', $3, $3, $4, $5, false, $6, $6), + ($7, $2, 'text', $3, $3, $4, $8, false, $6, $6)`, + [ + ids.selectedReply, + ids.room, + ids.root, + ids.actor, + selectedContent, + now, + ids.unselectedReply, + unselectedCanary, + ], + ); + const item = await client.query( + `INSERT INTO "KnowledgeItem" ( + "id", "ownerUserId", "scope", "sourceType", "title", + "status", "version", "createdAt", "updatedAt" + ) VALUES ($1, $2, 'personal', 'manual', 'Private source canary', + 'inbox', 1, $3, $3) + RETURNING "updatedAt"`, + [ids.item, ids.actor, now], + ); + await client.query( + `INSERT INTO "KnowledgeShare" ( + "id", "sourceKnowledgeItemId", "sourceOwnerUserId", "sharerUserId", + "chatPosterUserId", "destinationRoomId", "requestKeyHash", + "requestPayloadHash", "selectionHash", "contentHash", + "sourceItemVersion", "sourceItemUpdatedAt", "selectedTitle", + "createdAt", "createdBy", "updatedAt", "updatedBy" + ) VALUES ( + $1, $2, $3, $3, $3, $4, $5, $6, $7, $8, + 1, $9, 'Private source canary', $10, $3, $10, $3 + )`, + [ + ids.share, + ids.item, + ids.actor, + ids.room, + hash('1'), + hash('2'), + hash('3'), + hash('4'), + item.rows[0].updatedAt, + now, + ], + ); + await client.query( + `UPDATE "KnowledgeShare" + SET "status" = 'posted', "chatMessageId" = $2, + "postedAt" = $3, "version" = 2, + "updatedAt" = $3, "updatedBy" = $4 + WHERE "id" = $1`, + [ids.share, ids.root, now, ids.actor], + ); + await client.query('COMMIT'); + + const tokenCodec = createKnowledgeThreadPromotionTokenCodec({ + env: { + NODE_ENV: 'test', + KNOWLEDGE_CURSOR_SIGNING_SECRET: + 'synthetic-promotion-integration-secret-at-least-32-bytes', + }, + }); + const store = new PrismaKnowledgeThreadPromotionAdapter(); + const service = createKnowledgeThreadPromotionUseCases({ store, tokenCodec }); + const request = { + selectedReplyMessageIds: [ids.selectedReply], + includeSharedCard: false, + destination: { + scope: 'organization', + organizationGroupAccountIds: [ids.group], + }, + synthesis: { + title: 'Selected thread synthesis', + content: selectedContent, + confidenceBasisPoints: 7500, + unresolvedQuestions: ['Synthetic unresolved question'], + }, + }; + const preview = await service.preview({ + actor, + auditActor, + rootMessageId: ids.root, + body: request, + }); + assert.equal(preview.ok, true); + assert.equal(preview.value.selectedMessages.length, 1); + assert.deepEqual( + { + ...preview.value.selectedMessages[0], + createdAt: '', + }, + { + ordinal: 0, + content: selectedContent, + createdAt: '', + authorCategory: 'user', + }, + ); + assert.equal( + Number.isNaN(Date.parse(preview.value.selectedMessages[0].createdAt)), + false, + ); + assert.equal(JSON.stringify(preview.value).includes(unselectedCanary), false); + + const commitInput = { + actor, + auditActor, + rootMessageId: ids.root, + body: { + ...request, + previewToken: preview.value.previewToken, + requestKey: 'synthetic-promotion-request', + confirmed: true, + organizationAudienceConfirmed: true, + }, + }; + const committed = await service.commit(commitInput); + assert.equal(committed.ok, true); + assert.equal(committed.value.created, true); + assert.equal(committed.value.selectedMessageCount, 1); + assert.equal(committed.value.includesSharedCard, false); + + const replay = await service.commit(commitInput); + assert.equal(replay.ok, true); + assert.equal(replay.value.created, false); + assert.equal(replay.value.reused, true); + assert.equal(replay.value.promotionId, committed.value.promotionId); + assert.equal( + replay.value.synthesisVersionId, + committed.value.synthesisVersionId, + ); + + const repeatedPreview = await service.preview({ + actor, + auditActor, + rootMessageId: ids.root, + body: request, + }); + assert.equal(repeatedPreview.ok, true); + const repeatedPreviewReplay = await service.commit({ + ...commitInput, + body: { + ...commitInput.body, + previewToken: repeatedPreview.value.previewToken, + }, + }); + assert.equal(repeatedPreviewReplay.ok, true); + assert.equal(repeatedPreviewReplay.value.created, false); + assert.equal(repeatedPreviewReplay.value.reused, true); + assert.equal( + repeatedPreviewReplay.value.promotionId, + committed.value.promotionId, + ); + + const differentKeySamePreview = await service.commit({ + ...commitInput, + body: { + ...commitInput.body, + requestKey: 'synthetic-promotion-request-different-key', + }, + }); + assert.deepEqual(differentKeySamePreview, { + ok: false, + statusCode: 409, + code: 'promotion_conflict', + message: 'Promotion conflict', + }); + + const differentKeyRaceRequest = { + ...request, + synthesis: { + ...request.synthesis, + title: 'Different request-key race synthesis', + }, + }; + const differentKeyRacePreview = await service.preview({ + actor, + auditActor, + rootMessageId: ids.root, + body: differentKeyRaceRequest, + }); + assert.equal(differentKeyRacePreview.ok, true); + const differentKeyRaceBase = { + actor, + auditActor, + rootMessageId: ids.root, + body: { + ...differentKeyRaceRequest, + previewToken: differentKeyRacePreview.value.previewToken, + confirmed: true, + organizationAudienceConfirmed: true, + }, + }; + const differentKeyRaceResults = await Promise.all([ + service.commit({ + ...differentKeyRaceBase, + body: { ...differentKeyRaceBase.body, requestKey: 'race-key-a' }, + }), + service.commit({ + ...differentKeyRaceBase, + body: { ...differentKeyRaceBase.body, requestKey: 'race-key-b' }, + }), + ]); + assert.equal( + differentKeyRaceResults.filter((result) => result.ok).length, + 1, + ); + assert.deepEqual( + differentKeyRaceResults.find((result) => !result.ok), + { + ok: false, + statusCode: 409, + code: 'promotion_conflict', + message: 'Promotion conflict', + }, + ); + const differentKeyRacePersisted = await client.query( + `SELECT COUNT(DISTINCT s."id")::int AS "synthesisCount", + COUNT(DISTINCT v."id")::int AS "versionCount", + COUNT(DISTINCT p."id")::int AS "promotionCount", + COUNT(DISTINCT r."id")::int AS "requestCount" + FROM "KnowledgeSynthesis" s + LEFT JOIN "KnowledgeSynthesisVersion" v + ON v."synthesisId" = s."id" + LEFT JOIN "KnowledgeThreadPromotion" p + ON p."destinationSynthesisId" = s."id" + LEFT JOIN "KnowledgeThreadPromotionRequest" r + ON r."promotionId" = p."id" + WHERE s."title" = $1`, + [differentKeyRaceRequest.synthesis.title], + ); + assert.deepEqual(differentKeyRacePersisted.rows[0], { + synthesisCount: 1, + versionCount: 1, + promotionCount: 1, + requestCount: 1, + }); + + const concurrentRequest = { + ...request, + synthesis: { + ...request.synthesis, + title: 'Concurrent selected thread synthesis', + }, + }; + const concurrentPreview = await service.preview({ + actor, + auditActor, + rootMessageId: ids.root, + body: concurrentRequest, + }); + assert.equal(concurrentPreview.ok, true); + const concurrentCommitInput = { + actor, + auditActor, + rootMessageId: ids.root, + body: { + ...concurrentRequest, + previewToken: concurrentPreview.value.previewToken, + requestKey: 'synthetic-concurrent-promotion-request', + confirmed: true, + organizationAudienceConfirmed: true, + }, + }; + const concurrentResults = await Promise.all([ + service.commit(concurrentCommitInput), + service.commit(concurrentCommitInput), + ]); + assert.equal( + concurrentResults.every((result) => result.ok), + true, + ); + assert.equal( + new Set(concurrentResults.map((result) => result.value.promotionId)).size, + 1, + ); + assert.deepEqual( + concurrentResults.map((result) => result.value.created).sort(), + [false, true], + ); + + const conflict = await service.commit({ + ...commitInput, + body: { + ...commitInput.body, + synthesis: { ...request.synthesis, content: 'Conflicting content' }, + }, + }); + assert.deepEqual(conflict, { + ok: false, + statusCode: 409, + code: 'idempotency_conflict', + message: 'Idempotency conflict', + }); + + const persisted = await client.query( + `SELECT p."selectedMessageCount", p."includesSharedCard", + COUNT(m."id")::int AS "messageCount", + STRING_AGG(m."content", E'\n') AS "selectedContent" + FROM "KnowledgeThreadPromotion" p + JOIN "KnowledgeThreadPromotionMessage" m ON m."promotionId" = p."id" + WHERE p."id" = $1 + GROUP BY p."selectedMessageCount", p."includesSharedCard"`, + [committed.value.promotionId], + ); + assert.deepEqual(persisted.rows[0], { + selectedMessageCount: 1, + includesSharedCard: false, + messageCount: 1, + selectedContent, + }); + const privateSurface = await client.query( + `SELECT CONCAT_WS(E'\n', + COALESCE(p."contentHash", ''), COALESCE(p."selectionHash", ''), + COALESCE(m."content", ''), COALESCE(s."content", ''), + COALESCE(a."metadata"::text, '')) AS material + FROM "KnowledgeThreadPromotion" p + JOIN "KnowledgeThreadPromotionMessage" m ON m."promotionId" = p."id" + JOIN "KnowledgeSynthesisVersion" s ON s."id" = p."destinationSynthesisVersionId" + LEFT JOIN "AuditLog" a ON a."targetId" = p."id" + WHERE p."id" = $1`, + [committed.value.promotionId], + ); + assert.equal( + privateSurface.rows.some((row) => row.material.includes(unselectedCanary)), + false, + ); + assert.equal( + privateSurface.rows.some((row) => + row.material.includes('Private source canary'), + ), + false, + ); + + const synthesisRepository = new PrismaKnowledgeSynthesisRepository(); + const visibleBeforeRoomLoss = await synthesisRepository.findVisible({ + actor: viewer, + synthesisId: committed.value.synthesisId, + accessContext: createSynthesisAccessContext(), + }); + assert.ok(visibleBeforeRoomLoss); + assert.equal( + visibleBeforeRoomLoss.currentVersion.sources[0].accessible, + true, + ); + assert.equal( + visibleBeforeRoomLoss.currentVersion.sources[0].sourceId, + committed.value.promotionId, + ); + + await client.query( + `UPDATE "ChatRoom" + SET "allowExternalUsers" = true, "updatedAt" = $2 + WHERE "id" = $1`, + [ids.room, new Date(now.getTime() + 1)], + ); + const visibleAfterRoomExternalization = + await synthesisRepository.findVisible({ + actor: viewer, + synthesisId: committed.value.synthesisId, + accessContext: createSynthesisAccessContext(), + }); + assert.ok(visibleAfterRoomExternalization); + assert.equal( + visibleAfterRoomExternalization.currentVersion.sources[0].accessible, + false, + ); + assert.equal( + visibleAfterRoomExternalization.currentVersion.sources[0].sourceId, + null, + ); + await client.query( + `UPDATE "ChatRoom" + SET "allowExternalUsers" = false, "updatedAt" = $2 + WHERE "id" = $1`, + [ids.room, new Date(now.getTime() + 2)], + ); + + await client.query( + `UPDATE "ChatRoomMember" + SET "deletedAt" = $2, "updatedAt" = $2 + WHERE "id" = $1`, + [ids.viewerRoomMember, new Date(now.getTime() + 1)], + ); + const visibleAfterRoomLoss = await synthesisRepository.findVisible({ + actor: viewer, + synthesisId: committed.value.synthesisId, + accessContext: createSynthesisAccessContext(), + }); + assert.ok(visibleAfterRoomLoss); + assert.equal(visibleAfterRoomLoss.currentVersion.content, selectedContent); + assert.equal( + visibleAfterRoomLoss.currentVersion.sources[0].accessible, + false, + ); + assert.equal(visibleAfterRoomLoss.currentVersion.sources[0].sourceId, null); + + const revokedAt = new Date(Date.now() + 1_000); + await client.query( + `UPDATE "KnowledgeSynthesisGroupGrant" + SET "revokedAt" = $2, "revokedBy" = $3, + "updatedAt" = $2, "updatedBy" = $3 + WHERE "synthesisId" = $1`, + [committed.value.synthesisId, revokedAt, ids.actor], + ); + const hiddenAfterGrantRevoke = await synthesisRepository.findVisible({ + actor: viewer, + synthesisId: committed.value.synthesisId, + accessContext: createSynthesisAccessContext(), + }); + assert.equal(hiddenAfterGrantRevoke, null); + + const beforeRollback = await client.query( + `SELECT COUNT(*)::int AS count FROM "KnowledgeThreadPromotion"`, + ); + const failingHost = { + $transaction(operation, options) { + return prisma.$transaction((transaction) => { + const failingTransaction = new Proxy(transaction, { + get(target, property) { + if (property === 'auditLog') { + return { + create: async () => { + throw new Error('synthetic_mandatory_audit_failure'); + }, + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return operation(failingTransaction); + }, options); + }, + }; + const rollbackService = createKnowledgeThreadPromotionUseCases({ + store: new PrismaKnowledgeThreadPromotionAdapter(failingHost), + tokenCodec, + }); + const rollbackRequest = { + ...request, + synthesis: { ...request.synthesis, content: rollbackCanary }, + }; + const rollbackPreview = await service.preview({ + actor, + auditActor, + rootMessageId: ids.root, + body: rollbackRequest, + }); + assert.equal(rollbackPreview.ok, true); + await assert.rejects( + () => + rollbackService.commit({ + actor, + auditActor, + rootMessageId: ids.root, + body: { + ...rollbackRequest, + previewToken: rollbackPreview.value.previewToken, + requestKey: 'synthetic-promotion-rollback-request', + confirmed: true, + organizationAudienceConfirmed: true, + }, + }), + /synthetic_mandatory_audit_failure/, + ); + const afterRollback = await client.query( + `SELECT COUNT(*)::int AS count FROM "KnowledgeThreadPromotion"`, + ); + assert.equal(afterRollback.rows[0].count, beforeRollback.rows[0].count); + const rollbackRows = await client.query( + `SELECT COUNT(*)::int AS count + FROM "KnowledgeSynthesisVersion" + WHERE "content" = $1`, + [rollbackCanary], + ); + assert.equal(rollbackRows.rows[0].count, 0); + + const shareRevokedAt = new Date(Date.now() + 2_000); + await client.query( + `UPDATE "KnowledgeShare" + SET "status" = 'revoked', "revokedAt" = $2, "revokedBy" = $3, + "version" = 3, "updatedAt" = $2, "updatedBy" = $3 + WHERE "id" = $1`, + [ids.share, shareRevokedAt, ids.actor], + ); + const visibleAfterShareRevoke = await synthesisRepository.findVisible({ + actor, + synthesisId: committed.value.synthesisId, + accessContext: createSynthesisAccessContext(), + }); + assert.ok(visibleAfterShareRevoke); + assert.equal(visibleAfterShareRevoke.currentVersion.content, selectedContent); + assert.equal( + visibleAfterShareRevoke.currentVersion.sources[0].accessible, + false, + ); + assert.equal( + visibleAfterShareRevoke.currentVersion.sources[0].sourceId, + null, + ); + + console.log( + JSON.stringify({ + result: 'PASS', + selectedOnly: true, + idempotentReplay: true, + repeatedPreviewReplay: true, + differentKeySamePreviewConflicts: true, + concurrentDifferentKeyDeterministic: true, + concurrentReplayConverges: true, + idempotencyConflict: true, + organizationGrant: true, + revokedGrantDenied: true, + roomLossRedactsProvenanceOnly: true, + externalizedRoomRedactsProvenanceOnly: true, + shareRevokeRedactsProvenanceOnly: true, + auditFailureRollsBack: true, + }), + ); +} finally { + await prisma.$disconnect(); + await client.end(); +} diff --git a/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs b/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs new file mode 100644 index 00000000..35dd725b --- /dev/null +++ b/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs @@ -0,0 +1,658 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const baseline = process.env.KNOWLEDGE_THREAD_PROMOTION_OLD_APP_BASE_SHA; +const oldRoot = process.env.OLD_APP_ROOT; +const currentRoot = process.env.CURRENT_APP_ROOT; +const mode = process.env.KNOWLEDGE_SHARE_OLD_APP_MODE; + +function parseDatabaseUrl(value) { + try { + return value ? new URL(value) : null; + } catch { + return null; + } +} + +const databaseUrl = parseDatabaseUrl(process.env.DATABASE_URL); +if ( + process.env.KNOWLEDGE_THREAD_PROMOTION_OLD_APP_CONFIRM !== '1' || + baseline !== 'b720f575140e533c59070d33723146925faad489' || + !oldRoot || + !currentRoot || + !['seed', 'current-create', 'old-after', 'current-after'].includes(mode) || + !databaseUrl || + !['127.0.0.1', 'localhost'].includes(databaseUrl.hostname) || + databaseUrl.pathname !== '/erp4_knowledge_thread_promotion_old_app_test' +) { + throw new Error( + 'Refusing to run outside the confirmed loopback Knowledge thread promotion old-app database', + ); +} + +const oldLoad = (path) => import(pathToFileURL(`${oldRoot}/${path}`).href); +const oldRequire = createRequire( + pathToFileURL(`${oldRoot}/packages/backend/package.json`), +); +const currentRequire = createRequire( + pathToFileURL(`${currentRoot}/packages/backend/package.json`), +); + +const ownerId = 'share-old-app-owner'; +const viewerId = 'share-old-app-viewer'; +const roomId = 'share-old-app-room'; +const sourceItemId = 'share-old-app-source-item'; +const legacyMessageId = 'share-old-app-legacy-message'; +const shareId = '11111111-2222-4333-8444-555555555555'; +const shareMessageId = shareId; +const oldWriteMessageId = 'share-old-app-post-migration-message'; +const genericBody = 'Knowledge was shared.'; +const privateCanary = 'PRIVATE-SHARE-CONTENT-MUST-NOT-LEAK'; +const hex = (value) => value.repeat(64); + +function createPrisma(requireFromRoot) { + const { PrismaClient } = requireFromRoot('@prisma/client'); + const { PrismaPg } = requireFromRoot('@prisma/adapter-pg'); + return new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); +} + +function currentPrisma() { + return createPrisma(currentRequire); +} + +function oldPrisma() { + return createPrisma(oldRequire); +} + +if (mode === 'seed') { + const prisma = oldPrisma(); + try { + await prisma.chatRoom.create({ + data: { + id: roomId, + type: 'private_group', + name: 'Synthetic Knowledge share compatibility room', + isOfficial: false, + }, + }); + await prisma.chatRoomMember.createMany({ + data: [ + { roomId, userId: ownerId }, + { roomId, userId: viewerId }, + ], + }); + const legacy = await prisma.chatMessage.create({ + data: { + id: legacyMessageId, + roomId, + userId: ownerId, + body: 'Synthetic legacy root message', + createdAt: new Date('2026-08-10T00:00:00.000Z'), + }, + select: { activitySequence: true }, + }); + await prisma.chatReadState.create({ + data: { + roomId, + userId: viewerId, + lastReadAt: new Date('2026-08-10T00:00:00.000Z'), + lastReadMessageId: legacyMessageId, + lastReadActivitySequence: legacy.activitySequence, + }, + }); + await prisma.knowledgeItem.create({ + data: { + id: sourceItemId, + ownerUserId: ownerId, + scope: 'personal', + sourceType: 'manual', + title: privateCanary, + status: 'inbox', + version: 1, + createdBy: ownerId, + updatedBy: ownerId, + }, + }); + console.log(JSON.stringify({ mode, result: 'SEEDED', baseline })); + } finally { + await prisma.$disconnect(); + } +} else if (mode === 'current-create') { + const [ + { createKnowledgeShareUseCases }, + { createKnowledgeShareTokenCodec }, + { prismaKnowledgeShareAdapter }, + ] = await Promise.all([ + import( + pathToFileURL( + `${currentRoot}/packages/backend/dist/application/knowledge/knowledgeShareUseCases.js`, + ).href + ), + import( + pathToFileURL( + `${currentRoot}/packages/backend/dist/application/knowledge/knowledgeShareToken.js`, + ).href + ), + import( + pathToFileURL( + `${currentRoot}/packages/backend/dist/adapters/knowledge/prismaKnowledgeShareAdapter.js`, + ).href + ), + ]); + const prisma = currentPrisma(); + try { + const actor = { + userId: ownerId, + groupAccountIds: [], + }; + const chatActor = { + canonicalUserId: ownerId, + userId: ownerId, + roles: ['user'], + projectIds: [], + groupIds: [], + groupAccountIds: [], + }; + const auditActor = { + requestId: 'knowledge-share-old-app-current-create', + source: 'api', + principalUserId: ownerId, + actorUserId: ownerId, + }; + const selection = { + includeTitle: false, + includeSourceType: true, + includeCanonicalUrl: false, + snapshot: null, + labelAssignmentIds: [], + annotations: [], + conversationTurnIds: [], + syntheses: [], + sharerNote: null, + }; + const service = createKnowledgeShareUseCases({ + store: prismaKnowledgeShareAdapter, + chatIntegration: prismaKnowledgeShareAdapter, + tokenCodec: createKnowledgeShareTokenCodec({ + env: { NODE_ENV: 'test' }, + randomId: () => shareId, + }), + }); + const preview = await service.preview({ + actor, + chatActor, + auditActor, + itemId: sourceItemId, + body: { destinationRoomId: roomId, selection }, + }); + assert.equal(preview.ok, true, JSON.stringify(preview)); + const committed = await service.commit({ + actor, + chatActor, + auditActor, + itemId: sourceItemId, + body: { + destinationRoomId: roomId, + selection, + previewToken: preview.value.previewToken, + requestKey: 'synthetic-old-app-compat-request', + confirmed: true, + }, + }); + assert.equal(committed.ok, true, JSON.stringify(committed)); + assert.equal(committed.value.status, 'posted'); + assert.equal(committed.value.shareId, shareId); + assert.equal(committed.value.chatMessageId, shareMessageId); + const message = await prisma.chatMessage.findUniqueOrThrow({ + where: { id: shareMessageId }, + }); + assert.equal(message.messageType, 'text'); + assert.equal(message.body, genericBody); + const notification = await prisma.appNotification.findFirst({ + where: { + userId: viewerId, + kind: 'chat_message', + messageId: shareMessageId, + }, + }); + assert.ok(notification); + assert.equal(JSON.stringify(notification).includes(privateCanary), false); + const enumRows = await prisma.$queryRaw` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + WHERE pg_type.typname = 'ChatMessageType' + ORDER BY enumsortorder + `; + assert.deepEqual( + enumRows.map((row) => row.enumlabel), + ['text'], + ); + console.log( + JSON.stringify({ + mode, + result: 'PASS', + sideTableDiscriminator: true, + genericTextRoot: true, + }), + ); + } finally { + await prisma.$disconnect(); + } +} else if (mode === 'old-after') { + const [{ buildServer }] = await Promise.all([ + oldLoad('packages/backend/dist/server.js'), + ]); + const prisma = oldPrisma(); + let server; + try { + await prisma.chatMessage.create({ + data: { + id: oldWriteMessageId, + roomId, + userId: ownerId, + body: 'Synthetic old application write after migration', + createdAt: new Date('2026-08-10T00:02:00.000Z'), + }, + }); + + server = await buildServer({ logger: false }); + const headers = { 'x-user-id': viewerId, 'x-roles': 'user' }; + const health = await server.inject({ method: 'GET', url: '/healthz' }); + const ready = await server.inject({ method: 'GET', url: '/readyz' }); + assert.equal(health.statusCode, 200, health.body); + assert.equal(ready.statusCode, 200, ready.body); + + const timeline = await server.inject({ + method: 'GET', + url: `/chat-rooms/${roomId}/messages?limit=20`, + headers, + }); + assert.equal(timeline.statusCode, 200, timeline.body); + const timelineItems = timeline.json().items; + assert.ok(timelineItems.some((item) => item.id === legacyMessageId)); + const shareMessage = timelineItems.find( + (item) => item.id === shareMessageId, + ); + assert.ok(shareMessage); + assert.equal(shareMessage.messageType, 'text'); + assert.equal(shareMessage.body, genericBody); + assert.equal(shareMessage.parentMessageId, null); + assert.equal(shareMessage.threadRootId, null); + assert.doesNotMatch( + JSON.stringify(timeline.json()), + new RegExp(privateCanary), + ); + + const thread = await server.inject({ + method: 'GET', + url: `/chat-messages/${shareMessageId}/thread?limit=20`, + headers, + }); + assert.equal(thread.statusCode, 200, thread.body); + assert.equal(thread.json().root.id, shareMessageId); + assert.equal(thread.json().root.body, genericBody); + assert.deepEqual(thread.json().replies, []); + + const search = await server.inject({ + method: 'GET', + url: '/chat-messages/search?q=Knowledge&limit=20', + headers, + }); + assert.equal(search.statusCode, 200, search.body); + const searchItem = search + .json() + .items.find((item) => item.id === shareMessageId); + assert.ok(searchItem); + assert.equal(searchItem.body, genericBody); + assert.doesNotMatch( + JSON.stringify(search.json()), + new RegExp(privateCanary), + ); + + const unread = await server.inject({ + method: 'GET', + url: `/chat-rooms/${roomId}/unread`, + headers, + }); + assert.equal(unread.statusCode, 200, unread.body); + assert.ok(unread.json().unreadCount >= 1); + + const notifications = await server.inject({ + method: 'GET', + url: '/notifications?unread=1&limit=200', + headers, + }); + assert.equal(notifications.statusCode, 200, notifications.body); + const notification = notifications + .json() + .items.find((item) => item.messageId === shareMessageId); + assert.ok(notification); + assert.doesNotMatch( + JSON.stringify(notifications.json()), + new RegExp(privateCanary), + ); + + console.log( + JSON.stringify({ + mode, + result: 'PASS', + timeline: true, + thread: true, + search: true, + unread: true, + notification: true, + healthReadiness: true, + oldWrite: true, + }), + ); + } finally { + if (server) await server.close(); + await prisma.$disconnect(); + } +} else { + const [ + { createPrismaKnowledgeShareAdapter }, + { createPrismaChatThreadRepository }, + ] = await Promise.all([ + import( + pathToFileURL( + `${currentRoot}/packages/backend/dist/adapters/knowledge/prismaKnowledgeShareAdapter.js`, + ).href + ), + import( + pathToFileURL( + `${currentRoot}/packages/backend/dist/adapters/chat/prismaChatThreadAdapter.js`, + ).href + ), + ]); + const prisma = currentPrisma(); + try { + const share = await prisma.knowledgeShare.findUniqueOrThrow({ + where: { id: shareId }, + include: { chatMessage: true }, + }); + assert.equal(share.status, 'posted'); + assert.equal(share.chatMessageId, shareMessageId); + assert.equal(share.chatMessage.messageType, 'text'); + assert.equal(share.chatMessage.body, genericBody); + assert.equal(share.selectedTitle, null); + assert.equal(share.selectedCanonicalUrl, null); + assert.equal(share.selectedSharerNote, null); + + const viewerChatActor = { + canonicalUserId: viewerId, + userId: viewerId, + roles: ['user'], + projectIds: [], + groupIds: [], + groupAccountIds: [], + }; + const card = await createPrismaKnowledgeShareAdapter({ + $transaction: (...args) => prisma.$transaction(...args), + }).readRoomCard({ + actor: { userId: viewerId, groupAccountIds: [] }, + chatActor: viewerChatActor, + messageId: shareMessageId, + }); + assert.equal(card.ok, true, JSON.stringify(card)); + assert.equal(card.value.status, 'posted'); + assert.equal(card.value.canOpenSource, false); + assert.equal(card.value.card.sourceType, 'manual'); + assert.equal(card.value.card.title, undefined); + assert.equal(JSON.stringify(card.value).includes(privateCanary), false); + + const summaries = await createPrismaChatThreadRepository( + prisma, + ).listKnowledgeShareSummaries({ + actor: viewerChatActor, + roomId, + messageIds: [legacyMessageId, shareMessageId, oldWriteMessageId], + }); + assert.ok(summaries); + const currentShareRoot = summaries.find( + (message) => message.messageId === shareMessageId, + ); + assert.ok(currentShareRoot); + assert.deepEqual(currentShareRoot, { + messageId: shareMessageId, + shareId, + status: 'posted', + version: 2, + schemaVersion: 1, + }); + + const oldWrite = await prisma.chatMessage.findUniqueOrThrow({ + where: { id: oldWriteMessageId }, + }); + assert.equal(oldWrite.messageType, 'text'); + assert.equal(oldWrite.parentMessageId, null); + assert.equal(oldWrite.threadRootId, null); + + const counts = await Promise.all([ + prisma.knowledgeShare.count({ where: { id: shareId } }), + prisma.chatMessage.count({ where: { id: shareMessageId } }), + ]); + assert.deepEqual(counts, [1, 1]); + + const source = await prisma.knowledgeItem.findUniqueOrThrow({ + where: { id: sourceItemId }, + select: { version: true, updatedAt: true }, + }); + const pendingData = ( + id, + requestHash, + selectionHash, + contentHash, + includeScalar = true, + ) => ({ + id, + sourceKnowledgeItemId: sourceItemId, + sourceOwnerUserId: ownerId, + sharerUserId: ownerId, + chatPosterUserId: ownerId, + destinationRoomId: roomId, + selectionSchemaVersion: 1, + requestKeyHash: requestHash, + requestPayloadHash: requestHash, + selectionHash, + contentHash, + sourceItemVersion: source.version, + sourceItemUpdatedAt: source.updatedAt, + ...(includeScalar ? { selectedSourceType: 'manual' } : {}), + createdBy: ownerId, + updatedBy: ownerId, + }); + + await assert.rejects( + prisma.$executeRaw` + INSERT INTO "KnowledgeShare" ( + "id", "sourceKnowledgeItemId", "sourceOwnerUserId", "sharerUserId", + "chatPosterUserId", "destinationRoomId", "selectionSchemaVersion", "requestKeyHash", + "requestPayloadHash", "selectionHash", "contentHash", + "sourceItemVersion", "sourceItemUpdatedAt", "createdBy", "updatedAt", + "updatedBy" + ) VALUES ( + 'share-old-app-empty-selection', ${sourceItemId}, ${ownerId}, ${ownerId}, + ${ownerId}, ${roomId}, 1, ${hex('d')}, ${hex('d')}, ${hex('e')}, ${hex('f')}, + ${source.version}, ${source.updatedAt}, ${ownerId}, CURRENT_TIMESTAMP, + ${ownerId} + ) + `, + ); + + const annotationId = 'share-old-app-stale-annotation'; + const annotationRevisionId = 'share-old-app-stale-annotation-revision'; + const annotationShareId = '22222222-3333-4444-8555-666666666666'; + await prisma.knowledgeAnnotation.create({ + data: { + id: annotationId, + knowledgeItemId: sourceItemId, + ownerUserId: ownerId, + authorUserId: ownerId, + scope: 'personal', + kind: 'note', + origin: 'user', + currentRevision: 2, + createdBy: ownerId, + updatedBy: ownerId, + revisions: { + create: [ + { + id: annotationRevisionId, + revision: 1, + kind: 'note', + origin: 'user', + content: 'Synthetic superseded annotation revision', + createdBy: ownerId, + }, + { + id: 'share-old-app-current-annotation-revision', + revision: 2, + kind: 'note', + origin: 'user', + content: 'Synthetic current annotation revision', + createdBy: ownerId, + }, + ], + }, + }, + }); + await prisma.knowledgeShare.create({ + data: pendingData(annotationShareId, hex('5'), hex('6'), hex('7')), + }); + await assert.rejects( + prisma.knowledgeShareAnnotationSnapshot.create({ + data: { + shareId: annotationShareId, + sourceKnowledgeItemId: sourceItemId, + sourceOwnerUserId: ownerId, + sourceAnnotationId: annotationId, + sourceRevisionId: annotationRevisionId, + revision: 1, + kind: 'note', + origin: 'user', + content: 'Synthetic superseded annotation revision', + ordinal: 0, + contentHash: hex('8'), + createdBy: ownerId, + }, + }), + ); + + const nestedShareId = '44444444-5555-4666-8777-888888888888'; + await prisma.$transaction(async (tx) => { + await tx.knowledgeShare.create({ + data: pendingData(nestedShareId, hex('0'), hex('1'), hex('2'), false), + }); + await tx.knowledgeShareAnnotationSnapshot.create({ + data: { + shareId: nestedShareId, + sourceKnowledgeItemId: sourceItemId, + sourceOwnerUserId: ownerId, + sourceAnnotationId: annotationId, + sourceRevisionId: 'share-old-app-current-annotation-revision', + revision: 2, + kind: 'note', + origin: 'user', + content: 'Synthetic current annotation revision', + ordinal: 0, + contentHash: hex('2'), + createdBy: ownerId, + }, + }); + }); + + const synthesisId = 'share-old-app-stale-synthesis'; + const synthesisVersionId = 'share-old-app-stale-synthesis-version'; + const synthesisShareId = '33333333-4444-4555-8666-777777777777'; + await prisma.knowledgeSynthesis.create({ + data: { + id: synthesisId, + ownerUserId: ownerId, + scope: 'personal', + title: 'Synthetic synthesis', + currentVersion: 2, + createdBy: ownerId, + updatedBy: ownerId, + versions: { + create: [ + { + id: synthesisVersionId, + version: 1, + content: 'Synthetic superseded synthesis', + unresolvedQuestions: [], + createdBy: ownerId, + }, + { + id: 'share-old-app-current-synthesis-version', + version: 2, + content: 'Synthetic current synthesis', + unresolvedQuestions: [], + createdBy: ownerId, + }, + ], + }, + }, + }); + await prisma.knowledgeShare.create({ + data: pendingData(synthesisShareId, hex('9'), hex('a'), hex('b')), + }); + await assert.rejects( + prisma.knowledgeShareSynthesisSnapshot.create({ + data: { + shareId: synthesisShareId, + sourceKnowledgeItemId: sourceItemId, + sourceOwnerUserId: ownerId, + sourceSynthesisId: synthesisId, + sourceSynthesisVersionId: synthesisVersionId, + version: 1, + title: 'Synthetic synthesis', + content: 'Synthetic superseded synthesis', + unresolvedQuestions: [], + ordinal: 0, + contentHash: hex('c'), + createdBy: ownerId, + }, + }), + ); + + await assert.rejects( + prisma.auditLog.create({ + data: { + action: 'knowledge_share_posted', + targetTable: 'chat_messages', + targetId: shareId, + }, + }), + ); + await prisma.auditLog.create({ + data: { + action: 'knowledge_share_posted', + targetTable: 'knowledge_shares', + targetId: shareId, + }, + }); + console.log( + JSON.stringify({ + mode, + result: 'PASS', + postedReferenceRetained: true, + oldApplicationDefaultRetained: true, + selectedPrivateCanaryStored: false, + roomOnlyCardRead: true, + compactTimelineDiscriminator: true, + staleAnnotationRevisionRejected: true, + staleSynthesisVersionRejected: true, + auditTargetConstraint: true, + directEmptySelectionRejected: true, + deferredNestedSelectionAccepted: true, + }), + ); + } finally { + await prisma.$disconnect(); + } +} diff --git a/packages/backend/scripts/knowledge-thread-promotion-schema-integration.mjs b/packages/backend/scripts/knowledge-thread-promotion-schema-integration.mjs new file mode 100644 index 00000000..c3a3e5fa --- /dev/null +++ b/packages/backend/scripts/knowledge-thread-promotion-schema-integration.mjs @@ -0,0 +1,298 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import pg from 'pg'; + +if (process.env.KNOWLEDGE_THREAD_PROMOTION_SCHEMA_INTEGRATION_CONFIRM !== '1') { + throw new Error('knowledge_thread_promotion_schema_integration_not_confirmed'); +} +if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required'); + +const { Client } = pg; +const client = new Client({ connectionString: process.env.DATABASE_URL }); +const ids = Object.fromEntries( + [ + 'room', + 'root', + 'reply', + 'item', + 'share', + 'synthesis', + 'version', + 'promotion', + 'selected', + 'request', + 'source', + 'secondSource', + 'incompleteSynthesis', + 'incompleteVersion', + 'incompletePromotion', + ].map((name) => [name, randomUUID()]), +); +const actor = `promotion-schema-${randomUUID()}`; +const hash = (character) => character.repeat(64); +const now = new Date(); + +async function expectDatabaseError(work, expectedCode) { + try { + await work(); + assert.fail(`expected PostgreSQL error ${expectedCode}`); + } catch (error) { + assert.equal(error?.code, expectedCode); + } +} + +await client.connect(); +try { + await client.query('BEGIN'); + await client.query( + `INSERT INTO "ChatRoom" ( + "id", "type", "name", "allowExternalUsers", + "allowExternalIntegrations", "createdAt", "updatedAt" + ) VALUES ($1, 'private_group', 'Synthetic promotion room', false, false, $2, $2)`, + [ids.room, now], + ); + await client.query( + `INSERT INTO "ChatMessage" ( + "id", "roomId", "messageType", "userId", "body", + "mentionsAll", "createdAt", "updatedAt" + ) VALUES ($1, $2, 'text', $3, 'Knowledge was shared.', false, $4, $4)`, + [ids.root, ids.room, actor, now], + ); + const reply = await client.query( + `INSERT INTO "ChatMessage" ( + "id", "roomId", "messageType", "parentMessageId", "threadRootId", + "userId", "body", "mentionsAll", "createdAt", "updatedAt" + ) VALUES ($1, $2, 'text', $3, $3, $4, 'Selected synthetic reply', false, $5, $5) + RETURNING "activitySequence", "createdAt"`, + [ids.reply, ids.room, ids.root, actor, now], + ); + const item = await client.query( + `INSERT INTO "KnowledgeItem" ( + "id", "ownerUserId", "scope", "sourceType", "title", + "status", "version", "createdAt", "updatedAt" + ) VALUES ($1, $2, 'personal', 'manual', 'Synthetic source', + 'inbox', 1, $3, $3) + RETURNING "updatedAt"`, + [ids.item, actor, now], + ); + await client.query( + `INSERT INTO "KnowledgeShare" ( + "id", "sourceKnowledgeItemId", "sourceOwnerUserId", "sharerUserId", + "chatPosterUserId", "destinationRoomId", "requestKeyHash", + "requestPayloadHash", "selectionHash", "contentHash", + "sourceItemVersion", "sourceItemUpdatedAt", "selectedTitle", + "createdAt", "createdBy", "updatedAt", "updatedBy" + ) VALUES ( + $1, $2, $3, $3, $3, $4, $5, $6, $7, $8, + 1, $9, 'Synthetic source', $10, $3, $10, $3 + )`, + [ + ids.share, + ids.item, + actor, + ids.room, + hash('1'), + hash('2'), + hash('3'), + hash('4'), + item.rows[0].updatedAt, + now, + ], + ); + await client.query( + `UPDATE "KnowledgeShare" + SET "status" = 'posted', "chatMessageId" = $2, + "postedAt" = $3, "version" = 2, + "updatedAt" = $3, "updatedBy" = $4 + WHERE "id" = $1`, + [ids.share, ids.root, now, actor], + ); + await client.query( + `INSERT INTO "KnowledgeSynthesis" ( + "id", "ownerUserId", "scope", "title", "currentVersion", + "createdAt", "createdBy", "updatedAt", "updatedBy" + ) VALUES ($1, $2, 'personal', 'Synthetic synthesis', 1, $3, $2, $3, $2)`, + [ids.synthesis, actor, now], + ); + await client.query( + `INSERT INTO "KnowledgeSynthesisVersion" ( + "id", "synthesisId", "version", "content", "unresolvedQuestions", + "createdAt", "createdBy" + ) VALUES ($1, $2, 1, 'Synthetic conclusion', '[]'::jsonb, $3, $4)`, + [ids.version, ids.synthesis, now, actor], + ); + await client.query( + `INSERT INTO "KnowledgeThreadPromotion" ( + "id", "sourceShareId", "sourceShareVersion", "sourceShareContentHash", + "sourceRoomId", "sourceRootMessageId", "promoterUserId", "ownerUserId", + "scope", "destinationSynthesisId", "destinationSynthesisVersionId", + "selectionHash", "contentHash", "includesSharedCard", + "selectedMessageCount", "createdAt", "createdBy" + ) VALUES ( + $1, $2, 2, $3, $4, $5, $6, $6, 'personal', $7, $8, + $9, $10, true, 1, $11, $6 + )`, + [ + ids.promotion, + ids.share, + hash('4'), + ids.room, + ids.root, + actor, + ids.synthesis, + ids.version, + hash('5'), + hash('6'), + now, + ], + ); + await client.query( + `INSERT INTO "KnowledgeThreadPromotionMessage" ( + "id", "promotionId", "sourceRoomId", "sourceRootMessageId", + "sourceMessageId", "sourceActivitySequence", "sourceMessageCreatedAt", + "ordinal", "authorCategory", "content", "contentHash", + "createdAt", "createdBy" + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 'user', + 'Selected synthetic reply', $8, $9, $10)`, + [ + ids.selected, + ids.promotion, + ids.room, + ids.root, + ids.reply, + reply.rows[0].activitySequence, + reply.rows[0].createdAt, + hash('7'), + now, + actor, + ], + ); + await client.query( + `INSERT INTO "KnowledgeThreadPromotionRequest" ( + "id", "promoterUserId", "requestKeyHash", "requestPayloadHash", + "promotionId", "createdAt", "createdBy" + ) VALUES ($1, $2, $3, $4, $5, $6, $2)`, + [ids.request, actor, hash('8'), hash('9'), ids.promotion, now], + ); + await client.query( + `INSERT INTO "KnowledgeSynthesisSource" ( + "id", "synthesisVersionId", "relationType", "ordinal", + "sourceThreadPromotionId", "createdAt", "createdBy" + ) VALUES ($1, $2, 'primary', 0, $3, $4, $5)`, + [ids.source, ids.version, ids.promotion, now, actor], + ); + await client.query('COMMIT'); + + const persisted = await client.query( + `SELECT p."includesSharedCard", COUNT(m."id")::int AS "selectedCount", + COUNT(s."id")::int AS "sourceCount" + FROM "KnowledgeThreadPromotion" p + LEFT JOIN "KnowledgeThreadPromotionMessage" m ON m."promotionId" = p."id" + LEFT JOIN "KnowledgeSynthesisSource" s ON s."sourceThreadPromotionId" = p."id" + WHERE p."id" = $1 + GROUP BY p."includesSharedCard"`, + [ids.promotion], + ); + assert.deepEqual(persisted.rows[0], { + includesSharedCard: true, + selectedCount: 1, + sourceCount: 1, + }); + + await expectDatabaseError( + () => + client.query( + `INSERT INTO "KnowledgeSynthesisSource" ( + "id", "synthesisVersionId", "relationType", "ordinal", + "sourceKnowledgeItemId", "createdAt", "createdBy" + ) VALUES ($1, $2, 'supporting', 1, $3, $4, $5)`, + [ids.secondSource, ids.version, ids.item, now, actor], + ), + '23514', + ); + + await client.query('BEGIN'); + await client.query( + `INSERT INTO "KnowledgeSynthesis" ( + "id", "ownerUserId", "scope", "title", "currentVersion", + "createdAt", "createdBy", "updatedAt", "updatedBy" + ) VALUES ($1, $2, 'personal', 'Incomplete synthesis', 1, $3, $2, $3, $2)`, + [ids.incompleteSynthesis, actor, now], + ); + await client.query( + `INSERT INTO "KnowledgeSynthesisVersion" ( + "id", "synthesisId", "version", "content", "unresolvedQuestions", + "createdAt", "createdBy" + ) VALUES ($1, $2, 1, 'Incomplete', '[]'::jsonb, $3, $4)`, + [ids.incompleteVersion, ids.incompleteSynthesis, now, actor], + ); + await client.query( + `INSERT INTO "KnowledgeThreadPromotion" ( + "id", "sourceShareId", "sourceShareVersion", "sourceShareContentHash", + "sourceRoomId", "sourceRootMessageId", "promoterUserId", "ownerUserId", + "scope", "destinationSynthesisId", "destinationSynthesisVersionId", + "selectionHash", "contentHash", "includesSharedCard", + "selectedMessageCount", "createdAt", "createdBy" + ) VALUES ($1, $2, 2, $3, $4, $5, $6, $6, 'personal', $7, $8, + $9, $10, false, 1, $11, $6)`, + [ + ids.incompletePromotion, + ids.share, + hash('4'), + ids.room, + ids.root, + actor, + ids.incompleteSynthesis, + ids.incompleteVersion, + hash('a'), + hash('b'), + now, + ], + ); + await expectDatabaseError(() => client.query('COMMIT'), '23514'); + await client.query('ROLLBACK'); + + await expectDatabaseError( + () => + client.query( + `UPDATE "KnowledgeThreadPromotion" SET "includesSharedCard" = false + WHERE "id" = $1`, + [ids.promotion], + ), + '55000', + ); + + await client.query( + `UPDATE "KnowledgeShare" + SET "status" = 'revoked', "revokedAt" = $2, "revokedBy" = $3, + "version" = 3, "updatedAt" = $2, "updatedBy" = $3 + WHERE "id" = $1`, + [ids.share, new Date(now.getTime() + 1), actor], + ); + const history = await client.query( + `SELECT p."sourceShareVersion", p."sourceShareContentHash", s."version" AS "currentShareVersion" + FROM "KnowledgeThreadPromotion" p + JOIN "KnowledgeShare" s ON s."id" = p."sourceShareId" + WHERE p."id" = $1`, + [ids.promotion], + ); + assert.deepEqual(history.rows[0], { + sourceShareVersion: 2, + sourceShareContentHash: hash('4'), + currentShareVersion: 3, + }); + + console.log( + JSON.stringify({ + result: 'PASS', + includesSharedCard: true, + selectedOnly: true, + deferredCompleteRejectsIncomplete: true, + promotionDestinationRejectsSecondSource: true, + immutablePromotion: true, + sourceRevokePreservesSnapshot: true, + }), + ); +} finally { + await client.end(); +} diff --git a/packages/backend/src/adapters/knowledge/prismaKnowledgeProvenanceAdapter.ts b/packages/backend/src/adapters/knowledge/prismaKnowledgeProvenanceAdapter.ts index 4ec011df..f1d8eff1 100644 --- a/packages/backend/src/adapters/knowledge/prismaKnowledgeProvenanceAdapter.ts +++ b/packages/backend/src/adapters/knowledge/prismaKnowledgeProvenanceAdapter.ts @@ -31,6 +31,11 @@ import { consumeSynthesisAccessBudget, type KnowledgeSynthesisAccessContext, } from '../../application/knowledge/knowledgeSynthesisAccessContext.js'; +import { + buildKnowledgeSynthesisVisibilityWhere, + threadPromotionSourceAuthorizesVersion, + threadPromotionSourceAccessible, +} from './prismaKnowledgeSynthesisVisibility.js'; export { PrismaKnowledgeProvenanceAuditWriter } from './prismaKnowledgeProvenanceAuditAdapter.js'; @@ -47,8 +52,16 @@ type KnowledgeProvenanceDbClient = Pick< | 'knowledgeSynthesis' | 'knowledgeSynthesisSource' | 'knowledgeSynthesisVersion' + | 'knowledgeThreadPromotion' >; +// The application Prisma client intentionally omits ChatMessage.activitySequence +// from default result payloads. This adapter always uses explicit projections, +// while transaction clients expose Prisma's un-omitted delegate type. Keep one +// narrow cast at the adapter boundary instead of leaking that client-level omit +// distinction through every repository constructor. +const provenanceDbClient = prisma as unknown as KnowledgeProvenanceDbClient; + type KnowledgeProvenanceTransactionHost = { $transaction( work: (transaction: Prisma.TransactionClient) => Promise, @@ -216,9 +229,13 @@ function sourceIdentity(row: SynthesisSourceRow): { ['conversation', row.sourceConversationId], ['conversation_turn', row.sourceConversationTurnId], ['synthesis_version', row.sourceSynthesisVersionId], + ['thread_promotion', row.sourceThreadPromotionId], ] as const; - const defined = values.filter((entry) => entry[1] !== null); - if (defined.length !== 1 || defined[0]?.[1] === null) { + const defined = values.filter( + (entry): entry is readonly [KnowledgeSynthesisSourceKind, string] => + typeof entry[1] === 'string', + ); + if (defined.length !== 1) { throw new Error('knowledge_synthesis_source_integrity_invalid'); } return { kind: defined[0][0], sourceId: defined[0][1] }; @@ -307,21 +324,6 @@ function annotationHistoryVisibilityWhere( }; } -function synthesisBaseVisibilityWhere( - actor: KnowledgeActor, -): Prisma.KnowledgeSynthesisWhereInput { - const organizationId = actor.organizationId?.trim(); - return { - deletedAt: null, - OR: [ - { ownerUserId: actor.userId }, - ...(organizationId - ? [{ scope: 'organization' as const, organizationId }] - : []), - ], - }; -} - function sourceData(source: KnowledgeSynthesisSourceInput, ordinal: number) { const base = { relationType: source.relationType, @@ -342,11 +344,15 @@ function sourceData(source: KnowledgeSynthesisSourceInput, ordinal: number) { return { ...base, sourceConversationTurnId: source.sourceId }; case 'synthesis_version': return { ...base, sourceSynthesisVersionId: source.sourceId }; + case 'thread_promotion': + return { ...base, sourceThreadPromotionId: source.sourceId }; } } export class PrismaKnowledgeAccessRepository implements KnowledgeAccessRepository { - constructor(private readonly client: KnowledgeProvenanceDbClient = prisma) {} + constructor( + private readonly client: KnowledgeProvenanceDbClient = provenanceDbClient, + ) {} async findVisibleItem(actor: KnowledgeActor, itemId: string) { const row = await this.client.knowledgeItem.findFirst({ @@ -376,7 +382,9 @@ export class PrismaKnowledgeAccessRepository implements KnowledgeAccessRepositor } export class PrismaKnowledgeAnnotationRepository implements KnowledgeAnnotationRepository { - constructor(private readonly client: KnowledgeProvenanceDbClient = prisma) {} + constructor( + private readonly client: KnowledgeProvenanceDbClient = provenanceDbClient, + ) {} async withConsistentSnapshot( read: (repository: KnowledgeAnnotationRepository) => Promise, @@ -638,7 +646,9 @@ export class PrismaKnowledgeAnnotationRepository implements KnowledgeAnnotationR } export class PrismaKnowledgeConversationRepository implements KnowledgeConversationRepository { - constructor(private readonly client: KnowledgeProvenanceDbClient = prisma) {} + constructor( + private readonly client: KnowledgeProvenanceDbClient = provenanceDbClient, + ) {} async withConsistentSnapshot( read: (repository: KnowledgeConversationRepository) => Promise, @@ -919,7 +929,9 @@ export class PrismaKnowledgeConversationRepository implements KnowledgeConversat } export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRepository { - constructor(private readonly client: KnowledgeProvenanceDbClient = prisma) {} + constructor( + private readonly client: KnowledgeProvenanceDbClient = provenanceDbClient, + ) {} async withConsistentSnapshot( read: (repository: KnowledgeSynthesisRepository) => Promise, @@ -1039,6 +1051,15 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep }), ); break; + case 'thread_promotion': { + accessible = await threadPromotionSourceAccessible({ + client: this.client, + actor, + sourceId: identity.sourceId, + context, + }); + break; + } } context.sourceMemo.set(memoKey, accessible); return accessible; @@ -1063,7 +1084,7 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep const version = await this.client.knowledgeSynthesisVersion.findFirst({ where: { id: versionId, - synthesis: { is: synthesisBaseVisibilityWhere(actor) }, + synthesis: { is: buildKnowledgeSynthesisVisibilityWhere(actor) }, }, include: { synthesis: { select: { ownerUserId: true } }, @@ -1078,6 +1099,7 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep accessible = true; for (const source of version.sources) { if ( + !threadPromotionSourceAuthorizesVersion(source) && !(await this.sourceAccessible(actor, source, context, path, depth)) ) { accessible = false; @@ -1099,6 +1121,7 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep if (version.sources.length === 0) return false; for (const source of version.sources) { if ( + !threadPromotionSourceAuthorizesVersion(source) && !(await this.sourceAccessible(actor, source, context, new Set(), 0)) ) { return false; @@ -1143,7 +1166,7 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep const synthesis = await this.client.knowledgeSynthesis.findFirst({ where: { id: synthesisId, - ...synthesisBaseVisibilityWhere(actor), + ...buildKnowledgeSynthesisVisibilityWhere(actor), }, }); if (!synthesis) return null; @@ -1192,7 +1215,7 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep const rows = await this.client.knowledgeSynthesis.findMany({ where: { AND: [ - synthesisBaseVisibilityWhere(input.actor), + buildKnowledgeSynthesisVisibilityWhere(input.actor), ...(boundary ? [ { @@ -1342,6 +1365,8 @@ export class PrismaKnowledgeSynthesisRepository implements KnowledgeSynthesisRep source.kind === 'conversation_turn' ? source.sourceId : null, sourceSynthesisVersionId: source.kind === 'synthesis_version' ? source.sourceId : null, + sourceThreadPromotionId: + source.kind === 'thread_promotion' ? source.sourceId : null, createdAt: new Date(0), createdBy: input.actor.userId, } satisfies SynthesisSourceRow; @@ -1516,10 +1541,10 @@ export class PrismaKnowledgeProvenanceUnitOfWork implements KnowledgeProvenanceU } export const prismaKnowledgeAnnotationRepository = - new PrismaKnowledgeAnnotationRepository(prisma); + new PrismaKnowledgeAnnotationRepository(provenanceDbClient); export const prismaKnowledgeConversationRepository = - new PrismaKnowledgeConversationRepository(prisma); + new PrismaKnowledgeConversationRepository(provenanceDbClient); export const prismaKnowledgeSynthesisRepository = - new PrismaKnowledgeSynthesisRepository(prisma); + new PrismaKnowledgeSynthesisRepository(provenanceDbClient); export const prismaKnowledgeProvenanceUnitOfWork = new PrismaKnowledgeProvenanceUnitOfWork(prisma); diff --git a/packages/backend/src/adapters/knowledge/prismaKnowledgeSynthesisVisibility.ts b/packages/backend/src/adapters/knowledge/prismaKnowledgeSynthesisVisibility.ts new file mode 100644 index 00000000..d6a3187d --- /dev/null +++ b/packages/backend/src/adapters/knowledge/prismaKnowledgeSynthesisVisibility.ts @@ -0,0 +1,134 @@ +import { Prisma } from '@prisma/client'; + +import type { KnowledgeActor } from '../../application/knowledge/knowledgeItemPorts.js'; +import { + consumeSynthesisAccessBudget, + type KnowledgeSynthesisAccessContext, +} from '../../application/knowledge/knowledgeSynthesisAccessContext.js'; +import { + ensureChatRoomContentAccess, + hasActiveChatProject, +} from '../../services/chatRoomAccess.js'; +import { prisma } from '../../services/db.js'; + +type ThreadPromotionLookupClient = Pick< + Prisma.TransactionClient, + 'knowledgeThreadPromotion' +>; + +/** + * A promotion copies selected immutable Chat content into a synthesis governed + * by the destination Knowledge ACL. Losing live room access redacts the source + * link, but does not revoke that explicit copy. + */ +export function threadPromotionSourceAuthorizesVersion(source: { + sourceThreadPromotionId?: string | null; +}) { + return typeof source.sourceThreadPromotionId === 'string'; +} + +export function buildKnowledgeSynthesisVisibilityWhere( + actor: KnowledgeActor, +): Prisma.KnowledgeSynthesisWhereInput { + const organizationId = actor.organizationId?.trim(); + const groupAccountIds = [ + ...new Set( + actor.groupAccountIds.map((value) => value.trim()).filter(Boolean), + ), + ]; + return { + deletedAt: null, + OR: [ + { ownerUserId: actor.userId }, + ...(organizationId + ? [ + { + scope: 'organization' as const, + organizationId, + OR: [ + // Existing organization syntheses predate grant rows and retain + // their organization-wide read contract. Promotion-created + // syntheses always carry explicit group grants. + { groupGrants: { none: {} } }, + ...(groupAccountIds.length > 0 + ? [ + { + groupGrants: { + some: { + revokedAt: null, + groupAccountId: { in: groupAccountIds }, + groupAccount: { + active: true, + memberships: { + some: { + userId: actor.userId, + user: { + active: true, + deletedAt: null, + organization: organizationId, + }, + }, + }, + }, + }, + }, + }, + ] + : []), + ], + }, + ] + : []), + ], + }; +} + +export async function threadPromotionSourceAccessible(input: { + client: ThreadPromotionLookupClient; + actor: KnowledgeActor; + sourceId: string; + context: KnowledgeSynthesisAccessContext; +}) { + const chat = input.actor.chat; + if (!chat?.userId) return false; + consumeSynthesisAccessBudget(input.context, 'query'); + const promotion = await input.client.knowledgeThreadPromotion.findFirst({ + where: { + id: input.sourceId, + sourceShare: { + is: { + status: 'posted', + revokedAt: null, + chatMessageId: { not: null }, + }, + }, + sourceRootMessage: { + is: { + parentMessageId: null, + threadRootId: null, + deletedAt: null, + }, + }, + }, + select: { sourceRoomId: true }, + }); + if (!promotion) return false; + consumeSynthesisAccessBudget(input.context, 'query'); + const roomAccess = await ensureChatRoomContentAccess({ + roomId: promotion.sourceRoomId, + userId: chat.userId, + roles: chat.roles, + projectIds: chat.projectIds, + groupIds: chat.groupIds, + groupAccountIds: chat.groupAccountIds, + accessLevel: 'read', + client: input.client as unknown as typeof prisma, + }); + if (!roomAccess.ok) return false; + if (roomAccess.room.allowExternalUsers) return false; + consumeSynthesisAccessBudget(input.context, 'query'); + return hasActiveChatProject({ + room: roomAccess.room, + client: input.client as unknown as typeof prisma, + }); +} diff --git a/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAdapter.ts b/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAdapter.ts new file mode 100644 index 00000000..5b0f74ad --- /dev/null +++ b/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAdapter.ts @@ -0,0 +1,878 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { Prisma, type PrismaClient } from '@prisma/client'; + +import type { KnowledgeActor } from '../../application/knowledge/knowledgeItemPorts.js'; +import { + knowledgeShareSelectionCategories, + type KnowledgeShareSelectionCategory, +} from '../../application/knowledge/knowledgeSharePorts.js'; +import type { + KnowledgeThreadPromotionCommitRecord, + KnowledgeThreadPromotionFailure, + KnowledgeThreadPromotionPortResult, + KnowledgeThreadPromotionRequest, + KnowledgeThreadPromotionResolvedPreview, + KnowledgeThreadPromotionSelectedMessage, + KnowledgeThreadPromotionShareCardPreview, + KnowledgeThreadPromotionStorePort, +} from '../../application/knowledge/knowledgeThreadPromotionPorts.js'; +import { knowledgeProvenanceAuditActor } from '../../application/knowledge/knowledgeProvenanceValidation.js'; +import { + chatRoomProjectId, + ensureChatRoomContentAccess, + hasActiveChatProject, +} from '../../services/chatRoomAccess.js'; +import { prisma } from '../../services/db.js'; +import { safeCanonicalUrl } from './knowledgeShareSanitizers.js'; +import { PrismaKnowledgeProvenanceAuditWriter } from './prismaKnowledgeProvenanceAuditAdapter.js'; +import { PrismaKnowledgeThreadPromotionAuditWriter } from './prismaKnowledgeThreadPromotionAuditAdapter.js'; + +const serializableAttempts = 3; +const hashPattern = /^[0-9a-f]{64}$/; + +type PromotionHost = Pick; + +const shareInclude = Prisma.validator()({ + destinationRoom: true, + chatMessage: true, + snapshot: true, + labels: { orderBy: [{ ordinal: 'asc' }, { id: 'asc' }] }, + annotations: { orderBy: [{ ordinal: 'asc' }, { id: 'asc' }] }, + turns: { orderBy: [{ ordinal: 'asc' }, { id: 'asc' }] }, + syntheses: { orderBy: [{ ordinal: 'asc' }, { id: 'asc' }] }, +}); + +type ShareRow = Prisma.KnowledgeShareGetPayload<{ + include: typeof shareInclude; +}>; + +type InternalSelectedMessage = KnowledgeThreadPromotionSelectedMessage & { + sourceActivitySequence: bigint; +}; + +type InternalResolved = KnowledgeThreadPromotionResolvedPreview & { + sourceShareId: string; + sourceRoomId: string; + selectedMessages: InternalSelectedMessage[]; + contentHash: string; + selectionHash: string; +}; + +type ResolveResult = + | { ok: true; value: InternalResolved } + | { ok: false; error: KnowledgeThreadPromotionFailure }; + +function success(value: T): KnowledgeThreadPromotionPortResult { + return { ok: true, value }; +} + +function failure( + status: number, + code: KnowledgeThreadPromotionFailure['code'], +): KnowledgeThreadPromotionPortResult { + const messages: Record = { + invalid_request: 'Invalid request', + not_found: 'Not found', + stale_preview: 'Preview is stale', + preview_token_invalid: 'Invalid preview token', + preview_token_expired: 'Preview token expired', + idempotency_conflict: 'Idempotency conflict', + organization_confirmation_required: 'Organization confirmation is required', + promotion_conflict: 'Promotion conflict', + }; + return { ok: false, error: { status, code, message: messages[code] } }; +} + +function sha256(domain: string, value: string) { + return createHash('sha256') + .update(`erp4:knowledge:thread-promotion:${domain}:v1\0`, 'utf8') + .update(value, 'utf8') + .digest('hex'); +} + +function strictQuestions(value: Prisma.JsonValue): string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string') + ) { + throw new Error('knowledge_thread_promotion_questions_invalid'); + } + return value.map((entry) => entry as string); +} + +function selectedCategories(share: ShareRow) { + const result: KnowledgeShareSelectionCategory[] = []; + if (share.selectedTitle !== null) result.push('title'); + if (share.selectedSourceType !== null) result.push('source_type'); + if (share.selectedCanonicalUrl !== null) result.push('canonical_url'); + if (share.snapshot?.provenanceSelected) result.push('snapshot_provenance'); + if (share.snapshot?.excerptSelected) result.push('snapshot_excerpt'); + if (share.labels.length > 0) result.push('label'); + if (share.annotations.length > 0) result.push('annotation'); + if (share.turns.length > 0) result.push('conversation_turn'); + if (share.syntheses.length > 0) result.push('synthesis'); + if (share.selectedSharerNote !== null) result.push('sharer_note'); + return result; +} + +function publicShareCard( + share: ShareRow, +): KnowledgeThreadPromotionShareCardPreview | null { + const canonicalUrl = + share.selectedCanonicalUrl === null + ? undefined + : safeCanonicalUrl(share.selectedCanonicalUrl); + if (share.selectedCanonicalUrl !== null && !canonicalUrl) return null; + const selected = selectedCategories(share); + return { + schemaVersion: 1, + shareVersion: share.version, + ...(share.selectedTitle === null ? {} : { title: share.selectedTitle }), + ...(share.selectedSourceType === null + ? {} + : { sourceType: share.selectedSourceType }), + ...(canonicalUrl === undefined ? {} : { canonicalUrl }), + ...(share.snapshot + ? { + snapshot: { + version: share.snapshot.sourceSnapshotVersion, + sha256: share.snapshot.sourceSha256, + ...(share.snapshot.excerpt === null + ? {} + : { excerpt: share.snapshot.excerpt }), + }, + } + : {}), + ...(share.selectedSharerNote === null + ? {} + : { sharerNote: share.selectedSharerNote }), + labels: share.labels.map((entry) => ({ + displayName: entry.displayName, + ordinal: entry.ordinal, + })), + annotations: share.annotations.map((entry) => ({ + revision: entry.revision, + kind: entry.kind, + origin: entry.origin, + content: entry.content, + ordinal: entry.ordinal, + })), + turns: share.turns.map((entry) => ({ + role: entry.role, + origin: entry.origin, + content: entry.content, + name: entry.name, + occurredAt: entry.occurredAt, + ordinal: entry.ordinal, + })), + syntheses: share.syntheses.map((entry) => ({ + version: entry.version, + title: entry.title, + content: entry.content, + confidenceBasisPoints: entry.confidenceBasisPoints, + unresolvedQuestions: strictQuestions(entry.unresolvedQuestions), + ordinal: entry.ordinal, + })), + selectedCategories: selected, + omittedCategories: knowledgeShareSelectionCategories.filter( + (category) => !selected.includes(category), + ), + }; +} + +function publicResolved(value: InternalResolved) { + return { + promotionId: value.promotionId, + rootMessageId: value.rootMessageId, + sourceRoomName: value.sourceRoomName, + sourceRoomType: value.sourceRoomType, + sourceShareVersion: value.sourceShareVersion, + sourceShareContentHash: value.sourceShareContentHash, + threadReplyCount: value.threadReplyCount, + selectedMessages: value.selectedMessages.map((message) => ({ + sourceMessageId: message.sourceMessageId, + sourceActivitySequence: message.sourceActivitySequence, + ordinal: message.ordinal, + content: message.content, + contentHash: message.contentHash, + createdAt: message.createdAt, + authorCategory: message.authorCategory, + })), + selectedShareCard: value.selectedShareCard, + destination: value.destination, + bindingHash: value.bindingHash, + } satisfies KnowledgeThreadPromotionResolvedPreview; +} + +function auditMetadata( + request: KnowledgeThreadPromotionRequest, + resultCode: 'previewed' | 'created' | 'reused' | 'rejected' | 'conflict', + duplicate = false, +) { + return { + schemaVersion: 1 as const, + resultCode, + scope: request.destination.scope, + selectedMessageCount: request.selectedReplyMessageIds.length, + includesSharedCard: request.includeSharedCard, + organizationGrantCount: + request.destination.organizationGroupAccountIds.length, + duplicate, + }; +} + +async function writePromotionAudit( + transaction: Prisma.TransactionClient, + input: { + actor: KnowledgeActor; + auditActor: Parameters[1]; + promotionId: string; + request: KnowledgeThreadPromotionRequest; + action: + | 'knowledge_thread_promote_previewed' + | 'knowledge_thread_promoted' + | 'knowledge_thread_promote_duplicate_detected' + | 'knowledge_thread_promote_rejected'; + resultCode: 'previewed' | 'created' | 'reused' | 'rejected' | 'conflict'; + duplicate?: boolean; + }, +) { + await new PrismaKnowledgeThreadPromotionAuditWriter(transaction).write({ + action: input.action, + actor: knowledgeProvenanceAuditActor(input.actor, input.auditActor), + targetTable: 'knowledge_thread_promotions', + targetId: input.promotionId, + metadata: auditMetadata( + input.request, + input.resultCode, + input.duplicate ?? false, + ), + }); +} + +async function resolveMaterial( + transaction: Prisma.TransactionClient, + input: { + actor: KnowledgeActor; + rootMessageId: string; + promotionId: string; + request: KnowledgeThreadPromotionRequest; + }, + lock: boolean, +): Promise { + const chat = input.actor.chat; + if (!chat?.userId) return failure(404, 'not_found'); + + const share = await transaction.knowledgeShare.findFirst({ + where: { + status: 'posted', + revokedAt: null, + chatMessageId: input.rootMessageId, + chatMessage: { + is: { + id: input.rootMessageId, + parentMessageId: null, + threadRootId: null, + deletedAt: null, + }, + }, + }, + include: shareInclude, + }); + if ( + !share || + !share.chatMessage || + share.chatMessageId !== input.rootMessageId || + share.destinationRoomId !== share.chatMessage.roomId || + share.destinationRoom.allowExternalUsers + ) { + return failure(404, 'not_found'); + } + + const roomAccess = await ensureChatRoomContentAccess({ + roomId: share.destinationRoomId, + userId: chat.userId, + roles: chat.roles, + projectIds: chat.projectIds, + groupIds: chat.groupIds, + groupAccountIds: chat.groupAccountIds, + accessLevel: 'read', + client: transaction as unknown as typeof prisma, + }); + if ( + !roomAccess.ok || + !(await hasActiveChatProject({ + room: roomAccess.room, + client: transaction as unknown as typeof prisma, + })) + ) { + return failure(404, 'not_found'); + } + + if (lock) { + await transaction.$queryRaw(Prisma.sql` + SELECT message."id" + FROM "ChatMessage" AS message + WHERE message."id" = ${input.rootMessageId} + AND message."roomId" = ${share.destinationRoomId} + FOR SHARE + `); + await transaction.$queryRaw(Prisma.sql` + SELECT room."id" + FROM "ChatRoom" AS room + WHERE room."id" = ${share.destinationRoomId} + AND room."deletedAt" IS NULL + FOR SHARE + `); + const projectId = chatRoomProjectId(roomAccess.room); + if (projectId) { + await transaction.$queryRaw(Prisma.sql` + SELECT project."id" + FROM "Project" AS project + WHERE project."id" = ${projectId} + AND project."deletedAt" IS NULL + FOR SHARE + `); + } + await transaction.$queryRaw(Prisma.sql` + SELECT share."id" + FROM "KnowledgeShare" AS share + WHERE share."id" = ${share.id} + FOR SHARE + `); + const selectedIds = [...input.request.selectedReplyMessageIds].sort(); + await transaction.$queryRaw(Prisma.sql` + SELECT message."id" + FROM "ChatMessage" AS message + WHERE message."id" IN (${Prisma.join(selectedIds)}) + ORDER BY message."id" + FOR SHARE + `); + const grantIds = [ + ...input.request.destination.organizationGroupAccountIds, + ].sort(); + if (grantIds.length > 0) { + await transaction.$queryRaw(Prisma.sql` + SELECT account."id" + FROM "GroupAccount" AS account + WHERE account."id" IN (${Prisma.join(grantIds)}) + ORDER BY account."id" + FOR SHARE + `); + await transaction.$queryRaw(Prisma.sql` + SELECT membership."id" + FROM "UserGroup" AS membership + WHERE membership."userId" = ${input.actor.userId} + AND membership."groupId" IN (${Prisma.join(grantIds)}) + ORDER BY membership."groupId", membership."id" + FOR SHARE + `); + } + return resolveMaterial(transaction, input, false); + } + + if (input.request.destination.scope === 'organization') { + const organizationId = input.actor.organizationId?.trim(); + const groupIds = input.request.destination.organizationGroupAccountIds; + if (!organizationId || groupIds.length === 0) { + return failure(400, 'organization_confirmation_required'); + } + const activeCount = await transaction.groupAccount.count({ + where: { + id: { in: groupIds }, + active: true, + memberships: { + some: { + userId: input.actor.userId, + user: { + active: true, + deletedAt: null, + organization: organizationId, + }, + }, + }, + }, + }); + if (activeCount !== groupIds.length) { + return failure(404, 'not_found'); + } + } + + const rows = await transaction.chatMessage.findMany({ + where: { + id: { in: input.request.selectedReplyMessageIds }, + roomId: share.destinationRoomId, + parentMessageId: input.rootMessageId, + threadRootId: input.rootMessageId, + messageType: 'text', + deletedAt: null, + }, + select: { + id: true, + body: true, + createdAt: true, + activitySequence: true, + }, + }); + const byId = new Map(rows.map((row) => [row.id, row])); + if (byId.size !== input.request.selectedReplyMessageIds.length) { + return failure(404, 'not_found'); + } + const selectedMessages: InternalSelectedMessage[] = []; + for (const [ + ordinal, + messageId, + ] of input.request.selectedReplyMessageIds.entries()) { + const row = byId.get(messageId); + if (!row) return failure(404, 'not_found'); + selectedMessages.push({ + sourceMessageId: row.id, + ordinal, + content: row.body, + contentHash: sha256( + 'selected-message', + JSON.stringify({ + body: row.body, + createdAt: row.createdAt.toISOString(), + activitySequence: row.activitySequence.toString(), + }), + ), + createdAt: row.createdAt, + // The current Chat contract has only user-authored text messages. Share + // rooms with external users are rejected above, and system/tool message + // types are outside this text-reply MVP. Persist the bounded category + // without copying any author identity; future categories require an + // explicit source discriminator and additive migration. + authorCategory: 'user', + sourceActivitySequence: row.activitySequence, + }); + } + const threadReplyCount = await transaction.chatMessage.count({ + where: { + roomId: share.destinationRoomId, + parentMessageId: input.rootMessageId, + threadRootId: input.rootMessageId, + deletedAt: null, + }, + }); + const selectedShareCard = input.request.includeSharedCard + ? publicShareCard(share) + : null; + if (input.request.includeSharedCard && !selectedShareCard) { + return failure(404, 'not_found'); + } + const destination = input.request.destination; + const canonical = { + sourceShareId: share.id, + sourceShareVersion: share.version, + sourceShareContentHash: share.contentHash, + sourceRoomId: share.destinationRoomId, + rootMessageId: input.rootMessageId, + selectedMessages: selectedMessages.map((message) => ({ + sourceMessageId: message.sourceMessageId, + ordinal: message.ordinal, + contentHash: message.contentHash, + sourceActivitySequence: message.sourceActivitySequence.toString(), + })), + includeSharedCard: input.request.includeSharedCard, + destination, + synthesis: input.request.synthesis, + }; + const selectionHash = sha256( + 'selection', + JSON.stringify({ + selectedMessages: canonical.selectedMessages, + includeSharedCard: canonical.includeSharedCard, + }), + ); + const contentHash = sha256('content', JSON.stringify(canonical)); + const bindingHash = sha256( + 'preview-binding', + JSON.stringify({ + ...canonical, + selectionHash, + contentHash, + }), + ); + return success({ + promotionId: input.promotionId, + sourceShareId: share.id, + sourceRoomId: share.destinationRoomId, + rootMessageId: input.rootMessageId, + sourceRoomName: share.destinationRoom.name, + sourceRoomType: share.destinationRoom.type, + sourceShareVersion: share.version, + sourceShareContentHash: share.contentHash, + threadReplyCount, + selectedMessages, + selectedShareCard, + destination, + bindingHash, + contentHash, + selectionHash, + }); +} + +function commitRecord( + row: { + id: string; + destinationSynthesisId: string; + destinationSynthesisVersionId: string; + destinationSynthesisVersionNumber: number; + scope: 'personal' | 'organization'; + selectedMessageCount: number; + includesSharedCard: boolean; + createdAt: Date; + }, + created: boolean, +): KnowledgeThreadPromotionCommitRecord { + if (row.destinationSynthesisVersionNumber !== 1) { + throw new Error('knowledge_thread_promotion_version_invalid'); + } + return { + promotionId: row.id, + synthesisId: row.destinationSynthesisId, + synthesisVersionId: row.destinationSynthesisVersionId, + synthesisVersion: 1, + scope: row.scope, + selectedMessageCount: row.selectedMessageCount, + includesSharedCard: row.includesSharedCard, + createdAt: row.createdAt, + created, + }; +} + +function retryable(error: unknown) { + if (!error || typeof error !== 'object' || !('code' in error)) return false; + return error.code === 'P2034' || error.code === 'P2002'; +} + +export class PrismaKnowledgeThreadPromotionAdapter implements KnowledgeThreadPromotionStorePort { + constructor(private readonly host: PromotionHost = prisma as PrismaClient) {} + + private async serializable( + operation: (transaction: Prisma.TransactionClient) => Promise, + ) { + for (let attempt = 1; attempt <= serializableAttempts; attempt += 1) { + try { + return await this.host.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + } catch (error) { + if (!retryable(error) || attempt === serializableAttempts) throw error; + } + } + throw new Error('knowledge_thread_promotion_retry_exhausted'); + } + + async preview( + input: Parameters[0], + ) { + return this.host.$transaction( + async (transaction) => { + const resolved = await resolveMaterial(transaction, input, false); + if (!resolved.ok) { + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promote_rejected', + resultCode: 'rejected', + }); + return resolved; + } + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promote_previewed', + resultCode: 'previewed', + }); + return success(publicResolved(resolved.value)); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + } + + async resolveForCommit( + input: Parameters[0], + ) { + return this.host.$transaction( + async (transaction) => { + const resolved = await resolveMaterial(transaction, input, false); + return resolved.ok ? success(publicResolved(resolved.value)) : resolved; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + } + + async findIdempotent( + input: Parameters[0], + ) { + if ( + !hashPattern.test(input.requestKeyHash) || + !hashPattern.test(input.requestPayloadHash) + ) { + return failure(400, 'invalid_request'); + } + return this.host.$transaction(async (transaction) => { + const ledger = + await transaction.knowledgeThreadPromotionRequest.findUnique({ + where: { + promoterUserId_requestKeyHash: { + promoterUserId: input.actor.userId, + requestKeyHash: input.requestKeyHash, + }, + }, + include: { promotion: true }, + }); + if (!ledger) return success(null); + const request = { + selectedReplyMessageIds: Array.from( + { length: ledger.promotion.selectedMessageCount }, + () => '__redacted__', + ), + includeSharedCard: ledger.promotion.includesSharedCard, + destination: + ledger.promotion.scope === 'personal' + ? { scope: 'personal' as const, organizationGroupAccountIds: [] } + : { + scope: 'organization' as const, + organizationGroupAccountIds: Array.from( + { length: ledger.promotion.destinationGrantCount }, + () => '__redacted__', + ), + }, + synthesis: { + title: '__redacted__', + content: '__redacted__', + confidenceBasisPoints: null, + unresolvedQuestions: [], + }, + } satisfies KnowledgeThreadPromotionRequest; + const same = ledger.requestPayloadHash === input.requestPayloadHash; + await writePromotionAudit(transaction, { + actor: input.actor, + auditActor: input.auditActor, + promotionId: ledger.promotion.id, + request, + action: 'knowledge_thread_promote_duplicate_detected', + resultCode: same ? 'reused' : 'conflict', + duplicate: true, + }); + return same + ? success(commitRecord(ledger.promotion, false)) + : failure(409, 'idempotency_conflict'); + }); + } + + async commit( + input: Parameters[0], + ) { + if ( + !hashPattern.test(input.expectedBindingHash) || + !hashPattern.test(input.requestKeyHash) || + !hashPattern.test(input.requestPayloadHash) + ) { + return failure(400, 'invalid_request'); + } + const synthesisId = randomUUID(); + const synthesisVersionId = randomUUID(); + return this.serializable(async (transaction) => { + const existing = + await transaction.knowledgeThreadPromotionRequest.findUnique({ + where: { + promoterUserId_requestKeyHash: { + promoterUserId: input.actor.userId, + requestKeyHash: input.requestKeyHash, + }, + }, + include: { promotion: true }, + }); + if (existing) { + const same = existing.requestPayloadHash === input.requestPayloadHash; + await writePromotionAudit(transaction, { + ...input, + promotionId: existing.promotion.id, + action: 'knowledge_thread_promote_duplicate_detected', + resultCode: same ? 'reused' : 'conflict', + duplicate: true, + }); + return same + ? success(commitRecord(existing.promotion, false)) + : failure(409, 'idempotency_conflict'); + } + const existingPromotion = + await transaction.knowledgeThreadPromotion.findUnique({ + where: { id: input.promotionId }, + select: { id: true }, + }); + if (existingPromotion) { + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promote_rejected', + resultCode: 'conflict', + }); + return failure(409, 'promotion_conflict'); + } + const resolved = await resolveMaterial(transaction, input, true); + if (!resolved.ok) { + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promote_rejected', + resultCode: 'rejected', + }); + return resolved; + } + if (resolved.value.bindingHash !== input.expectedBindingHash) { + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promote_rejected', + resultCode: 'conflict', + }); + return failure(409, 'stale_preview'); + } + const organizationId = + input.request.destination.scope === 'organization' + ? (input.actor.organizationId ?? null) + : null; + await transaction.knowledgeSynthesis.create({ + data: { + id: synthesisId, + ownerUserId: input.actor.userId, + scope: input.request.destination.scope, + organizationId, + title: input.request.synthesis.title, + currentVersion: 1, + createdBy: input.actor.userId, + updatedBy: input.actor.userId, + versions: { + create: { + id: synthesisVersionId, + version: 1, + content: input.request.synthesis.content, + unresolvedQuestions: input.request.synthesis.unresolvedQuestions, + confidenceBasisPoints: + input.request.synthesis.confidenceBasisPoints, + createdBy: input.actor.userId, + }, + }, + groupGrants: { + create: input.request.destination.organizationGroupAccountIds.map( + (groupAccountId) => ({ + groupAccountId, + createdBy: input.actor.userId, + updatedBy: input.actor.userId, + }), + ), + }, + }, + }); + const row = await transaction.knowledgeThreadPromotion.create({ + data: { + id: input.promotionId, + sourceShareId: resolved.value.sourceShareId, + sourceShareVersion: resolved.value.sourceShareVersion, + sourceShareContentHash: resolved.value.sourceShareContentHash, + sourceRoomId: resolved.value.sourceRoomId, + sourceRootMessageId: input.rootMessageId, + promoterUserId: input.actor.userId, + ownerUserId: input.actor.userId, + scope: input.request.destination.scope, + organizationId, + destinationSynthesisId: synthesisId, + destinationSynthesisVersionId: synthesisVersionId, + destinationSynthesisVersionNumber: 1, + previewSchemaVersion: 1, + selectionHash: resolved.value.selectionHash, + contentHash: resolved.value.contentHash, + selectedMessageCount: resolved.value.selectedMessages.length, + includesSharedCard: input.request.includeSharedCard, + destinationGrantCount: + input.request.destination.organizationGroupAccountIds.length, + destinationGrantHash: + input.request.destination.organizationGroupAccountIds.length === 0 + ? null + : sha256( + 'destination-grants', + JSON.stringify( + input.request.destination.organizationGroupAccountIds, + ), + ), + version: 1, + createdBy: input.actor.userId, + }, + }); + await transaction.knowledgeThreadPromotionMessage.createMany({ + data: resolved.value.selectedMessages.map((message) => ({ + id: randomUUID(), + promotionId: row.id, + sourceRoomId: resolved.value.sourceRoomId, + sourceRootMessageId: input.rootMessageId, + sourceMessageId: message.sourceMessageId, + sourceActivitySequence: message.sourceActivitySequence, + sourceMessageCreatedAt: message.createdAt, + ordinal: message.ordinal, + authorCategory: message.authorCategory, + content: message.content, + contentHash: message.contentHash, + createdBy: input.actor.userId, + })), + }); + await transaction.knowledgeThreadPromotionRequest.create({ + data: { + id: randomUUID(), + promoterUserId: input.actor.userId, + requestKeyHash: input.requestKeyHash, + requestPayloadHash: input.requestPayloadHash, + promotionId: row.id, + createdBy: input.actor.userId, + }, + }); + await transaction.knowledgeSynthesisSource.create({ + data: { + synthesisVersionId, + relationType: 'primary', + ordinal: 0, + sourceThreadPromotionId: row.id, + createdBy: input.actor.userId, + }, + }); + const auditActor = knowledgeProvenanceAuditActor( + input.actor, + input.auditActor, + ); + const synthesisAudit = new PrismaKnowledgeProvenanceAuditWriter( + transaction, + ); + await synthesisAudit.write({ + action: 'knowledge_synthesis_created', + actor: auditActor, + targetTable: 'knowledge_syntheses', + targetId: synthesisId, + metadata: { + scope: input.request.destination.scope, + sourceCount: 1, + version: 1, + }, + }); + await synthesisAudit.write({ + action: 'knowledge_synthesis_source_linked', + actor: auditActor, + targetTable: 'knowledge_syntheses', + targetId: synthesisId, + metadata: { + sourceKind: 'thread_promotion', + relationType: 'primary', + version: 1, + }, + }); + await writePromotionAudit(transaction, { + ...input, + action: 'knowledge_thread_promoted', + resultCode: 'created', + }); + return success(commitRecord(row, true)); + }); + } +} + +export const prismaKnowledgeThreadPromotionAdapter = + new PrismaKnowledgeThreadPromotionAdapter(); diff --git a/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAuditAdapter.ts b/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAuditAdapter.ts new file mode 100644 index 00000000..2102e3c1 --- /dev/null +++ b/packages/backend/src/adapters/knowledge/prismaKnowledgeThreadPromotionAuditAdapter.ts @@ -0,0 +1,159 @@ +import { Prisma } from '@prisma/client'; + +import type { + KnowledgeThreadPromotionAuditEntry, + KnowledgeThreadPromotionAuditWriter, +} from '../../application/knowledge/knowledgeThreadPromotionPorts.js'; +import { normalizeAuthIdentifier } from '../../services/authIdentifiers.js'; +import { normalizeAuthScopes } from '../../services/authScopes.js'; + +type AuditClient = Pick; + +const requestIdPattern = /^[A-Za-z0-9._-]{1,128}$/; +const identifierMaximum = 255; +const audienceMaximum = 100; + +const targetByAction = { + knowledge_thread_promote_previewed: 'knowledge_thread_promotions', + knowledge_thread_promoted: 'knowledge_thread_promotions', + knowledge_thread_promote_duplicate_detected: 'knowledge_thread_promotions', + knowledge_thread_promote_rejected: 'knowledge_thread_promotions', +} as const; + +function requiredText(value: string | undefined, maximum: number) { + try { + return normalizeAuthIdentifier(value, maximum); + } catch { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } +} + +function optionalText(value: string | undefined, maximum: number) { + return value === undefined ? undefined : requiredText(value, maximum); +} + +function actorMetadata( + actor: KnowledgeThreadPromotionAuditEntry['actor'], +): Prisma.InputJsonObject { + const requestId = requiredText(actor.requestId, 128); + if ( + !requestIdPattern.test(requestId) || + (actor.source !== 'api' && actor.source !== 'agent') + ) { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + const auth: Record = { + principalUserId: requiredText(actor.principalUserId, identifierMaximum), + actorUserId: requiredText(actor.actorUserId, identifierMaximum), + }; + if (actor.authScopes !== undefined) { + try { + auth.scopes = normalizeAuthScopes(actor.authScopes); + } catch { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + } + const tokenId = optionalText(actor.authTokenId, identifierMaximum); + if (tokenId !== undefined) auth.tokenId = tokenId; + if (actor.authAudience !== undefined) { + if ( + !Array.isArray(actor.authAudience) || + actor.authAudience.length > audienceMaximum + ) { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + auth.audience = [ + ...new Set( + actor.authAudience.map((value) => + requiredText(value, identifierMaximum), + ), + ), + ]; + } + if (actor.authExpiresAt !== undefined) { + if (!Number.isSafeInteger(actor.authExpiresAt) || actor.authExpiresAt < 0) { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + auth.expiresAt = actor.authExpiresAt; + } + const metadata: Record = { + _auth: auth, + _request: { id: requestId, source: actor.source }, + }; + const runId = optionalText(actor.agentRunId, identifierMaximum); + const decisionRequestId = optionalText( + actor.decisionRequestId, + identifierMaximum, + ); + if (runId !== undefined || decisionRequestId !== undefined) { + metadata._agent = { + ...(runId !== undefined ? { runId } : {}), + ...(decisionRequestId !== undefined ? { decisionRequestId } : {}), + }; + } + return metadata; +} + +function allowlistedMetadata( + metadata: KnowledgeThreadPromotionAuditEntry['metadata'], +): Prisma.InputJsonObject { + if ( + metadata.schemaVersion !== 1 || + !['previewed', 'created', 'reused', 'rejected', 'conflict'].includes( + metadata.resultCode, + ) || + (metadata.scope !== 'personal' && metadata.scope !== 'organization') || + !Number.isInteger(metadata.selectedMessageCount) || + metadata.selectedMessageCount < 1 || + metadata.selectedMessageCount > 100 || + !Number.isInteger(metadata.organizationGrantCount) || + metadata.organizationGrantCount < 0 || + metadata.organizationGrantCount > 20 || + typeof metadata.includesSharedCard !== 'boolean' || + typeof metadata.duplicate !== 'boolean' + ) { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + return { + schemaVersion: 1, + resultCode: metadata.resultCode, + scope: metadata.scope, + selectedMessageCount: metadata.selectedMessageCount, + includesSharedCard: metadata.includesSharedCard, + organizationGrantCount: metadata.organizationGrantCount, + duplicate: metadata.duplicate, + }; +} + +export class PrismaKnowledgeThreadPromotionAuditWriter implements KnowledgeThreadPromotionAuditWriter { + constructor(private readonly client: AuditClient) {} + + async write(entry: KnowledgeThreadPromotionAuditEntry) { + if ( + targetByAction[entry.action] !== entry.targetTable || + !entry.targetId || + entry.targetId.length > identifierMaximum + ) { + throw new Error('knowledge_thread_promotion_audit_contract_invalid'); + } + const requestId = entry.actor.requestId?.trim(); + await this.client.auditLog.create({ + data: { + action: entry.action, + userId: requiredText(entry.actor.userId, identifierMaximum), + requestId: + requestId && requestIdPattern.test(requestId) ? requestId : undefined, + source: + entry.actor.source === 'api' || entry.actor.source === 'agent' + ? entry.actor.source + : undefined, + targetTable: entry.targetTable, + targetId: entry.targetId, + metadata: { + ...allowlistedMetadata(entry.metadata), + ...actorMetadata(entry.actor), + }, + }, + }); + } +} diff --git a/packages/backend/src/application/knowledge/knowledgeItemPorts.ts b/packages/backend/src/application/knowledge/knowledgeItemPorts.ts index e232a093..34f52fce 100644 --- a/packages/backend/src/application/knowledge/knowledgeItemPorts.ts +++ b/packages/backend/src/application/knowledge/knowledgeItemPorts.ts @@ -54,6 +54,19 @@ export type KnowledgeActor = { userId: string; organizationId?: string; groupAccountIds: string[]; + /** + * Current Chat authorization claims are optional because non-Chat Knowledge + * flows do not need them. They are required before live Chat provenance is + * exposed; an absent context therefore fails closed rather than falling back + * to the Knowledge principal or a raw token subject. + */ + chat?: { + userId: string; + roles: string[]; + projectIds: string[]; + groupIds: string[]; + groupAccountIds: string[]; + }; }; export type KnowledgeAuditActorContext = { diff --git a/packages/backend/src/application/knowledge/knowledgeProvenancePorts.ts b/packages/backend/src/application/knowledge/knowledgeProvenancePorts.ts index cd3c14c7..41c4adcc 100644 --- a/packages/backend/src/application/knowledge/knowledgeProvenancePorts.ts +++ b/packages/backend/src/application/knowledge/knowledgeProvenancePorts.ts @@ -59,7 +59,7 @@ export const knowledgeSynthesisSourceRelationTypes = [ export type KnowledgeSynthesisSourceRelationType = (typeof knowledgeSynthesisSourceRelationTypes)[number]; -export const knowledgeSynthesisSourceKinds = [ +export const knowledgeSynthesisInputSourceKinds = [ 'item', 'snapshot', 'annotation', @@ -68,6 +68,11 @@ export const knowledgeSynthesisSourceKinds = [ 'conversation_turn', 'synthesis_version', ] as const; + +export const knowledgeSynthesisSourceKinds = [ + ...knowledgeSynthesisInputSourceKinds, + 'thread_promotion', +] as const; export type KnowledgeSynthesisSourceKind = (typeof knowledgeSynthesisSourceKinds)[number]; diff --git a/packages/backend/src/application/knowledge/knowledgeSynthesisUseCases.ts b/packages/backend/src/application/knowledge/knowledgeSynthesisUseCases.ts index 328d64f1..9a5b89c1 100644 --- a/packages/backend/src/application/knowledge/knowledgeSynthesisUseCases.ts +++ b/packages/backend/src/application/knowledge/knowledgeSynthesisUseCases.ts @@ -6,7 +6,7 @@ import type { import { knowledgeItemScopes } from './knowledgeItemPorts.js'; import { knowledgeProvenanceLimits, - knowledgeSynthesisSourceKinds, + knowledgeSynthesisInputSourceKinds, knowledgeSynthesisSourceRelationTypes, type KnowledgePageBoundary, type KnowledgeProvenanceUnitOfWork, @@ -77,7 +77,10 @@ function normalizeSources( Object.keys(record).some( (key) => !['kind', 'sourceId', 'relationType'].includes(key), ) || - !isAllowedKnowledgeValue(knowledgeSynthesisSourceKinds, record.kind) || + !isAllowedKnowledgeValue( + knowledgeSynthesisInputSourceKinds, + record.kind, + ) || !isBoundedKnowledgeId(record.sourceId) || !isAllowedKnowledgeValue( knowledgeSynthesisSourceRelationTypes, diff --git a/packages/backend/src/application/knowledge/knowledgeThreadPromotionPorts.ts b/packages/backend/src/application/knowledge/knowledgeThreadPromotionPorts.ts new file mode 100644 index 00000000..5a2c2ea1 --- /dev/null +++ b/packages/backend/src/application/knowledge/knowledgeThreadPromotionPorts.ts @@ -0,0 +1,239 @@ +import type { + KnowledgeActor, + KnowledgeAuditActor, + KnowledgeAuditActorContext, + KnowledgeItemScope, + KnowledgeSourceType, +} from './knowledgeItemPorts.js'; +import type { + KnowledgeAnnotationKind, + KnowledgeConversationRole, + KnowledgeProvenanceOrigin, +} from './knowledgeProvenancePorts.js'; +import type { KnowledgeShareSelectionCategory } from './knowledgeSharePorts.js'; + +export const knowledgeThreadPromotionAuthorCategories = ['user'] as const; +export type KnowledgeThreadPromotionAuthorCategory = + (typeof knowledgeThreadPromotionAuthorCategories)[number]; + +export const knowledgeThreadPromotionLimits = { + id: 200, + requestKey: 200, + previewTokenBytes: 4096, + previewTtlMs: 10 * 60 * 1000, + selectedReplies: 100, + organizationGroupAccountIds: 20, + titleCodePoints: 500, + synthesisContentBytes: 256 * 1024, + unresolvedQuestions: 50, + unresolvedQuestionCodePoints: 4000, + selectedMessageBytes: 64 * 1024, +} as const; + +export type KnowledgeThreadPromotionDestination = + | { + scope: 'personal'; + organizationGroupAccountIds: []; + } + | { + scope: 'organization'; + organizationGroupAccountIds: string[]; + }; + +export type KnowledgeThreadPromotionSynthesisDraft = { + title: string; + content: string; + confidenceBasisPoints: number | null; + unresolvedQuestions: string[]; +}; + +/** + * Canonical request shared by preview and commit. Reply order is meaningful; + * callers must not sort this list or silently add the root/other replies. + */ +export type KnowledgeThreadPromotionRequest = { + selectedReplyMessageIds: string[]; + includeSharedCard: boolean; + destination: KnowledgeThreadPromotionDestination; + synthesis: KnowledgeThreadPromotionSynthesisDraft; +}; + +export type KnowledgeThreadPromotionSelectedMessage = { + sourceMessageId: string; + /** Internal exact-version boundary; public API projections omit it. */ + sourceActivitySequence: bigint; + ordinal: number; + content: string; + contentHash: string; + createdAt: Date; + authorCategory: KnowledgeThreadPromotionAuthorCategory; +}; + +/** + * Public-safe immutable card material. Internal Knowledge/Chat source IDs are + * deliberately absent even though the persistence adapter retains exact FKs. + */ +export type KnowledgeThreadPromotionShareCardPreview = { + schemaVersion: 1; + shareVersion: number; + title?: string; + sourceType?: KnowledgeSourceType; + canonicalUrl?: string; + snapshot?: { + version: number; + sha256: string; + excerpt?: string; + }; + sharerNote?: string; + labels: Array<{ displayName: string; ordinal: number }>; + annotations: Array<{ + revision: number; + kind: KnowledgeAnnotationKind; + origin: KnowledgeProvenanceOrigin; + content: string; + ordinal: number; + }>; + turns: Array<{ + role: KnowledgeConversationRole; + origin: KnowledgeProvenanceOrigin; + content: string; + name: string | null; + occurredAt: Date | null; + ordinal: number; + }>; + syntheses: Array<{ + version: number; + title: string; + content: string; + confidenceBasisPoints: number | null; + unresolvedQuestions: string[]; + ordinal: number; + }>; + selectedCategories: KnowledgeShareSelectionCategory[]; + omittedCategories: KnowledgeShareSelectionCategory[]; +}; + +export type KnowledgeThreadPromotionResolvedPreview = { + promotionId: string; + rootMessageId: string; + sourceRoomName: string; + sourceRoomType: string; + sourceShareVersion: number; + sourceShareContentHash: string; + threadReplyCount: number; + selectedMessages: KnowledgeThreadPromotionSelectedMessage[]; + selectedShareCard: KnowledgeThreadPromotionShareCardPreview | null; + destination: KnowledgeThreadPromotionDestination; + bindingHash: string; +}; + +export type KnowledgeThreadPromotionCommitRecord = { + promotionId: string; + synthesisId: string; + synthesisVersionId: string; + synthesisVersion: 1; + scope: KnowledgeItemScope; + selectedMessageCount: number; + includesSharedCard: boolean; + createdAt: Date; + created: boolean; +}; + +export type KnowledgeThreadPromotionFailure = { + status: number; + code: + | 'invalid_request' + | 'not_found' + | 'stale_preview' + | 'preview_token_invalid' + | 'preview_token_expired' + | 'idempotency_conflict' + | 'organization_confirmation_required' + | 'promotion_conflict'; + message: string; +}; + +export type KnowledgeThreadPromotionPortResult = + | { ok: true; value: T } + | { ok: false; error: KnowledgeThreadPromotionFailure }; + +export interface KnowledgeThreadPromotionStorePort { + /** Resolves exact current state and writes the mandatory preview audit. */ + preview(input: { + actor: KnowledgeActor; + auditActor: KnowledgeAuditActorContext; + rootMessageId: string; + promotionId: string; + request: KnowledgeThreadPromotionRequest; + }): Promise< + KnowledgeThreadPromotionPortResult + >; + + /** Re-resolves current ACL, root/share state and selected reply hashes. */ + resolveForCommit(input: { + actor: KnowledgeActor; + rootMessageId: string; + promotionId: string; + request: KnowledgeThreadPromotionRequest; + }): Promise< + KnowledgeThreadPromotionPortResult + >; + + /** + * Returns an existing exact request and writes duplicate audit atomically. + * A reused key with another payload returns idempotency_conflict. + */ + findIdempotent(input: { + actor: KnowledgeActor; + auditActor: KnowledgeAuditActorContext; + requestKeyHash: string; + requestPayloadHash: string; + }): Promise< + KnowledgeThreadPromotionPortResult + >; + + /** + * Atomically creates promotion snapshots, synthesis v1, exactly-one source, + * explicit organization grants, request ledger and mandatory audits. + */ + commit(input: { + actor: KnowledgeActor; + auditActor: KnowledgeAuditActorContext; + rootMessageId: string; + promotionId: string; + request: KnowledgeThreadPromotionRequest; + expectedBindingHash: string; + requestKeyHash: string; + requestPayloadHash: string; + }): Promise< + KnowledgeThreadPromotionPortResult + >; +} + +export type KnowledgeThreadPromotionAuditAction = + | 'knowledge_thread_promote_previewed' + | 'knowledge_thread_promoted' + | 'knowledge_thread_promote_duplicate_detected' + | 'knowledge_thread_promote_rejected'; + +export type KnowledgeThreadPromotionAuditMetadata = { + schemaVersion: 1; + resultCode: 'previewed' | 'created' | 'reused' | 'rejected' | 'conflict'; + scope: KnowledgeItemScope; + selectedMessageCount: number; + includesSharedCard: boolean; + organizationGrantCount: number; + duplicate: boolean; +}; + +export type KnowledgeThreadPromotionAuditEntry = { + action: KnowledgeThreadPromotionAuditAction; + actor: KnowledgeAuditActor; + targetTable: 'knowledge_thread_promotions'; + targetId: string; + metadata: KnowledgeThreadPromotionAuditMetadata; +}; + +export interface KnowledgeThreadPromotionAuditWriter { + write(entry: KnowledgeThreadPromotionAuditEntry): Promise; +} diff --git a/packages/backend/src/application/knowledge/knowledgeThreadPromotionToken.ts b/packages/backend/src/application/knowledge/knowledgeThreadPromotionToken.ts new file mode 100644 index 00000000..8eaa92ca --- /dev/null +++ b/packages/backend/src/application/knowledge/knowledgeThreadPromotionToken.ts @@ -0,0 +1,318 @@ +import { + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from 'node:crypto'; +import { TextDecoder } from 'node:util'; + +import type { KnowledgeActor } from './knowledgeItemPorts.js'; +import { knowledgeThreadPromotionLimits } from './knowledgeThreadPromotionPorts.js'; + +const VERSION = 1 as const; +const PURPOSE = 'knowledge_thread_promotion_preview' as const; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; +const HASH_PATTERN = /^[a-f0-9]{64}$/; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SECRET_MINIMUM_BYTES = 32; +const CLOCK_SKEW_MS = 5_000; + +type PreviewEnvelope = { + v: typeof VERSION; + purpose: typeof PURPOSE; + promotionId: string; + actorFingerprint: string; + rootFingerprint: string; + payloadBinding: string; + issuedAt: number; + expiresAt: number; +}; + +let ephemeralSecret: Buffer | undefined; + +export class KnowledgeThreadPromotionTokenError extends Error { + constructor( + readonly code: + 'preview_token_invalid' | 'preview_token_expired' | 'stale_preview', + ) { + super(code); + this.name = 'KnowledgeThreadPromotionTokenError'; + } +} + +function invalid(): never { + throw new KnowledgeThreadPromotionTokenError('preview_token_invalid'); +} + +function resolveRootSecret(env: NodeJS.ProcessEnv) { + const configured = env.KNOWLEDGE_CURSOR_SIGNING_SECRET; + if (configured !== undefined && configured.length > 0) { + if ( + configured.trim().length === 0 || + Buffer.byteLength(configured, 'utf8') < SECRET_MINIMUM_BYTES + ) { + throw new Error( + 'KNOWLEDGE_CURSOR_SIGNING_SECRET must contain at least 32 UTF-8 bytes', + ); + } + return Buffer.from(configured, 'utf8'); + } + if ((env.NODE_ENV ?? '').trim().toLowerCase() === 'production') { + throw new Error( + 'KNOWLEDGE_CURSOR_SIGNING_SECRET is required in production', + ); + } + ephemeralSecret ??= randomBytes(SECRET_MINIMUM_BYTES); + return Buffer.from(ephemeralSecret); +} + +function deriveSecret(root: Buffer) { + return createHmac('sha256', root) + .update('erp4:knowledge:thread-promotion-preview-key:v1\0', 'utf8') + .digest(); +} + +function encode(value: string | Uint8Array) { + return Buffer.from(value).toString('base64url'); +} + +function decode(value: string) { + if (!BASE64URL_PATTERN.test(value)) invalid(); + const decoded = Buffer.from(value, 'base64url'); + if (decoded.length === 0 || encode(decoded) !== value) invalid(); + return decoded; +} + +function fingerprint(secret: Buffer, domain: string, value: string) { + return createHmac('sha256', secret) + .update(domain, 'utf8') + .update('\0', 'utf8') + .update(value, 'utf8') + .digest('hex'); +} + +function actorFingerprint(actor: KnowledgeActor, secret: Buffer) { + return fingerprint( + secret, + 'erp4:knowledge:thread-promotion-preview-actor:v1', + actor.userId, + ); +} + +function parseEnvelope(value: Buffer): PreviewEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(value), + ) as unknown; + } catch { + invalid(); + } + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.getPrototypeOf(parsed) !== Object.prototype + ) { + invalid(); + } + const record = parsed as Record; + const allowed = new Set([ + 'v', + 'purpose', + 'promotionId', + 'actorFingerprint', + 'rootFingerprint', + 'payloadBinding', + 'issuedAt', + 'expiresAt', + ]); + if ( + Object.keys(record).length !== allowed.size || + Object.keys(record).some((key) => !allowed.has(key)) || + record.v !== VERSION || + record.purpose !== PURPOSE || + typeof record.promotionId !== 'string' || + !UUID_PATTERN.test(record.promotionId) || + typeof record.actorFingerprint !== 'string' || + !HASH_PATTERN.test(record.actorFingerprint) || + typeof record.rootFingerprint !== 'string' || + !HASH_PATTERN.test(record.rootFingerprint) || + typeof record.payloadBinding !== 'string' || + !HASH_PATTERN.test(record.payloadBinding) || + typeof record.issuedAt !== 'number' || + !Number.isSafeInteger(record.issuedAt) || + typeof record.expiresAt !== 'number' || + !Number.isSafeInteger(record.expiresAt) || + record.expiresAt - record.issuedAt !== + knowledgeThreadPromotionLimits.previewTtlMs + ) { + invalid(); + } + return record as PreviewEnvelope; +} + +export function createKnowledgeThreadPromotionTokenCodec( + options: { + env?: NodeJS.ProcessEnv; + now?: () => Date; + randomId?: () => string; + } = {}, +) { + const secret = deriveSecret(resolveRootSecret(options.env ?? process.env)); + const now = options.now ?? (() => new Date()); + const randomId = options.randomId ?? randomUUID; + + function sign(envelope: PreviewEnvelope) { + const payload = encode(JSON.stringify(envelope)); + const signature = encode( + createHmac('sha256', secret).update(payload, 'ascii').digest(), + ); + return `${payload}.${signature}`; + } + + function authenticate(input: { + actor: KnowledgeActor; + rootMessageId: string; + token: unknown; + }) { + if ( + typeof input.token !== 'string' || + Buffer.byteLength(input.token, 'utf8') > + knowledgeThreadPromotionLimits.previewTokenBytes + ) { + invalid(); + } + const segments = input.token.split('.'); + if (segments.length !== 2) invalid(); + const payloadSegment = segments[0]; + const signatureSegment = segments[1]; + if (!payloadSegment || !signatureSegment) invalid(); + const payload = decode(payloadSegment); + const provided = decode(signatureSegment); + const expected = createHmac('sha256', secret) + .update(payloadSegment, 'ascii') + .digest(); + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + invalid(); + } + const envelope = parseEnvelope(payload); + const current = now().getTime(); + if ( + envelope.issuedAt > current + CLOCK_SKEW_MS || + envelope.actorFingerprint !== actorFingerprint(input.actor, secret) || + envelope.rootFingerprint !== + fingerprint( + secret, + 'erp4:knowledge:thread-promotion-preview-root:v1', + input.rootMessageId, + ) + ) { + invalid(); + } + return { envelope, current }; + } + + return { + reservePromotionId() { + const promotionId = randomId(); + if (!UUID_PATTERN.test(promotionId)) { + throw new Error('knowledge_thread_promotion_token_contract_invalid'); + } + return promotionId; + }, + + create(input: { + actor: KnowledgeActor; + rootMessageId: string; + bindingHash: string; + promotionId: string; + }) { + const issuedAt = now().getTime(); + if ( + !Number.isSafeInteger(issuedAt) || + !UUID_PATTERN.test(input.promotionId) || + !HASH_PATTERN.test(input.bindingHash) + ) { + throw new Error('knowledge_thread_promotion_token_contract_invalid'); + } + const expiresAt = issuedAt + knowledgeThreadPromotionLimits.previewTtlMs; + return { + promotionId: input.promotionId, + expiresAt: new Date(expiresAt), + token: sign({ + v: VERSION, + purpose: PURPOSE, + promotionId: input.promotionId, + actorFingerprint: actorFingerprint(input.actor, secret), + rootFingerprint: fingerprint( + secret, + 'erp4:knowledge:thread-promotion-preview-root:v1', + input.rootMessageId, + ), + payloadBinding: fingerprint( + secret, + 'erp4:knowledge:thread-promotion-preview-payload:v1', + input.bindingHash, + ), + issuedAt, + expiresAt, + }), + }; + }, + + readForReplay(input: { + actor: KnowledgeActor; + rootMessageId: string; + token: unknown; + }) { + try { + const { envelope } = authenticate(input); + return { + promotionId: envelope.promotionId, + payloadBinding: envelope.payloadBinding, + expiresAt: new Date(envelope.expiresAt), + }; + } catch (error) { + if (error instanceof KnowledgeThreadPromotionTokenError) throw error; + invalid(); + } + }, + + verify(input: { + actor: KnowledgeActor; + rootMessageId: string; + bindingHash: string; + token: unknown; + }) { + try { + const { envelope, current } = authenticate(input); + if (envelope.expiresAt <= current) { + throw new KnowledgeThreadPromotionTokenError('preview_token_expired'); + } + if ( + envelope.payloadBinding !== + fingerprint( + secret, + 'erp4:knowledge:thread-promotion-preview-payload:v1', + input.bindingHash, + ) + ) { + throw new KnowledgeThreadPromotionTokenError('stale_preview'); + } + return { + promotionId: envelope.promotionId, + payloadBinding: envelope.payloadBinding, + expiresAt: new Date(envelope.expiresAt), + }; + } catch (error) { + if (error instanceof KnowledgeThreadPromotionTokenError) throw error; + invalid(); + } + }, + }; +} diff --git a/packages/backend/src/application/knowledge/knowledgeThreadPromotionUseCases.ts b/packages/backend/src/application/knowledge/knowledgeThreadPromotionUseCases.ts new file mode 100644 index 00000000..34c54f8e --- /dev/null +++ b/packages/backend/src/application/knowledge/knowledgeThreadPromotionUseCases.ts @@ -0,0 +1,672 @@ +import { createHash } from 'node:crypto'; + +import type { + KnowledgeActor, + KnowledgeAuditActorContext, +} from './knowledgeItemPorts.js'; +import { + knowledgeThreadPromotionLimits, + type KnowledgeThreadPromotionCommitRecord, + type KnowledgeThreadPromotionDestination, + type KnowledgeThreadPromotionFailure, + type KnowledgeThreadPromotionRequest, + type KnowledgeThreadPromotionResolvedPreview, + type KnowledgeThreadPromotionShareCardPreview, + type KnowledgeThreadPromotionStorePort, +} from './knowledgeThreadPromotionPorts.js'; +import { + createKnowledgeThreadPromotionTokenCodec, + KnowledgeThreadPromotionTokenError, +} from './knowledgeThreadPromotionToken.js'; +import { + hasKnowledgePrincipal, + knowledgeProvenanceAuditActor, +} from './knowledgeProvenanceValidation.js'; + +type TokenCodec = ReturnType; +type FailureCode = KnowledgeThreadPromotionFailure['code']; + +export type KnowledgeThreadPromotionUseCaseResult = + | { ok: true; value: T } + | { + ok: false; + statusCode: 400 | 404 | 409; + code: FailureCode; + message: string; + }; + +class InvalidKnowledgeThreadPromotionInput extends Error { + constructor() { + super('invalid_knowledge_thread_promotion_input'); + this.name = 'InvalidKnowledgeThreadPromotionInput'; + } +} + +const CONTROL_OR_DIRECTIONAL_CODE_POINTS = new Set([ + 0x061c, 0x200e, 0x200f, 0x2028, 0x2029, 0x202a, 0x202b, 0x202c, 0x202d, + 0x202e, 0x2066, 0x2067, 0x2068, 0x2069, 0xfeff, +]); + +function ok(value: T): KnowledgeThreadPromotionUseCaseResult { + return { ok: true, value }; +} + +function failure( + statusCode: 400 | 404 | 409, + code: FailureCode, +): KnowledgeThreadPromotionUseCaseResult { + const messages: Record = { + invalid_request: 'Invalid request', + not_found: 'Not found', + stale_preview: 'Preview is stale', + preview_token_invalid: 'Invalid preview token', + preview_token_expired: 'Preview token expired', + idempotency_conflict: 'Idempotency conflict', + organization_confirmation_required: + 'Organization audience confirmation is required', + promotion_conflict: 'Promotion conflict', + }; + return { ok: false, statusCode, code, message: messages[code] }; +} + +function portFailure(error: KnowledgeThreadPromotionFailure) { + const statusCode = + error.status === 400 || error.status === 404 || error.status === 409 + ? error.status + : 409; + return failure(statusCode, error.code); +} + +function exactRecord( + value: unknown, + allowedKeys: readonly string[], +): Record { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + const record = value as Record; + const allowed = new Set(allowedKeys); + if ( + Object.keys(record).length !== allowed.size || + Object.keys(record).some((key) => !allowed.has(key)) + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return record; +} + +function hasUnsafeCodePoint(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if ( + codePoint === undefined || + codePoint < 0x20 || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + CONTROL_OR_DIRECTIONAL_CODE_POINTS.has(codePoint) + ) { + return true; + } + } + return false; +} + +function boundedIdentifier(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + [...value].length > knowledgeThreadPromotionLimits.id || + Buffer.byteLength(value, 'utf8') > knowledgeThreadPromotionLimits.id * 4 || + hasUnsafeCodePoint(value) + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value; +} + +function boundedRequestKey(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + [...value].length > knowledgeThreadPromotionLimits.requestKey || + Buffer.byteLength(value, 'utf8') > + knowledgeThreadPromotionLimits.requestKey * 4 || + hasUnsafeCodePoint(value) + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value; +} + +function boundedText(value: unknown, maximumCodePoints: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.trim() || + [...value].length > maximumCodePoints + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value; +} + +function boundedContent(value: unknown, maximumBytes: number): string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + Buffer.byteLength(value, 'utf8') > maximumBytes + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value; +} + +function normalizeSelectedReplies(value: unknown): string[] { + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > knowledgeThreadPromotionLimits.selectedReplies + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + const selected = value.map(boundedIdentifier); + if (new Set(selected).size !== selected.length) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return selected; +} + +function normalizeUnresolvedQuestions(value: unknown): string[] { + if ( + !Array.isArray(value) || + value.length > knowledgeThreadPromotionLimits.unresolvedQuestions + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value.map((question) => + boundedText( + question, + knowledgeThreadPromotionLimits.unresolvedQuestionCodePoints, + ), + ); +} + +function normalizeConfidence(value: unknown): number | null { + if (value === null) return null; + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < 0 || + value > 10_000 + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return value; +} + +function normalizeDestination( + value: unknown, + actor: KnowledgeActor, +): KnowledgeThreadPromotionDestination { + const record = exactRecord(value, ['scope', 'organizationGroupAccountIds']); + if (!Array.isArray(record.organizationGroupAccountIds)) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + const groupAccountIds = + record.organizationGroupAccountIds.map(boundedIdentifier); + if ( + groupAccountIds.length > + knowledgeThreadPromotionLimits.organizationGroupAccountIds || + new Set(groupAccountIds).size !== groupAccountIds.length + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + groupAccountIds.sort(); + if (record.scope === 'personal') { + if (groupAccountIds.length !== 0) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return { scope: 'personal', organizationGroupAccountIds: [] }; + } + if ( + record.scope !== 'organization' || + groupAccountIds.length === 0 || + typeof actor.organizationId !== 'string' || + actor.organizationId.trim().length === 0 || + groupAccountIds.some( + (groupAccountId) => !actor.groupAccountIds.includes(groupAccountId), + ) + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + return { + scope: 'organization', + organizationGroupAccountIds: groupAccountIds, + }; +} + +function normalizeRequest( + value: unknown, + actor: KnowledgeActor, +): KnowledgeThreadPromotionRequest { + const record = exactRecord(value, [ + 'selectedReplyMessageIds', + 'includeSharedCard', + 'destination', + 'synthesis', + ]); + if (typeof record.includeSharedCard !== 'boolean') { + throw new InvalidKnowledgeThreadPromotionInput(); + } + const synthesis = exactRecord(record.synthesis, [ + 'title', + 'content', + 'confidenceBasisPoints', + 'unresolvedQuestions', + ]); + return { + selectedReplyMessageIds: normalizeSelectedReplies( + record.selectedReplyMessageIds, + ), + includeSharedCard: record.includeSharedCard, + destination: normalizeDestination(record.destination, actor), + synthesis: { + title: boundedText( + synthesis.title, + knowledgeThreadPromotionLimits.titleCodePoints, + ), + content: boundedContent( + synthesis.content, + knowledgeThreadPromotionLimits.synthesisContentBytes, + ), + confidenceBasisPoints: normalizeConfidence( + synthesis.confidenceBasisPoints, + ), + unresolvedQuestions: normalizeUnresolvedQuestions( + synthesis.unresolvedQuestions, + ), + }, + }; +} + +function validActor(actor: KnowledgeActor): boolean { + if (!hasKnowledgePrincipal(actor) || !actor.chat?.userId) return false; + try { + boundedIdentifier(actor.userId); + boundedIdentifier(actor.chat.userId); + for (const values of [ + actor.groupAccountIds, + actor.chat.roles, + actor.chat.projectIds, + actor.chat.groupIds, + actor.chat.groupAccountIds, + ]) { + if (!Array.isArray(values) || new Set(values).size !== values.length) { + return false; + } + values.forEach((value) => boundedIdentifier(value)); + } + return true; + } catch (error) { + if (error instanceof InvalidKnowledgeThreadPromotionInput) return false; + throw error; + } +} + +function normalizedAuditActor( + actor: KnowledgeActor, + auditActor: KnowledgeAuditActorContext, +): KnowledgeAuditActorContext { + return knowledgeProvenanceAuditActor(actor, auditActor); +} + +function canonicalRequest(request: KnowledgeThreadPromotionRequest) { + return { + selectedReplyMessageIds: request.selectedReplyMessageIds, + includeSharedCard: request.includeSharedCard, + destination: request.destination, + synthesis: request.synthesis, + }; +} + +export function hashKnowledgeThreadPromotionRequestKey( + actor: KnowledgeActor, + requestKey: string, +): string { + return createHash('sha256') + .update('erp4:knowledge:thread-promotion-request-key:v1\0', 'utf8') + .update(actor.userId, 'utf8') + .update('\0', 'utf8') + .update(requestKey, 'utf8') + .digest('hex'); +} + +function hashRequestPayload(input: { + rootMessageId: string; + request: KnowledgeThreadPromotionRequest; + previewPayloadBinding: string; +}): string { + return createHash('sha256') + .update('erp4:knowledge:thread-promotion-request-payload:v1\0', 'utf8') + .update( + JSON.stringify({ + rootMessageId: input.rootMessageId, + request: canonicalRequest(input.request), + previewPayloadBinding: input.previewPayloadBinding, + }), + 'utf8', + ) + .digest('hex'); +} + +function publicCard(card: KnowledgeThreadPromotionShareCardPreview) { + return { + schemaVersion: card.schemaVersion, + shareVersion: card.shareVersion, + ...(card.title === undefined ? {} : { title: card.title }), + ...(card.sourceType === undefined ? {} : { sourceType: card.sourceType }), + ...(card.canonicalUrl === undefined + ? {} + : { canonicalUrl: card.canonicalUrl }), + ...(card.snapshot === undefined + ? {} + : { + snapshot: { + version: card.snapshot.version, + sha256: card.snapshot.sha256, + ...(card.snapshot.excerpt === undefined + ? {} + : { excerpt: card.snapshot.excerpt }), + }, + }), + ...(card.sharerNote === undefined ? {} : { sharerNote: card.sharerNote }), + labels: card.labels.map((entry) => ({ + displayName: entry.displayName, + ordinal: entry.ordinal, + })), + annotations: card.annotations.map((entry) => ({ + revision: entry.revision, + kind: entry.kind, + origin: entry.origin, + content: entry.content, + ordinal: entry.ordinal, + })), + turns: card.turns.map((entry) => ({ + role: entry.role, + origin: entry.origin, + content: entry.content, + name: entry.name, + occurredAt: entry.occurredAt?.toISOString() ?? null, + ordinal: entry.ordinal, + })), + syntheses: card.syntheses.map((entry) => ({ + version: entry.version, + title: entry.title, + content: entry.content, + confidenceBasisPoints: entry.confidenceBasisPoints, + unresolvedQuestions: [...entry.unresolvedQuestions], + ordinal: entry.ordinal, + })), + selectedCategories: [...card.selectedCategories], + omittedCategories: [...card.omittedCategories], + }; +} + +function publicPreview(resolved: KnowledgeThreadPromotionResolvedPreview) { + return { + sourceThread: { + roomName: resolved.sourceRoomName, + roomType: resolved.sourceRoomType, + replyCount: resolved.threadReplyCount, + }, + selectedMessages: resolved.selectedMessages.map((message) => ({ + ordinal: message.ordinal, + content: message.content, + createdAt: message.createdAt.toISOString(), + authorCategory: message.authorCategory, + })), + selectedMessageCount: resolved.selectedMessages.length, + omittedMessageCount: Math.max( + 0, + resolved.threadReplyCount - resolved.selectedMessages.length, + ), + sharedCard: + resolved.selectedShareCard === null + ? null + : publicCard(resolved.selectedShareCard), + destination: { + scope: resolved.destination.scope, + organizationGroupCount: + resolved.destination.organizationGroupAccountIds.length, + }, + }; +} + +function publicCommit(record: KnowledgeThreadPromotionCommitRecord) { + return { + promotionId: record.promotionId, + synthesisId: record.synthesisId, + synthesisVersionId: record.synthesisVersionId, + synthesisVersion: record.synthesisVersion, + scope: record.scope, + selectedMessageCount: record.selectedMessageCount, + includesSharedCard: record.includesSharedCard, + createdAt: record.createdAt.toISOString(), + }; +} + +export function createKnowledgeThreadPromotionUseCases(dependencies: { + store: KnowledgeThreadPromotionStorePort; + tokenCodec?: TokenCodec; +}) { + const tokenCodec = + dependencies.tokenCodec ?? createKnowledgeThreadPromotionTokenCodec(); + + return { + async preview(input: { + actor: KnowledgeActor; + auditActor: KnowledgeAuditActorContext; + rootMessageId: unknown; + body: unknown; + }): Promise> { + if (!validActor(input.actor)) return failure(404, 'not_found'); + let rootMessageId: string; + let request: KnowledgeThreadPromotionRequest; + try { + rootMessageId = boundedIdentifier(input.rootMessageId); + request = normalizeRequest(input.body, input.actor); + } catch (error) { + if (error instanceof InvalidKnowledgeThreadPromotionInput) { + return failure(400, 'invalid_request'); + } + throw error; + } + const promotionId = tokenCodec.reservePromotionId(); + const resolved = await dependencies.store.preview({ + actor: input.actor, + auditActor: normalizedAuditActor(input.actor, input.auditActor), + rootMessageId, + promotionId, + request, + }); + if (!resolved.ok) return portFailure(resolved.error); + if ( + resolved.value.promotionId !== promotionId || + resolved.value.rootMessageId !== rootMessageId || + resolved.value.destination.scope !== request.destination.scope + ) { + return failure(409, 'promotion_conflict'); + } + const token = tokenCodec.create({ + actor: input.actor, + rootMessageId, + bindingHash: resolved.value.bindingHash, + promotionId, + }); + const preview = publicPreview(resolved.value); + return ok({ + ...preview, + synthesis: { + ...request.synthesis, + unresolvedQuestions: [...request.synthesis.unresolvedQuestions], + }, + previewToken: token.token, + expiresAt: token.expiresAt.toISOString(), + requiresConfirmation: true, + requiresOrganizationAudienceConfirmation: + request.destination.scope === 'organization', + }); + }, + + async commit(input: { + actor: KnowledgeActor; + auditActor: KnowledgeAuditActorContext; + rootMessageId: unknown; + body: unknown; + }): Promise> { + if (!validActor(input.actor)) return failure(404, 'not_found'); + let rootMessageId: string; + let request: KnowledgeThreadPromotionRequest; + let previewToken: unknown; + let requestKey: string; + try { + rootMessageId = boundedIdentifier(input.rootMessageId); + const body = exactRecord(input.body, [ + 'selectedReplyMessageIds', + 'includeSharedCard', + 'destination', + 'synthesis', + 'previewToken', + 'requestKey', + 'confirmed', + 'organizationAudienceConfirmed', + ]); + if (body.confirmed !== true) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + request = normalizeRequest( + { + selectedReplyMessageIds: body.selectedReplyMessageIds, + includeSharedCard: body.includeSharedCard, + destination: body.destination, + synthesis: body.synthesis, + }, + input.actor, + ); + if ( + request.destination.scope === 'organization' && + body.organizationAudienceConfirmed !== true + ) { + return failure(400, 'organization_confirmation_required'); + } + if ( + request.destination.scope === 'personal' && + body.organizationAudienceConfirmed !== false + ) { + throw new InvalidKnowledgeThreadPromotionInput(); + } + previewToken = body.previewToken; + requestKey = boundedRequestKey(body.requestKey); + } catch (error) { + if (error instanceof InvalidKnowledgeThreadPromotionInput) { + return failure(400, 'invalid_request'); + } + throw error; + } + + let replayToken; + try { + replayToken = tokenCodec.readForReplay({ + actor: input.actor, + rootMessageId, + token: previewToken, + }); + } catch (error) { + if (!(error instanceof KnowledgeThreadPromotionTokenError)) throw error; + return failure(400, error.code); + } + + const requestKeyHash = hashKnowledgeThreadPromotionRequestKey( + input.actor, + requestKey, + ); + const requestPayloadHash = hashRequestPayload({ + rootMessageId, + request, + previewPayloadBinding: replayToken.payloadBinding, + }); + const replay = await dependencies.store.findIdempotent({ + actor: input.actor, + auditActor: normalizedAuditActor(input.actor, input.auditActor), + requestKeyHash, + requestPayloadHash, + }); + if (!replay.ok) return portFailure(replay.error); + if (replay.value) { + return ok({ + ...publicCommit(replay.value), + created: false, + reused: true, + }); + } + + const resolved = await dependencies.store.resolveForCommit({ + actor: input.actor, + rootMessageId, + promotionId: replayToken.promotionId, + request, + }); + if (!resolved.ok) return portFailure(resolved.error); + if ( + resolved.value.promotionId !== replayToken.promotionId || + resolved.value.rootMessageId !== rootMessageId || + resolved.value.destination.scope !== request.destination.scope + ) { + return failure(409, 'promotion_conflict'); + } + let verified; + try { + verified = tokenCodec.verify({ + actor: input.actor, + rootMessageId, + bindingHash: resolved.value.bindingHash, + token: previewToken, + }); + } catch (error) { + if (!(error instanceof KnowledgeThreadPromotionTokenError)) throw error; + return failure(error.code === 'stale_preview' ? 409 : 400, error.code); + } + if (verified.promotionId !== replayToken.promotionId) { + return failure(400, 'preview_token_invalid'); + } + + const committed = await dependencies.store.commit({ + actor: input.actor, + auditActor: normalizedAuditActor(input.actor, input.auditActor), + rootMessageId, + promotionId: verified.promotionId, + request, + expectedBindingHash: resolved.value.bindingHash, + requestKeyHash, + requestPayloadHash, + }); + if (!committed.ok) return portFailure(committed.error); + return ok({ + ...publicCommit(committed.value), + created: committed.value.created, + reused: !committed.value.created, + }); + }, + }; +} + +export type KnowledgeThreadPromotionUseCases = ReturnType< + typeof createKnowledgeThreadPromotionUseCases +>; diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 8c6a491c..17dd8074 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -68,6 +68,7 @@ import { registerKnowledgeConversationRoutes } from './knowledgeConversations.js import { registerKnowledgeConversationImportRoutes } from './knowledgeConversationImports.js'; import { registerKnowledgeSynthesisRoutes } from './knowledgeSyntheses.js'; import { registerKnowledgeShareRoutes } from './knowledgeShares.js'; +import { registerKnowledgeThreadPromotionRoutes } from './knowledgeThreadPromotions.js'; export async function registerRoutes(app: FastifyInstance) { await registerAuthRoutes(app); @@ -139,4 +140,5 @@ export async function registerRoutes(app: FastifyInstance) { await registerKnowledgeConversationImportRoutes(app); await registerKnowledgeSynthesisRoutes(app); await registerKnowledgeShareRoutes(app); + await registerKnowledgeThreadPromotionRoutes(app); } diff --git a/packages/backend/src/routes/knowledgeRouteContext.ts b/packages/backend/src/routes/knowledgeRouteContext.ts index 91c7d09b..4a3e74f6 100644 --- a/packages/backend/src/routes/knowledgeRouteContext.ts +++ b/packages/backend/src/routes/knowledgeRouteContext.ts @@ -19,24 +19,42 @@ function knowledgeActorUserId(request: FastifyRequest) { return typeof candidate === 'string' ? candidate.trim() : ''; } +function normalizedStrings(value: unknown) { + return [ + ...new Set( + (Array.isArray(value) ? value : []) + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; +} + export function knowledgeActorFromRequest( request: FastifyRequest, + options: { includeChat?: boolean } = {}, ): KnowledgeActor { const userId = knowledgeActorUserId(request); const orgId = request.user?.orgId; const groupAccountIds = request.user?.groupAccountIds; + const chatUserId = + typeof request.user?.userId === 'string' ? request.user.userId.trim() : ''; return { userId, organizationId: typeof orgId === 'string' ? orgId.trim() || undefined : undefined, - groupAccountIds: [ - ...new Set( - (Array.isArray(groupAccountIds) ? groupAccountIds : []) - .filter((value): value is string => typeof value === 'string') - .map((value) => value.trim()) - .filter(Boolean), - ), - ], + groupAccountIds: normalizedStrings(groupAccountIds), + ...(options.includeChat === true && chatUserId + ? { + chat: { + userId: chatUserId, + roles: normalizedStrings(request.user?.roles), + projectIds: normalizedStrings(request.user?.projectIds), + groupIds: normalizedStrings(request.user?.groupIds), + groupAccountIds: normalizedStrings(request.user?.groupAccountIds), + }, + } + : {}), }; } diff --git a/packages/backend/src/routes/knowledgeShares.ts b/packages/backend/src/routes/knowledgeShares.ts index a8f6c1db..dd3abaf5 100644 --- a/packages/backend/src/routes/knowledgeShares.ts +++ b/packages/backend/src/routes/knowledgeShares.ts @@ -448,6 +448,10 @@ const publicCardSchema = { }, } as const; +// Reused by the thread-promotion preview. The promotion route adds only its +// immutable share version discriminator and does not expose source identities. +export const knowledgeSharePublicCardSchema = publicCardSchema; + const previewResponseSchema = { type: 'object', additionalProperties: false, @@ -715,31 +719,19 @@ function sendResult( .send(mapper(result.value)); } -function normalizedStrings(value: unknown) { - return [ - ...new Set( - (Array.isArray(value) ? value : []) - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ]; -} - export function knowledgeShareChatActorFromRequest( request: FastifyRequest, ): KnowledgeShareChatActor { - const knowledgeActor = knowledgeActorFromRequest(request); + const knowledgeActor = knowledgeActorFromRequest(request, { + includeChat: true, + }); return { canonicalUserId: knowledgeActor.userId, - userId: - typeof request.user?.userId === 'string' - ? request.user.userId.trim() - : '', - roles: normalizedStrings(request.user?.roles), - projectIds: normalizedStrings(request.user?.projectIds), - groupIds: normalizedStrings(request.user?.groupIds), - groupAccountIds: normalizedStrings(request.user?.groupAccountIds), + userId: knowledgeActor.chat?.userId ?? '', + roles: knowledgeActor.chat?.roles ?? [], + projectIds: knowledgeActor.chat?.projectIds ?? [], + groupIds: knowledgeActor.chat?.groupIds ?? [], + groupAccountIds: knowledgeActor.chat?.groupAccountIds ?? [], }; } diff --git a/packages/backend/src/routes/knowledgeSyntheses.ts b/packages/backend/src/routes/knowledgeSyntheses.ts index aaea28b6..4d50378d 100644 --- a/packages/backend/src/routes/knowledgeSyntheses.ts +++ b/packages/backend/src/routes/knowledgeSyntheses.ts @@ -7,6 +7,7 @@ import { } from '../application/knowledge/knowledgeProvenanceCursor.js'; import { knowledgeProvenanceLimits, + knowledgeSynthesisInputSourceKinds, knowledgeSynthesisSourceKinds, knowledgeSynthesisSourceRelationTypes, type KnowledgePage, @@ -199,7 +200,7 @@ const sourceInputSchema = { additionalProperties: false, required: ['kind', 'sourceId', 'relationType'], properties: { - kind: { type: 'string', enum: knowledgeSynthesisSourceKinds }, + kind: { type: 'string', enum: knowledgeSynthesisInputSourceKinds }, sourceId: { type: 'string', minLength: 1, @@ -303,7 +304,7 @@ export async function registerKnowledgeSynthesisRoutes( }, }, async (request, reply) => { - const actor = knowledgeActorFromRequest(request); + const actor = knowledgeActorFromRequest(request, { includeChat: true }); const query = request.query as { limit?: number; cursor?: string }; let boundary; try { @@ -389,7 +390,7 @@ export async function registerKnowledgeSynthesisRoutes( }, async (request, reply) => { const result = await service.create({ - actor: knowledgeActorFromRequest(request), + actor: knowledgeActorFromRequest(request, { includeChat: true }), auditActor: knowledgeAuditActorFromRequest(request), body: request.body as never, }); @@ -420,7 +421,7 @@ export async function registerKnowledgeSynthesisRoutes( }, async (request, reply) => { const result = await service.detail({ - actor: knowledgeActorFromRequest(request), + actor: knowledgeActorFromRequest(request, { includeChat: true }), synthesisId: (request.params as { synthesisId: string }).synthesisId, }); return sendKnowledgeProvenanceResult( @@ -457,7 +458,7 @@ export async function registerKnowledgeSynthesisRoutes( }, }, async (request, reply) => { - const actor = knowledgeActorFromRequest(request); + const actor = knowledgeActorFromRequest(request, { includeChat: true }); const synthesisId = (request.params as { synthesisId: string }) .synthesisId; const query = request.query as { limit?: number; cursor?: string }; @@ -543,7 +544,7 @@ export async function registerKnowledgeSynthesisRoutes( }, async (request, reply) => { const result = await service.appendVersion({ - actor: knowledgeActorFromRequest(request), + actor: knowledgeActorFromRequest(request, { includeChat: true }), auditActor: knowledgeAuditActorFromRequest(request), synthesisId: (request.params as { synthesisId: string }).synthesisId, body: request.body as never, diff --git a/packages/backend/src/routes/knowledgeThreadPromotions.ts b/packages/backend/src/routes/knowledgeThreadPromotions.ts new file mode 100644 index 00000000..ba4387d3 --- /dev/null +++ b/packages/backend/src/routes/knowledgeThreadPromotions.ts @@ -0,0 +1,494 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { + createKnowledgeThreadPromotionUseCases, + type KnowledgeThreadPromotionUseCases, +} from '../application/knowledge/knowledgeThreadPromotionUseCases.js'; +import { + knowledgeThreadPromotionAuthorCategories, + knowledgeThreadPromotionLimits, +} from '../application/knowledge/knowledgeThreadPromotionPorts.js'; +import { prismaKnowledgeThreadPromotionAdapter } from '../adapters/knowledge/prismaKnowledgeThreadPromotionAdapter.js'; +import { createApiErrorResponse } from '../services/errors.js'; +import { requireRole } from '../services/rbac.js'; +import { + knowledgeActorFromRequest, + knowledgeAuditActorFromRequest, + requireCanonicalKnowledgeActor, +} from './knowledgeRouteContext.js'; +import { knowledgeProvenanceErrorResponseSchema } from './knowledgeProvenanceSchemas.js'; +import { knowledgeSharePublicCardSchema } from './knowledgeShares.js'; +import { knowledgeShareCardResponse } from './knowledgeShares.js'; + +const allowedRoles = ['admin', 'mgmt', 'exec', 'user'] as const; + +type RouteResult = + | { ok: true; value: T } + | { + ok: false; + statusCode: number; + code: string; + message: string; + }; + +type PromotionPreviewValue = { + sourceThread: { roomName: string; roomType: string; replyCount: number }; + selectedMessages: Array<{ + ordinal: number; + content: string; + createdAt: string; + authorCategory: string; + }>; + selectedMessageCount: number; + omittedMessageCount: number; + sharedCard: + | ({ shareVersion: number } & Parameters< + typeof knowledgeShareCardResponse + >[0]) + | null; + destination: { + scope: 'personal' | 'organization'; + organizationGroupCount: number; + }; + synthesis: { + title: string; + content: string; + confidenceBasisPoints: number | null; + unresolvedQuestions: string[]; + }; + previewToken: string; + expiresAt: string; + requiresConfirmation: true; + requiresOrganizationAudienceConfirmation: boolean; +}; + +type PromotionCommitValue = { + promotionId: string; + synthesisId: string; + synthesisVersionId: string; + synthesisVersion: 1; + scope: 'personal' | 'organization'; + selectedMessageCount: number; + includesSharedCard: boolean; + createdAt: string; + created: boolean; + reused: boolean; +}; + +const idSchema = { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.id, +} as const; + +const paramsSchema = { + type: 'object', + additionalProperties: false, + required: ['rootMessageId'], + properties: { rootMessageId: idSchema }, +} as const; + +const destinationSchema = { + type: 'object', + additionalProperties: false, + required: ['scope', 'organizationGroupAccountIds'], + properties: { + scope: { type: 'string', enum: ['personal', 'organization'] }, + organizationGroupAccountIds: { + type: 'array', + uniqueItems: true, + maxItems: knowledgeThreadPromotionLimits.organizationGroupAccountIds, + items: idSchema, + }, + }, +} as const; + +const synthesisSchema = { + type: 'object', + additionalProperties: false, + required: [ + 'title', + 'content', + 'confidenceBasisPoints', + 'unresolvedQuestions', + ], + properties: { + title: { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.titleCodePoints, + }, + content: { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.synthesisContentBytes, + description: 'Maximum 262,144 UTF-8 bytes; byte-bound in application.', + }, + confidenceBasisPoints: { + anyOf: [ + { type: 'integer', minimum: 0, maximum: 10_000 }, + { type: 'null' }, + ], + }, + unresolvedQuestions: { + type: 'array', + maxItems: knowledgeThreadPromotionLimits.unresolvedQuestions, + items: { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.unresolvedQuestionCodePoints, + }, + }, + }, +} as const; + +const requestProperties = { + selectedReplyMessageIds: { + type: 'array', + minItems: 1, + maxItems: knowledgeThreadPromotionLimits.selectedReplies, + uniqueItems: true, + items: idSchema, + }, + includeSharedCard: { type: 'boolean' }, + destination: destinationSchema, + synthesis: synthesisSchema, +} as const; + +const previewBodySchema = { + type: 'object', + additionalProperties: false, + required: Object.keys(requestProperties), + properties: requestProperties, +} as const; + +const commitBodySchema = { + type: 'object', + additionalProperties: false, + required: [ + ...Object.keys(requestProperties), + 'previewToken', + 'requestKey', + 'confirmed', + 'organizationAudienceConfirmed', + ], + properties: { + ...requestProperties, + previewToken: { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.previewTokenBytes, + }, + requestKey: { + type: 'string', + minLength: 1, + maxLength: knowledgeThreadPromotionLimits.requestKey, + }, + confirmed: { const: true }, + organizationAudienceConfirmed: { type: 'boolean' }, + }, +} as const; + +const promotionShareCardSchema = { + ...knowledgeSharePublicCardSchema, + required: [...knowledgeSharePublicCardSchema.required, 'shareVersion'], + properties: { + ...knowledgeSharePublicCardSchema.properties, + shareVersion: { type: 'integer', minimum: 1 }, + }, +} as const; + +const previewResponseSchema = { + type: 'object', + additionalProperties: false, + required: [ + 'sourceThread', + 'selectedMessages', + 'selectedMessageCount', + 'omittedMessageCount', + 'sharedCard', + 'destination', + 'synthesis', + 'previewToken', + 'expiresAt', + 'requiresConfirmation', + 'requiresOrganizationAudienceConfirmation', + ], + properties: { + sourceThread: { + type: 'object', + additionalProperties: false, + required: ['roomName', 'roomType', 'replyCount'], + properties: { + roomName: { type: 'string' }, + roomType: { type: 'string' }, + replyCount: { type: 'integer', minimum: 0 }, + }, + }, + selectedMessages: { + type: 'array', + minItems: 1, + maxItems: knowledgeThreadPromotionLimits.selectedReplies, + items: { + type: 'object', + additionalProperties: false, + required: ['ordinal', 'content', 'createdAt', 'authorCategory'], + properties: { + ordinal: { type: 'integer', minimum: 0 }, + content: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + authorCategory: { + type: 'string', + enum: knowledgeThreadPromotionAuthorCategories, + }, + }, + }, + }, + selectedMessageCount: { type: 'integer', minimum: 1, maximum: 100 }, + omittedMessageCount: { type: 'integer', minimum: 0 }, + sharedCard: { + anyOf: [promotionShareCardSchema, { type: 'null' }], + }, + destination: { + type: 'object', + additionalProperties: false, + required: ['scope', 'organizationGroupCount'], + properties: { + scope: { type: 'string', enum: ['personal', 'organization'] }, + organizationGroupCount: { + type: 'integer', + minimum: 0, + maximum: knowledgeThreadPromotionLimits.organizationGroupAccountIds, + }, + }, + }, + synthesis: synthesisSchema, + previewToken: { type: 'string' }, + expiresAt: { type: 'string', format: 'date-time' }, + requiresConfirmation: { const: true }, + requiresOrganizationAudienceConfirmation: { type: 'boolean' }, + }, +} as const; + +const commitResponseSchema = { + type: 'object', + additionalProperties: false, + required: [ + 'promotionId', + 'synthesisId', + 'synthesisVersionId', + 'synthesisVersion', + 'scope', + 'selectedMessageCount', + 'includesSharedCard', + 'createdAt', + 'created', + 'reused', + ], + properties: { + promotionId: { type: 'string' }, + synthesisId: { type: 'string' }, + synthesisVersionId: { type: 'string' }, + synthesisVersion: { type: 'integer', enum: [1] }, + scope: { type: 'string', enum: ['personal', 'organization'] }, + selectedMessageCount: { type: 'integer', minimum: 1, maximum: 100 }, + includesSharedCard: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + created: { type: 'boolean' }, + reused: { type: 'boolean' }, + }, +} as const; + +function rejectUnsupportedFields( + allowedFields: readonly string[], +): (request: FastifyRequest, reply: FastifyReply) => Promise { + const allowed = new Set(allowedFields); + return async (request, reply) => { + const body = request.body; + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + Object.keys(body).some((key) => !allowed.has(key)) + ) { + return reply.code(400).send( + createApiErrorResponse('invalid_request', 'Invalid request', { + category: 'validation', + }), + ); + } + }; +} + +function sendResult( + reply: FastifyReply, + result: RouteResult, + mapper: (value: T) => unknown, + createdStatus = false, +) { + if (!result.ok) { + return reply.code(result.statusCode).send( + createApiErrorResponse(result.code, result.message, { + category: + result.statusCode === 404 + ? 'not_found' + : result.statusCode === 409 + ? 'conflict' + : 'validation', + }), + ); + } + const status = + createdStatus && (result.value as { created?: boolean }).created === true + ? 201 + : 200; + return reply.code(status).send(mapper(result.value)); +} + +export function knowledgeThreadPromotionPreviewResponse( + value: PromotionPreviewValue, +) { + return { + sourceThread: { + roomName: value.sourceThread.roomName, + roomType: value.sourceThread.roomType, + replyCount: value.sourceThread.replyCount, + }, + selectedMessages: value.selectedMessages.map((message) => ({ + ordinal: message.ordinal, + content: message.content, + createdAt: message.createdAt, + authorCategory: message.authorCategory, + })), + selectedMessageCount: value.selectedMessageCount, + omittedMessageCount: value.omittedMessageCount, + sharedCard: + value.sharedCard === null + ? null + : { + ...knowledgeShareCardResponse(value.sharedCard), + shareVersion: value.sharedCard.shareVersion, + }, + destination: { + scope: value.destination.scope, + organizationGroupCount: value.destination.organizationGroupCount, + }, + synthesis: { + title: value.synthesis.title, + content: value.synthesis.content, + confidenceBasisPoints: value.synthesis.confidenceBasisPoints, + unresolvedQuestions: [...value.synthesis.unresolvedQuestions], + }, + previewToken: value.previewToken, + expiresAt: value.expiresAt, + requiresConfirmation: value.requiresConfirmation === true, + requiresOrganizationAudienceConfirmation: + value.requiresOrganizationAudienceConfirmation === true, + }; +} + +export function knowledgeThreadPromotionCommitResponse( + value: PromotionCommitValue, +) { + return { + promotionId: value.promotionId, + synthesisId: value.synthesisId, + synthesisVersionId: value.synthesisVersionId, + synthesisVersion: 1 as const, + scope: value.scope, + selectedMessageCount: value.selectedMessageCount, + includesSharedCard: value.includesSharedCard, + createdAt: value.createdAt, + created: value.created === true, + reused: value.reused === true, + }; +} + +export async function registerKnowledgeThreadPromotionRoutes( + app: FastifyInstance, + dependencies: { service?: KnowledgeThreadPromotionUseCases } = {}, +) { + const service = + dependencies.service ?? + createKnowledgeThreadPromotionUseCases({ + store: prismaKnowledgeThreadPromotionAdapter, + }); + const preHandler = [ + requireCanonicalKnowledgeActor, + requireRole(allowedRoles), + ]; + + app.post( + '/chat-messages/:rootMessageId/promote-to-knowledge/preview', + { + preHandler, + preValidation: rejectUnsupportedFields(Object.keys(requestProperties)), + schema: { + tags: ['chat', 'knowledge'], + params: paramsSchema, + body: previewBodySchema, + response: { + 200: previewResponseSchema, + 400: knowledgeProvenanceErrorResponseSchema, + 401: knowledgeProvenanceErrorResponseSchema, + 403: knowledgeProvenanceErrorResponseSchema, + 404: knowledgeProvenanceErrorResponseSchema, + 409: knowledgeProvenanceErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const result = (await service.preview({ + actor: knowledgeActorFromRequest(request, { includeChat: true }), + auditActor: knowledgeAuditActorFromRequest(request), + rootMessageId: (request.params as { rootMessageId: string }) + .rootMessageId, + body: request.body, + })) as RouteResult; + return sendResult(reply, result, knowledgeThreadPromotionPreviewResponse); + }, + ); + + app.post( + '/chat-messages/:rootMessageId/promote-to-knowledge', + { + preHandler, + preValidation: rejectUnsupportedFields([ + ...Object.keys(requestProperties), + 'previewToken', + 'requestKey', + 'confirmed', + 'organizationAudienceConfirmed', + ]), + schema: { + tags: ['chat', 'knowledge'], + params: paramsSchema, + body: commitBodySchema, + response: { + 200: commitResponseSchema, + 201: commitResponseSchema, + 400: knowledgeProvenanceErrorResponseSchema, + 401: knowledgeProvenanceErrorResponseSchema, + 403: knowledgeProvenanceErrorResponseSchema, + 404: knowledgeProvenanceErrorResponseSchema, + 409: knowledgeProvenanceErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const result = (await service.commit({ + actor: knowledgeActorFromRequest(request, { includeChat: true }), + auditActor: knowledgeAuditActorFromRequest(request), + rootMessageId: (request.params as { rootMessageId: string }) + .rootMessageId, + body: request.body, + })) as RouteResult; + return sendResult( + reply, + result, + knowledgeThreadPromotionCommitResponse, + true, + ); + }, + ); +} diff --git a/packages/backend/test/knowledgeProvenanceUseCases.test.js b/packages/backend/test/knowledgeProvenanceUseCases.test.js index 26064ad5..171127a3 100644 --- a/packages/backend/test/knowledgeProvenanceUseCases.test.js +++ b/packages/backend/test/knowledgeProvenanceUseCases.test.js @@ -915,6 +915,24 @@ test('synthesis source input is strict and concurrent version append returns con }); assert.equal(unknown.statusCode, 400); + const internalPromotionSource = await service.create({ + actor, + auditActor, + body: { + scope: 'personal', + title: 'Internal source cannot be selected', + content: 'Conclusion', + sources: [ + { + kind: 'thread_promotion', + sourceId: 'promotion-1', + relationType: 'primary', + }, + ], + }, + }); + assert.equal(internalPromotionSource.statusCode, 400); + const nullQuestionsCreate = await service.create({ actor, auditActor, diff --git a/packages/backend/test/knowledgeThreadPromotionAuditAdapter.test.js b/packages/backend/test/knowledgeThreadPromotionAuditAdapter.test.js new file mode 100644 index 00000000..196c0285 --- /dev/null +++ b/packages/backend/test/knowledgeThreadPromotionAuditAdapter.test.js @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PrismaKnowledgeThreadPromotionAuditWriter } from '../dist/adapters/knowledge/prismaKnowledgeThreadPromotionAuditAdapter.js'; + +function actor(overrides = {}) { + return { + userId: 'actor-1', + principalUserId: 'principal-1', + actorUserId: 'actor-1', + requestId: 'request-1', + source: 'api', + ...overrides, + }; +} + +function metadata(overrides = {}) { + return { + schemaVersion: 1, + resultCode: 'created', + scope: 'personal', + selectedMessageCount: 2, + includesSharedCard: false, + organizationGrantCount: 0, + duplicate: false, + ...overrides, + }; +} + +test('promotion audit persists only bounded content-free metadata', async () => { + let persisted; + const writer = new PrismaKnowledgeThreadPromotionAuditWriter({ + auditLog: { + create: async (input) => { + persisted = input; + return { id: 'audit-1' }; + }, + }, + }); + + await writer.write({ + action: 'knowledge_thread_promoted', + actor: actor({ + authScopes: ['knowledge:write'], + authTokenId: 'opaque-token-id', + authAudience: ['erp4'], + content: 'private-selected-message-canary', + }), + targetTable: 'knowledge_thread_promotions', + targetId: 'promotion-1', + metadata: metadata({ + body: 'private-selected-message-canary', + roomId: 'private-room-canary', + messageIds: ['private-message-canary'], + requestKey: 'private-request-key-canary', + }), + }); + + assert.deepEqual(persisted.data.metadata, { + schemaVersion: 1, + resultCode: 'created', + scope: 'personal', + selectedMessageCount: 2, + includesSharedCard: false, + organizationGrantCount: 0, + duplicate: false, + _auth: { + principalUserId: 'principal-1', + actorUserId: 'actor-1', + scopes: ['knowledge:write'], + tokenId: 'opaque-token-id', + audience: ['erp4'], + }, + _request: { id: 'request-1', source: 'api' }, + }); + assert.equal( + JSON.stringify(persisted).includes('private-selected-message-canary'), + false, + ); + assert.equal( + JSON.stringify(persisted).includes('private-room-canary'), + false, + ); + assert.equal( + JSON.stringify(persisted).includes('private-request-key-canary'), + false, + ); +}); + +test('promotion audit rejects invalid targets, actor context and unbounded counts', async () => { + const writer = new PrismaKnowledgeThreadPromotionAuditWriter({ + auditLog: { create: async (input) => input }, + }); + await assert.rejects( + () => + writer.write({ + action: 'knowledge_thread_promoted', + actor: actor(), + targetTable: 'knowledge_syntheses', + targetId: 'promotion-1', + metadata: metadata(), + }), + /knowledge_thread_promotion_audit_contract_invalid/, + ); + await assert.rejects( + () => + writer.write({ + action: 'knowledge_thread_promoted', + actor: actor({ principalUserId: undefined }), + targetTable: 'knowledge_thread_promotions', + targetId: 'promotion-1', + metadata: metadata(), + }), + /knowledge_thread_promotion_audit_contract_invalid/, + ); + await assert.rejects( + () => + writer.write({ + action: 'knowledge_thread_promoted', + actor: actor(), + targetTable: 'knowledge_thread_promotions', + targetId: 'promotion-1', + metadata: metadata({ organizationGrantCount: 21 }), + }), + /knowledge_thread_promotion_audit_contract_invalid/, + ); +}); diff --git a/packages/backend/test/knowledgeThreadPromotionRoutes.test.js b/packages/backend/test/knowledgeThreadPromotionRoutes.test.js new file mode 100644 index 00000000..849291e5 --- /dev/null +++ b/packages/backend/test/knowledgeThreadPromotionRoutes.test.js @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import test from 'node:test'; + +import { + knowledgeThreadPromotionCommitResponse, + knowledgeThreadPromotionPreviewResponse, + registerKnowledgeThreadPromotionRoutes, +} from '../dist/routes/knowledgeThreadPromotions.js'; +import { mapErrorToResponse } from '../dist/services/errors.js'; + +const timestamp = '2026-08-10T01:00:00.000Z'; + +function requestUser(overrides = {}) { + return { + userId: 'chat-user-1', + roles: ['user'], + orgId: 'org-1', + projectIds: ['project-1'], + groupIds: ['Knowledge'], + groupAccountIds: ['group-1'], + auth: { + providerType: 'header', + principalUserId: 'principal-1', + actorUserId: 'chat-user-1', + scopes: ['knowledge:write'], + tokenId: 'token-1', + audience: ['erp4-agent'], + expiresAt: 1_900_000_000, + }, + ...overrides, + }; +} + +function requestBody(overrides = {}) { + return { + selectedReplyMessageIds: ['reply-private-1'], + includeSharedCard: false, + destination: { + scope: 'personal', + organizationGroupAccountIds: [], + }, + synthesis: { + title: 'Synthetic synthesis', + content: 'Only selected reply content.', + confidenceBasisPoints: 7500, + unresolvedQuestions: ['Synthetic question'], + }, + ...overrides, + }; +} + +function previewValue(overrides = {}) { + return { + sourceThread: { + roomName: 'Synthetic room', + roomType: 'private_group', + replyCount: 2, + }, + selectedMessages: [ + { + ordinal: 0, + content: 'Selected reply', + createdAt: timestamp, + authorCategory: 'user', + sourceMessageId: 'must-not-leak', + }, + ], + selectedMessageCount: 1, + omittedMessageCount: 1, + sharedCard: null, + destination: { scope: 'personal', organizationGroupCount: 0 }, + synthesis: requestBody().synthesis, + previewToken: 'opaque.preview.token', + expiresAt: '2026-08-10T01:10:00.000Z', + requiresConfirmation: true, + requiresOrganizationAudienceConfirmation: false, + sourceRoomId: 'must-not-leak', + requestKey: 'must-not-leak', + ...overrides, + }; +} + +function sharedCardValue() { + return { + schemaVersion: 1, + shareVersion: 2, + title: 'Selected card title', + labels: [], + annotations: [], + turns: [], + syntheses: [], + selectedCategories: ['title'], + omittedCategories: [ + 'source_type', + 'canonical_url', + 'snapshot_provenance', + 'snapshot_excerpt', + 'label', + 'annotation', + 'conversation_turn', + 'synthesis', + 'sharer_note', + ], + }; +} + +function commitValue(overrides = {}) { + return { + promotionId: 'promotion-safe-id', + synthesisId: 'synthesis-safe-id', + synthesisVersionId: 'version-safe-id', + synthesisVersion: 1, + scope: 'personal', + selectedMessageCount: 1, + includesSharedCard: false, + createdAt: timestamp, + created: true, + reused: false, + sourceRoomId: 'must-not-leak', + requestKeyHash: 'a'.repeat(64), + ...overrides, + }; +} + +function service(overrides = {}) { + return { + preview: async () => ({ ok: true, value: previewValue() }), + commit: async () => ({ ok: true, value: commitValue() }), + ...overrides, + }; +} + +async function build(routeService, user = requestUser()) { + const app = Fastify(); + app.setErrorHandler((error, _request, reply) => { + const mapped = mapErrorToResponse(error, { env: 'test' }); + return reply.status(mapped.statusCode).send(mapped.body); + }); + app.addHook('onRequest', async (request) => { + request.user = user; + }); + await registerKnowledgeThreadPromotionRoutes(app, { + service: routeService, + }); + await app.ready(); + return app; +} + +test('preview passes canonical Knowledge and Chat claims and returns only allowlisted fields', async (t) => { + let call; + const app = await build( + service({ + preview: async (input) => { + call = input; + return { ok: true, value: previewValue() }; + }, + }), + ); + t.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/chat-messages/root-1/promote-to-knowledge/preview', + payload: requestBody(), + }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(call.actor.userId, 'chat-user-1'); + assert.deepEqual(call.actor.chat, { + userId: 'chat-user-1', + roles: ['user'], + projectIds: ['project-1'], + groupIds: ['Knowledge'], + groupAccountIds: ['group-1'], + }); + assert.equal(call.rootMessageId, 'root-1'); + const text = JSON.stringify(response.json()); + assert.equal(text.includes('must-not-leak'), false); + assert.equal(text.includes('sourceMessageId'), false); + assert.equal(text.includes('sourceRoomId'), false); + assert.equal(text.includes('requestKey'), false); + assert.equal(response.json().selectedMessages[0].content, 'Selected reply'); +}); + +test('commit requires exact confirmed body and maps created/reused response', async (t) => { + let call; + const app = await build( + service({ + commit: async (input) => { + call = input; + return { ok: true, value: commitValue() }; + }, + }), + ); + t.after(() => app.close()); + const response = await app.inject({ + method: 'POST', + url: '/chat-messages/root-1/promote-to-knowledge', + payload: { + ...requestBody(), + previewToken: 'opaque.preview.token', + requestKey: 'opaque-request-key', + confirmed: true, + organizationAudienceConfirmed: false, + }, + }); + assert.equal(response.statusCode, 201, response.body); + assert.equal(call.rootMessageId, 'root-1'); + assert.deepEqual(response.json(), knowledgeThreadPromotionCommitResponse(commitValue())); + assert.equal(JSON.stringify(response.json()).includes('must-not-leak'), false); +}); + +test('route rejects unknown fields and normalizes service not-found', async (t) => { + const app = await build( + service({ + preview: async () => ({ + ok: false, + statusCode: 404, + code: 'not_found', + message: 'Not found', + }), + }), + ); + t.after(() => app.close()); + const unknown = await app.inject({ + method: 'POST', + url: '/chat-messages/root-1/promote-to-knowledge/preview', + payload: { ...requestBody(), providerKey: 'private' }, + }); + assert.equal(unknown.statusCode, 400, unknown.body); + assert.equal(unknown.body.includes('private'), false); + const missing = await app.inject({ + method: 'POST', + url: '/chat-messages/root-1/promote-to-knowledge/preview', + payload: requestBody(), + }); + assert.equal(missing.statusCode, 404, missing.body); + assert.equal(missing.json().error.code, 'not_found'); +}); + +test('preview response mapper strips internal source and provider fields', () => { + const response = knowledgeThreadPromotionPreviewResponse(previewValue()); + const text = JSON.stringify(response); + assert.equal(text.includes('must-not-leak'), false); + assert.equal(text.includes('provider'), false); + assert.equal(response.omittedMessageCount, 1); +}); + +test('preview maps an included share card with the complete selection summary', async (t) => { + const app = await build( + service({ + preview: async () => ({ + ok: true, + value: previewValue({ sharedCard: sharedCardValue() }), + }), + }), + ); + t.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/chat-messages/root-1/promote-to-knowledge/preview', + payload: requestBody({ includeSharedCard: true }), + }); + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual(response.json().sharedCard.omittedCategories, [ + 'source_type', + 'canonical_url', + 'snapshot_provenance', + 'snapshot_excerpt', + 'label', + 'annotation', + 'conversation_turn', + 'synthesis', + 'sharer_note', + ]); +}); diff --git a/packages/backend/test/knowledgeThreadPromotionSchema.test.js b/packages/backend/test/knowledgeThreadPromotionSchema.test.js new file mode 100644 index 00000000..05cf9f5b --- /dev/null +++ b/packages/backend/test/knowledgeThreadPromotionSchema.test.js @@ -0,0 +1,287 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const schemaUrl = new URL('../prisma/schema.prisma', import.meta.url); +const migrationUrl = new URL( + '../prisma/migrations/20260810130000_add_knowledge_thread_promotion/migration.sql', + import.meta.url, +); +const integrationUrl = new URL( + '../scripts/knowledge-thread-promotion-schema-integration.mjs', + import.meta.url, +); +const integrationWrapperUrl = new URL( + '../../../scripts/test-knowledge-thread-promotion-schema-postgres.sh', + import.meta.url, +); + +const [schema, migration, integration, integrationWrapper] = await Promise.all([ + readFile(schemaUrl, 'utf8'), + readFile(migrationUrl, 'utf8'), + readFile(integrationUrl, 'utf8'), + readFile(integrationWrapperUrl, 'utf8'), +]); + +function schemaBlock(kind, name) { + const match = schema.match( + new RegExp(`\\b${kind} ${name}\\s*\\{([\\s\\S]*?)\\n\\}`), + ); + assert.ok(match, `${kind} ${name} must exist`); + return match[1]; +} + +test('thread promotion is an independent typed aggregate with selected-only snapshots', () => { + const promotion = schemaBlock('model', 'KnowledgeThreadPromotion'); + const selected = schemaBlock('model', 'KnowledgeThreadPromotionMessage'); + const authorCategory = schemaBlock( + 'enum', + 'KnowledgeThreadPromotionAuthorCategory', + ); + + for (const model of [ + 'KnowledgeThreadPromotion', + 'KnowledgeThreadPromotionMessage', + 'KnowledgeThreadPromotionRequest', + 'KnowledgeSynthesisGroupGrant', + ]) { + assert.match(schemaBlock('model', model), /id\s+String\s+@id/); + assert.match(migration, new RegExp(`CREATE TABLE "${model}"`)); + } + + assert.match(authorCategory, /\buser\b/); + assert.doesNotMatch(authorCategory, /\b(?:external|system|ai|tool)\b/); + + for (const field of [ + 'sourceShareId', + 'sourceShareVersion', + 'sourceShareContentHash', + 'sourceRoomId', + 'sourceRootMessageId', + 'promoterUserId', + 'ownerUserId', + 'destinationSynthesisId', + 'destinationSynthesisVersionId', + 'selectionHash', + 'contentHash', + 'includesSharedCard', + 'selectedMessageCount', + 'destinationGrantHash', + ]) { + assert.match(promotion, new RegExp(`\\b${field}\\s+`), field); + } + + for (const field of [ + 'sourceActivitySequence', + 'sourceMessageCreatedAt', + 'ordinal', + 'authorCategory', + 'content', + 'contentHash', + ]) { + assert.match(selected, new RegExp(`\\b${field}\\s+`), field); + } + assert.doesNotMatch(selected, /provider|metadata|requestKey/i); + assert.match(migration, /KnowledgeThreadPromotionMessage_snapshot_check/); + assert.match(migration, /"ordinal" BETWEEN 0 AND 99/); + assert.match(migration, /OCTET_LENGTH\("content"\) BETWEEN 1 AND 65536/); +}); + +test('shared-card inclusion is explicit and part of the canonical content-hash contract', () => { + const promotion = schemaBlock('model', 'KnowledgeThreadPromotion'); + assert.match(promotion, /includesSharedCard\s+Boolean\s+@default\(false\)/); + assert.match( + migration, + /"includesSharedCard" BOOLEAN NOT NULL DEFAULT false/, + ); + assert.match( + migration, + /KnowledgeThreadPromotion_content_binding_check[\s\S]*?"contentHash" ~ '\^\[0-9a-f\]\{64\}\$'[\s\S]*?"includesSharedCard"/, + ); + assert.match( + migration, + /COMMENT ON COLUMN "KnowledgeThreadPromotion"\."contentHash"[\s\S]*?including includesSharedCard, ordered selected reply hashes, destination scope and grant hash/, + ); +}); + +test('promotion source extends the existing exactly-one FK provenance contract', () => { + const source = schemaBlock('model', 'KnowledgeSynthesisSource'); + assert.match(source, /sourceThreadPromotion\s+KnowledgeThreadPromotion\?/); + assert.match(source, /sourceThreadPromotionId\s+String\?\s+@unique/); + assert.doesNotMatch(source, /\bsourceType\s+String/); + assert.doesNotMatch(source, /\bsourceId\s+String/); + + assert.match(migration, /ADD COLUMN "sourceThreadPromotionId" TEXT;/); + assert.match( + migration, + /KnowledgeSynthesisSource_sourceThreadPromotionId_fkey[\s\S]*?REFERENCES "KnowledgeThreadPromotion"\("id"\)[\s\S]*?ON DELETE RESTRICT ON UPDATE RESTRICT/, + ); + assert.match( + migration, + /KnowledgeSynthesisSource_exactly_one_promotion_check[\s\S]*?NUM_NONNULLS\([\s\S]*?"sourceThreadPromotionId"[\s\S]*?\) = 1/, + ); + assert.match( + migration, + /VALIDATE CONSTRAINT "KnowledgeSynthesisSource_exactly_one_promotion_check";[\s\S]*?DROP CONSTRAINT "KnowledgeSynthesisSource_exactly_one_check";[\s\S]*?RENAME CONSTRAINT "KnowledgeSynthesisSource_exactly_one_promotion_check"[\s\S]*?TO "KnowledgeSynthesisSource_exactly_one_check"/, + ); + assert.match( + migration, + /"relationType" <> 'primary'[\s\S]*?"ordinal" <> 0[\s\S]*?KnowledgeSynthesisSource_thread_promotion_check/, + ); + assert.match( + migration, + /"destinationSynthesisVersionId" = NEW\."synthesisVersionId"[\s\S]*?KnowledgeSynthesisSource_thread_promotion_exclusive_check/, + ); + assert.match( + migration, + /KnowledgeSynthesisSource_sourceThreadPromotionId_key/, + ); +}); + +test('deferred aggregate validation fixes the synthesis-v1 then promotion then source creation order', () => { + assert.match( + migration, + /KnowledgeThreadPromotion_destinationSynthesisId_ownerUserI_fkey[\s\S]*?REFERENCES "KnowledgeSynthesis"\("id", "ownerUserId"\)/, + ); + assert.match( + migration, + /KnowledgeThreadPromotion_destinationSynthesisVersionId_des_fkey[\s\S]*?REFERENCES "KnowledgeSynthesisVersion"\("id", "synthesisId", "version"\)/, + ); + assert.match( + migration, + /CREATE CONSTRAINT TRIGGER "KnowledgeThreadPromotion_complete_trigger"[\s\S]*?AFTER INSERT ON "KnowledgeThreadPromotion"[\s\S]*?DEFERRABLE INITIALLY DEFERRED/, + ); + assert.match( + migration, + /FROM "KnowledgeSynthesisSource"[\s\S]*?"sourceThreadPromotionId" = NEW\."id"[\s\S]*?"synthesisVersionId" = NEW\."destinationSynthesisVersionId"/, + ); + assert.match( + migration, + /destination_source_count <> 1[\s\S]*?request_count <> 1/, + ); + assert.match(migration, /"destinationSynthesisVersionNumber" = 1/); +}); + +test('selected reply insertion is exact-thread, exact-version, immutable and source-revoke safe', () => { + assert.match(migration, /KnowledgeThreadPromotion_exact_share_check/); + assert.match( + migration, + /share_row\."version" IS DISTINCT FROM NEW\."sourceShareVersion"[\s\S]*?share_row\."contentHash" IS DISTINCT FROM NEW\."sourceShareContentHash"/, + ); + assert.match( + migration, + /message_row\."parentMessageId" IS DISTINCT FROM NEW\."sourceRootMessageId"[\s\S]*?message_row\."threadRootId" IS DISTINCT FROM NEW\."sourceRootMessageId"/, + ); + assert.match( + migration, + /message_row\."activitySequence" IS DISTINCT FROM NEW\."sourceActivitySequence"[\s\S]*?message_row\."body" IS DISTINCT FROM NEW\."content"/, + ); + for (const table of [ + 'KnowledgeThreadPromotion', + 'KnowledgeThreadPromotionMessage', + 'KnowledgeThreadPromotionRequest', + ]) { + assert.match( + migration, + new RegExp( + `CREATE TRIGGER "${table}_immutable_trigger"[\\s\\S]*?BEFORE UPDATE OR DELETE ON "${table}"`, + ), + ); + } + assert.match( + migration, + /KnowledgeThreadPromotion_sourceShareId_fkey[\s\S]*?REFERENCES "KnowledgeShare"\("id"\)/, + ); + assert.doesNotMatch( + migration, + /FOREIGN KEY \("sourceShareId", "sourceShareVersion", "sourceShareContentHash"\)/, + ); +}); + +test('request ledger and synthesis grants preserve idempotency and explicit destination ACL', () => { + const request = schemaBlock('model', 'KnowledgeThreadPromotionRequest'); + const grant = schemaBlock('model', 'KnowledgeSynthesisGroupGrant'); + assert.match(request, /@@unique\(\[promoterUserId, requestKeyHash\]\)/); + assert.match(request, /promotionId\s+String\s+@unique/); + assert.match( + migration, + /KnowledgeThreadPromotionRequest_hash_check[\s\S]*?requestKeyHash" ~ '\^\[0-9a-f\]\{64\}\$'[\s\S]*?requestPayloadHash" ~ '\^\[0-9a-f\]\{64\}\$'/, + ); + assert.match( + migration, + /KnowledgeThreadPromotionRequest_promotionId_promoterUserId_fkey[\s\S]*?REFERENCES "KnowledgeThreadPromotion"\("id", "promoterUserId"\)/, + ); + + assert.match(grant, /groupAccountId\s+String/); + assert.match(grant, /revokedAt\s+DateTime\?/); + assert.match(grant, /@@unique\(\[synthesisId, groupAccountId\]\)/); + assert.match(migration, /KnowledgeThreadPromotion_scope_grants_check/); + assert.match( + migration, + /"scope" = 'personal'[\s\S]*?"destinationGrantCount" = 0[\s\S]*?"scope" = 'organization'[\s\S]*?"destinationGrantCount" BETWEEN 1 AND 20/, + ); + assert.match(migration, /KnowledgeSynthesisGroupGrant_scope_check/); + assert.match( + migration, + /knowledge synthesis grants must be revoked, not deleted/, + ); +}); + +test('migration is expand-only and preserves the old text-message application contract', () => { + assert.match(schema, /enum ChatMessageType\s*\{\s*text\s*\}/s); + assert.doesNotMatch(migration, /ALTER TYPE "ChatMessageType"/); + assert.doesNotMatch( + migration, + /\b(?:DROP TABLE|DROP COLUMN|TRUNCATE|DELETE\s+FROM|UPDATE\s+")\b/i, + ); + assert.doesNotMatch(migration, /ALTER TABLE "ChatMessage"\s+ADD COLUMN/); + assert.match( + migration, + /ALTER TABLE "KnowledgeSynthesisSource"\s+ADD COLUMN "sourceThreadPromotionId" TEXT;/, + ); + assert.match( + migration, + /root_row\."messageType" <> 'text'[\s\S]*?root_row\."body" IS DISTINCT FROM 'Knowledge was shared\.'/, + ); +}); + +test('promotion audit actions are constrained to a content-free aggregate target', () => { + for (const action of [ + 'knowledge_thread_promote_previewed', + 'knowledge_thread_promoted', + 'knowledge_thread_promote_duplicate_detected', + 'knowledge_thread_promote_rejected', + ]) { + assert.match(migration, new RegExp(`'${action}'`)); + } + assert.match( + migration, + /AuditLog_knowledge_thread_promotion_target_check[\s\S]*?"targetTable" = 'knowledge_thread_promotions'[\s\S]*?"targetId" IS NOT NULL[\s\S]*?\) NOT VALID;/, + ); +}); + +test('PostgreSQL integration fixes deferred completion, immutable snapshots and old-share revoke compatibility', () => { + assert.match(integrationWrapper, /postgres:15@sha256:/); + assert.match(integrationWrapper, /--tmpfs \/var\/lib\/postgresql\/data/); + assert.match(integrationWrapper, /trap cleanup EXIT INT TERM/); + assert.match(integrationWrapper, /prisma migrate deploy/); + assert.match( + integrationWrapper, + /KnowledgeThreadPromotion\|KnowledgeSynthesisGroupGrant\|sourceThreadPromotionId/, + ); + assert.match(integration, /"includesSharedCard",[\s\S]*?true, 1/); + assert.match( + integration, + /expectDatabaseError\(\(\) => client\.query\('COMMIT'\), '23514'\)/, + ); + assert.match( + integration, + /UPDATE "KnowledgeThreadPromotion" SET "includesSharedCard" = false/, + ); + assert.match( + integration, + /UPDATE "KnowledgeShare"[\s\S]*?"status" = 'revoked'[\s\S]*?"version" = 3/, + ); + assert.match(integration, /sourceShareVersion: 2/); + assert.match(integration, /currentShareVersion: 3/); +}); diff --git a/packages/backend/test/knowledgeThreadPromotionToken.test.js b/packages/backend/test/knowledgeThreadPromotionToken.test.js new file mode 100644 index 00000000..1fa26fb6 --- /dev/null +++ b/packages/backend/test/knowledgeThreadPromotionToken.test.js @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createKnowledgeThreadPromotionTokenCodec, + KnowledgeThreadPromotionTokenError, +} from '../dist/application/knowledge/knowledgeThreadPromotionToken.js'; + +const actor = { + userId: 'knowledge-user-synthetic', + organizationId: 'organization-synthetic', + groupAccountIds: ['group-synthetic'], + chat: { + userId: 'chat-user-synthetic', + roles: ['user'], + projectIds: [], + groupIds: [], + groupAccountIds: ['group-synthetic'], + }, +}; +const rootMessageId = 'chat-root-private-synthetic'; +const bindingHash = 'a'.repeat(64); +const promotionId = '11111111-2222-4333-8444-123456789012'; +const now = new Date('2026-08-10T06:00:00.000Z'); + +function createCodec(overrides = {}) { + return createKnowledgeThreadPromotionTokenCodec({ + env: { + NODE_ENV: 'test', + KNOWLEDGE_CURSOR_SIGNING_SECRET: + 'thread-promotion-preview-test-secret-000001', + }, + now: () => now, + randomId: () => promotionId, + ...overrides, + }); +} + +function created(codec = createCodec()) { + const reservedPromotionId = codec.reservePromotionId(); + return codec.create({ + actor, + rootMessageId, + bindingHash, + promotionId: reservedPromotionId, + }); +} + +function expectTokenError(code, work) { + assert.throws(work, (error) => { + assert.equal(error instanceof KnowledgeThreadPromotionTokenError, true); + assert.equal(error.code, code); + assert.equal(error.message, code); + return true; + }); +} + +test('promotion preview token verifies exact actor, root and payload binding', () => { + const codec = createCodec(); + const value = created(codec); + + assert.equal(value.promotionId, promotionId); + assert.equal(value.expiresAt.toISOString(), '2026-08-10T06:10:00.000Z'); + const verified = codec.verify({ + actor, + rootMessageId, + bindingHash, + token: value.token, + }); + assert.equal(verified.promotionId, promotionId); + assert.match(verified.payloadBinding, /^[a-f0-9]{64}$/); + assert.equal(verified.expiresAt.toISOString(), value.expiresAt.toISOString()); +}); + +test('promotion preview token contains only opaque actor, root and payload fingerprints', () => { + const { token } = created(); + const envelopeText = Buffer.from(token.split('.')[0], 'base64url').toString( + 'utf8', + ); + const envelope = JSON.parse(envelopeText); + + for (const privateValue of [actor.userId, rootMessageId, bindingHash]) { + assert.equal(token.includes(privateValue), false); + assert.equal(envelopeText.includes(privateValue), false); + } + assert.deepEqual(Object.keys(envelope).sort(), [ + 'actorFingerprint', + 'expiresAt', + 'issuedAt', + 'payloadBinding', + 'promotionId', + 'purpose', + 'rootFingerprint', + 'v', + ]); +}); + +test('promotion preview token rejects actor/root mismatch and identifies stale exact state', () => { + const codec = createCodec(); + const { token } = created(codec); + const valid = { actor, rootMessageId, bindingHash, token }; + + expectTokenError('preview_token_invalid', () => + codec.verify({ + ...valid, + actor: { ...actor, userId: 'another-knowledge-user' }, + }), + ); + expectTokenError('preview_token_invalid', () => + codec.verify({ ...valid, rootMessageId: 'another-root' }), + ); + expectTokenError('stale_preview', () => + codec.verify({ ...valid, bindingHash: 'b'.repeat(64) }), + ); +}); + +test('promotion preview token rejects tampering, malformed JSON and oversized input', () => { + const codec = createCodec(); + const { token } = created(codec); + const [payload, signature] = token.split('.'); + const replacement = signature.at(-1) === 'A' ? 'B' : 'A'; + const tampered = `${payload}.${signature.slice(0, -1)}${replacement}`; + const malformedEnvelope = Buffer.from('{', 'utf8').toString('base64url'); + + for (const invalidToken of [ + tampered, + `${token}.extra`, + 'not-base64url.*', + `${malformedEnvelope}.${signature}`, + '', + 'a'.repeat(4097), + null, + ]) { + expectTokenError('preview_token_invalid', () => + codec.verify({ + actor, + rootMessageId, + bindingHash, + token: invalidToken, + }), + ); + } +}); + +test('promotion preview token expires at ten minutes but remains readable for exact replay', () => { + let current = now; + const codec = createCodec({ now: () => current }); + const { token } = created(codec); + + current = new Date('2026-08-10T06:09:59.999Z'); + assert.equal( + codec.verify({ actor, rootMessageId, bindingHash, token }).promotionId, + promotionId, + ); + current = new Date('2026-08-10T06:10:00.000Z'); + expectTokenError('preview_token_expired', () => + codec.verify({ actor, rootMessageId, bindingHash, token }), + ); + assert.deepEqual(codec.readForReplay({ actor, rootMessageId, token }), { + promotionId, + payloadBinding: JSON.parse( + Buffer.from(token.split('.')[0], 'base64url').toString('utf8'), + ).payloadBinding, + expiresAt: new Date('2026-08-10T06:10:00.000Z'), + }); +}); + +test('promotion preview token has a dedicated configured-secret domain', () => { + const tokenA = created(createCodec()).token; + const codecB = createCodec({ + env: { + NODE_ENV: 'test', + KNOWLEDGE_CURSOR_SIGNING_SECRET: + 'thread-promotion-preview-test-secret-000002', + }, + }); + const tokenB = created(codecB).token; + + assert.notEqual(tokenA, tokenB); + expectTokenError('preview_token_invalid', () => + codecB.verify({ actor, rootMessageId, bindingHash, token: tokenA }), + ); +}); + +test('promotion token requires a production secret and rejects invalid creation contracts', () => { + for (const env of [ + { NODE_ENV: 'production' }, + { NODE_ENV: 'production', KNOWLEDGE_CURSOR_SIGNING_SECRET: 'short' }, + { NODE_ENV: 'test', KNOWLEDGE_CURSOR_SIGNING_SECRET: ' '.repeat(32) }, + ]) { + assert.throws( + () => createKnowledgeThreadPromotionTokenCodec({ env }), + /KNOWLEDGE_CURSOR_SIGNING_SECRET/, + ); + } + assert.throws( + () => created(createCodec({ randomId: () => 'not-a-uuid' })), + (error) => { + assert.equal( + error.message, + 'knowledge_thread_promotion_token_contract_invalid', + ); + assert.equal(error.message.includes(rootMessageId), false); + return true; + }, + ); +}); diff --git a/packages/backend/test/knowledgeThreadPromotionUseCases.test.js b/packages/backend/test/knowledgeThreadPromotionUseCases.test.js new file mode 100644 index 00000000..24f0d9b7 --- /dev/null +++ b/packages/backend/test/knowledgeThreadPromotionUseCases.test.js @@ -0,0 +1,615 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createKnowledgeThreadPromotionTokenCodec } from '../dist/application/knowledge/knowledgeThreadPromotionToken.js'; +import { + createKnowledgeThreadPromotionUseCases, + hashKnowledgeThreadPromotionRequestKey, +} from '../dist/application/knowledge/knowledgeThreadPromotionUseCases.js'; + +const actor = { + userId: 'knowledge-user-synthetic', + organizationId: 'organization-synthetic', + groupAccountIds: ['organization-group-a', 'organization-group-b'], + chat: { + userId: 'chat-user-synthetic', + roles: ['user'], + projectIds: ['project-synthetic'], + groupIds: ['chat-group-synthetic'], + groupAccountIds: ['organization-group-a', 'organization-group-b'], + }, +}; +const auditActor = { + requestId: 'request-synthetic', + source: 'agent', + principalUserId: actor.userId, + actorUserId: actor.userId, + authScopes: ['chat:read', 'knowledge:write'], +}; +const rootMessageId = 'chat-root-synthetic'; +const promotionId = '11111111-2222-4333-8444-123456789012'; +const bindingHash = 'a'.repeat(64); +const createdAt = new Date('2026-08-10T06:00:00.000Z'); + +function personalRequest(overrides = {}) { + return { + selectedReplyMessageIds: ['reply-selected-2', 'reply-selected-1'], + includeSharedCard: true, + destination: { + scope: 'personal', + organizationGroupAccountIds: [], + }, + synthesis: { + title: 'Explicit synthesis', + content: 'Only the explicitly selected replies are synthesized.', + confidenceBasisPoints: 7500, + unresolvedQuestions: ['What remains unresolved?'], + }, + ...overrides, + }; +} + +function selectedCard() { + return { + schemaVersion: 1, + shareVersion: 3, + title: 'Selected share title', + sourceType: 'manual', + snapshot: { + version: 2, + sha256: 'b'.repeat(64), + excerpt: 'Selected excerpt', + }, + sharerNote: 'Selected note', + labels: [{ displayName: 'Selected label', ordinal: 0 }], + annotations: [ + { + revision: 2, + kind: 'quote', + origin: 'user', + content: 'Selected annotation', + ordinal: 0, + }, + ], + turns: [ + { + role: 'assistant', + origin: 'ai', + content: 'Selected AI turn', + name: null, + occurredAt: new Date('2026-08-10T05:30:00.000Z'), + ordinal: 0, + }, + ], + syntheses: [ + { + version: 1, + title: 'Selected conclusion', + content: 'Selected synthesis content', + confidenceBasisPoints: 8000, + unresolvedQuestions: [], + ordinal: 0, + }, + ], + selectedCategories: [ + 'title', + 'source_type', + 'snapshot_provenance', + 'snapshot_excerpt', + 'label', + 'annotation', + 'conversation_turn', + 'synthesis', + 'sharer_note', + ], + omittedCategories: ['canonical_url'], + }; +} + +function resolved(request = personalRequest(), overrides = {}) { + return { + promotionId, + rootMessageId, + sourceRoomName: 'Synthetic room', + sourceRoomType: 'private_group', + sourceShareVersion: 3, + sourceShareContentHash: 'c'.repeat(64), + threadReplyCount: 4, + selectedMessages: [ + { + sourceMessageId: request.selectedReplyMessageIds[0], + sourceActivitySequence: 102n, + ordinal: 0, + content: 'Second reply selected first', + contentHash: 'd'.repeat(64), + createdAt: new Date('2026-08-10T05:40:00.000Z'), + authorCategory: 'user', + }, + { + sourceMessageId: request.selectedReplyMessageIds[1], + sourceActivitySequence: 101n, + ordinal: 1, + content: 'First reply selected second', + contentHash: 'e'.repeat(64), + createdAt: new Date('2026-08-10T05:35:00.000Z'), + authorCategory: 'user', + }, + ], + selectedShareCard: request.includeSharedCard ? selectedCard() : null, + destination: structuredClone(request.destination), + bindingHash, + ...overrides, + }; +} + +function commitRecord(overrides = {}) { + return { + promotionId, + synthesisId: 'synthesis-created', + synthesisVersionId: 'synthesis-version-created', + synthesisVersion: 1, + scope: 'personal', + selectedMessageCount: 2, + includesSharedCard: true, + createdAt, + created: true, + ...overrides, + }; +} + +function createHarness() { + const state = { + previewInputs: [], + resolveInputs: [], + replayInputs: [], + commitInputs: [], + requestLedger: new Map(), + previewResult: null, + resolveResult: null, + commitResult: null, + resolvedRequest: personalRequest(), + }; + const store = { + async preview(input) { + state.previewInputs.push(structuredClone(input)); + return ( + state.previewResult ?? { + ok: true, + value: resolved(input.request), + } + ); + }, + async resolveForCommit(input) { + state.resolveInputs.push(structuredClone(input)); + return ( + state.resolveResult ?? { + ok: true, + value: resolved(input.request), + } + ); + }, + async findIdempotent(input) { + state.replayInputs.push(structuredClone(input)); + const existing = state.requestLedger.get(input.requestKeyHash); + if (!existing) return { ok: true, value: null }; + if (existing.requestPayloadHash !== input.requestPayloadHash) { + return { + ok: false, + error: { + status: 409, + code: 'idempotency_conflict', + message: 'raw private conflict detail must not escape', + }, + }; + } + return { + ok: true, + value: { ...existing.record, created: false }, + }; + }, + async commit(input) { + state.commitInputs.push(structuredClone(input)); + if (state.commitResult) return state.commitResult; + const existing = state.requestLedger.get(input.requestKeyHash); + if (existing) { + if (existing.requestPayloadHash !== input.requestPayloadHash) { + return { + ok: false, + error: { + status: 409, + code: 'idempotency_conflict', + message: 'hidden conflict detail', + }, + }; + } + return { ok: true, value: { ...existing.record, created: false } }; + } + const record = commitRecord({ + promotionId: input.promotionId, + scope: input.request.destination.scope, + selectedMessageCount: input.request.selectedReplyMessageIds.length, + includesSharedCard: input.request.includeSharedCard, + }); + state.requestLedger.set(input.requestKeyHash, { + requestPayloadHash: input.requestPayloadHash, + record, + }); + return { ok: true, value: record }; + }, + }; + const tokenCodec = createKnowledgeThreadPromotionTokenCodec({ + env: { + NODE_ENV: 'test', + KNOWLEDGE_CURSOR_SIGNING_SECRET: + 'thread-promotion-usecase-test-secret-000001', + }, + now: () => createdAt, + randomId: () => promotionId, + }); + return { + state, + service: createKnowledgeThreadPromotionUseCases({ store, tokenCodec }), + }; +} + +async function preview(harness, request = personalRequest()) { + return harness.service.preview({ + actor, + auditActor, + rootMessageId, + body: request, + }); +} + +function commitBody( + previewResult, + request = personalRequest(), + overrides = {}, +) { + return { + ...request, + previewToken: previewResult.value.previewToken, + requestKey: 'request-key-synthetic', + confirmed: true, + organizationAudienceConfirmed: request.destination.scope === 'organization', + ...overrides, + }; +} + +test('preview preserves explicit reply order and returns only safe exact content', async () => { + const harness = createHarness(); + const result = await preview(harness); + + assert.equal(result.ok, true); + assert.deepEqual( + harness.state.previewInputs[0].request.selectedReplyMessageIds, + ['reply-selected-2', 'reply-selected-1'], + ); + assert.equal(harness.state.previewInputs[0].auditActor.userId, actor.userId); + assert.deepEqual(result.value.selectedMessages, [ + { + ordinal: 0, + content: 'Second reply selected first', + createdAt: '2026-08-10T05:40:00.000Z', + authorCategory: 'user', + }, + { + ordinal: 1, + content: 'First reply selected second', + createdAt: '2026-08-10T05:35:00.000Z', + authorCategory: 'user', + }, + ]); + assert.equal(result.value.selectedMessageCount, 2); + assert.equal(result.value.omittedMessageCount, 2); + assert.equal(result.value.sharedCard.title, 'Selected share title'); + assert.deepEqual(result.value.sharedCard.omittedCategories, [ + 'canonical_url', + ]); + assert.equal(result.value.requiresConfirmation, true); + assert.equal(result.value.requiresOrganizationAudienceConfirmation, false); + const serialized = JSON.stringify(result.value); + for (const hidden of [ + 'reply-selected-1', + 'reply-selected-2', + 'sourceShareContentHash', + 'sourceMessageId', + bindingHash, + ]) { + assert.equal(serialized.includes(hidden), false); + } +}); + +test('preview token contains no selected reply IDs, synthesis body or organization grant', async () => { + const harness = createHarness(); + const result = await preview(harness); + const decoded = Buffer.from( + result.value.previewToken.split('.')[0], + 'base64url', + ).toString('utf8'); + + for (const privateValue of [ + ...personalRequest().selectedReplyMessageIds, + personalRequest().synthesis.content, + actor.userId, + rootMessageId, + ]) { + assert.equal(decoded.includes(privateValue), false); + } +}); + +test('commit re-resolves exact state and creates one synthesis v1', async () => { + const harness = createHarness(); + const previewResult = await preview(harness); + const result = await harness.service.commit({ + actor, + auditActor, + rootMessageId, + body: commitBody(previewResult), + }); + + assert.deepEqual(result, { + ok: true, + value: { + promotionId, + synthesisId: 'synthesis-created', + synthesisVersionId: 'synthesis-version-created', + synthesisVersion: 1, + scope: 'personal', + selectedMessageCount: 2, + includesSharedCard: true, + createdAt: '2026-08-10T06:00:00.000Z', + created: true, + reused: false, + }, + }); + assert.equal(harness.state.resolveInputs.length, 1); + assert.equal(harness.state.commitInputs.length, 1); + const committed = harness.state.commitInputs[0]; + assert.equal(committed.expectedBindingHash, bindingHash); + assert.match(committed.requestKeyHash, /^[a-f0-9]{64}$/); + assert.match(committed.requestPayloadHash, /^[a-f0-9]{64}$/); + assert.equal( + committed.requestKeyHash.includes('request-key-synthetic'), + false, + ); + assert.equal(committed.auditActor.userId, actor.userId); +}); + +test('same key and payload reuses the existing promotion without another mutation', async () => { + const harness = createHarness(); + const previewResult = await preview(harness); + const input = { + actor, + auditActor, + rootMessageId, + body: commitBody(previewResult), + }; + + const first = await harness.service.commit(input); + const second = await harness.service.commit(input); + + assert.equal(first.ok, true); + assert.equal(second.ok, true); + assert.equal(second.value.created, false); + assert.equal(second.value.reused, true); + assert.equal(second.value.promotionId, first.value.promotionId); + assert.equal(harness.state.commitInputs.length, 1); + assert.equal(harness.state.resolveInputs.length, 1); +}); + +test('same request key with another payload returns a sanitized conflict', async () => { + const harness = createHarness(); + const previewResult = await preview(harness); + await harness.service.commit({ + actor, + auditActor, + rootMessageId, + body: commitBody(previewResult), + }); + const changedRequest = personalRequest({ + synthesis: { + ...personalRequest().synthesis, + title: 'Different exact synthesis', + }, + }); + const changedPreview = await preview(harness, changedRequest); + const conflict = await harness.service.commit({ + actor, + auditActor, + rootMessageId, + body: commitBody(changedPreview, changedRequest), + }); + + assert.deepEqual(conflict, { + ok: false, + statusCode: 409, + code: 'idempotency_conflict', + message: 'Idempotency conflict', + }); + assert.equal(JSON.stringify(conflict).includes('private conflict'), false); + assert.equal(harness.state.commitInputs.length, 1); +}); + +test('organization promotion requires explicit grants and additional confirmation', async () => { + const request = personalRequest({ + destination: { + scope: 'organization', + organizationGroupAccountIds: [ + 'organization-group-b', + 'organization-group-a', + ], + }, + }); + const harness = createHarness(); + const previewResult = await preview(harness, request); + + assert.equal(previewResult.ok, true); + assert.deepEqual( + harness.state.previewInputs[0].request.destination + .organizationGroupAccountIds, + ['organization-group-a', 'organization-group-b'], + ); + assert.equal( + previewResult.value.requiresOrganizationAudienceConfirmation, + true, + ); + const rejected = await harness.service.commit({ + actor, + auditActor, + rootMessageId, + body: commitBody(previewResult, request, { + organizationAudienceConfirmed: false, + }), + }); + assert.deepEqual(rejected, { + ok: false, + statusCode: 400, + code: 'organization_confirmation_required', + message: 'Organization audience confirmation is required', + }); + assert.equal(harness.state.commitInputs.length, 0); +}); + +test('strict request validation rejects unknown fields, duplicate replies and invalid scope data', async () => { + const invalidRequests = [ + { ...personalRequest(), unknown: true }, + personalRequest({ selectedReplyMessageIds: [] }), + personalRequest({ + selectedReplyMessageIds: ['reply-selected', 'reply-selected'], + }), + personalRequest({ + selectedReplyMessageIds: Array.from( + { length: 101 }, + (_, index) => `reply-${index}`, + ), + }), + personalRequest({ + destination: { + scope: 'personal', + organizationGroupAccountIds: ['must-be-empty'], + }, + }), + personalRequest({ + destination: { + scope: 'organization', + organizationGroupAccountIds: [], + }, + }), + personalRequest({ + synthesis: { + ...personalRequest().synthesis, + confidenceBasisPoints: 10001, + }, + }), + personalRequest({ + synthesis: { ...personalRequest().synthesis, unknown: 'private' }, + }), + ]; + + for (const body of invalidRequests) { + const result = await createHarness().service.preview({ + actor, + auditActor, + rootMessageId, + body, + }); + assert.deepEqual(result, { + ok: false, + statusCode: 400, + code: 'invalid_request', + message: 'Invalid request', + }); + } +}); + +test('organization grants must be an explicit subset of the canonical actor groups', async () => { + const result = await createHarness().service.preview({ + actor, + auditActor, + rootMessageId, + body: personalRequest({ + destination: { + scope: 'organization', + organizationGroupAccountIds: [ + 'organization-group-a', + 'organization-group-not-held', + ], + }, + }), + }); + + assert.deepEqual(result, { + ok: false, + statusCode: 400, + code: 'invalid_request', + message: 'Invalid request', + }); +}); + +test('missing Chat actor context and unauthorized port results fail closed', async () => { + const harness = createHarness(); + const noChat = await harness.service.preview({ + actor: { ...actor, chat: undefined }, + auditActor, + rootMessageId, + body: personalRequest(), + }); + assert.deepEqual(noChat, { + ok: false, + statusCode: 404, + code: 'not_found', + message: 'Not found', + }); + + harness.state.previewResult = { + ok: false, + error: { status: 404, code: 'not_found', message: 'raw root detail' }, + }; + const hidden = await preview(harness); + assert.deepEqual(hidden, { + ok: false, + statusCode: 404, + code: 'not_found', + message: 'Not found', + }); + assert.equal(JSON.stringify(hidden).includes('raw root'), false); +}); + +test('changed exact reply/share state is reported as stale preview before mutation', async () => { + const harness = createHarness(); + const previewResult = await preview(harness); + harness.state.resolveResult = { + ok: true, + value: resolved(personalRequest(), { bindingHash: 'f'.repeat(64) }), + }; + const result = await harness.service.commit({ + actor, + auditActor, + rootMessageId, + body: commitBody(previewResult), + }); + + assert.deepEqual(result, { + ok: false, + statusCode: 409, + code: 'stale_preview', + message: 'Preview is stale', + }); + assert.equal(harness.state.commitInputs.length, 0); +}); + +test('request-key hashing is actor scoped, deterministic and opaque', () => { + const requestKey = 'opaque-request-key-synthetic'; + const hash = hashKnowledgeThreadPromotionRequestKey(actor, requestKey); + assert.match(hash, /^[a-f0-9]{64}$/); + assert.equal(hash, hashKnowledgeThreadPromotionRequestKey(actor, requestKey)); + assert.notEqual( + hash, + hashKnowledgeThreadPromotionRequestKey( + { ...actor, userId: 'another-knowledge-user' }, + requestKey, + ), + ); + assert.equal(hash.includes(requestKey), false); +}); diff --git a/scripts/test-knowledge-thread-promotion-old-app.sh b/scripts/test-knowledge-thread-promotion-old-app.sh new file mode 100755 index 00000000..b1660012 --- /dev/null +++ b/scripts/test-knowledge-thread-promotion-old-app.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_SHA="${KNOWLEDGE_THREAD_PROMOTION_OLD_APP_BASE_SHA:-b720f575140e533c59070d33723146925faad489}" +EXPECTED_BASE_SHA="b720f575140e533c59070d33723146925faad489" +POSTGRES_IMAGE="${POSTGRES_IMAGE:-docker.io/library/postgres:15@sha256:6ab12ad4395ee49ab49fe19530f7e183c5a9c97fc47cf687b3e281bec5f91ee4}" +CONTAINER_NAME="erp4-knowledge-thread-promotion-old-app-$$" +SCRATCH_ROOT="$ROOT_DIR/.codex-local/tmp/knowledge-thread-promotion-old-app-$$" +OLD_APP_ROOT="$SCRATCH_ROOT/old-app" +TEST_DATABASE="erp4_knowledge_thread_promotion_old_app_test" +TEST_USER="erp4_knowledge_thread_promotion_old_app_test" +TEST_PASSWORD="$({ + node -e 'process.stdout.write(require("node:crypto").randomBytes(24).toString("hex"))' +})" + +if [[ "$BASE_SHA" != "$EXPECTED_BASE_SHA" ]]; then + echo "Refusing an unreviewed Knowledge thread promotion old-app compatibility baseline" >&2 + exit 1 +fi +git -C "$ROOT_DIR" cat-file -e "${BASE_SHA}^{commit}" +mkdir -p "$OLD_APP_ROOT" +chmod 700 "$SCRATCH_ROOT" + +cleanup() { + podman stop --time 5 "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ -d "$SCRATCH_ROOT" && "$SCRATCH_ROOT" == "$ROOT_DIR/.codex-local/tmp/knowledge-thread-promotion-old-app-"* ]]; then + find "$SCRATCH_ROOT" -xdev -depth -delete + fi +} +trap cleanup EXIT INT TERM + +git -C "$ROOT_DIR" archive "$BASE_SHA" | tar -x -C "$OLD_APP_ROOT" +podman run --rm -d \ + --name "$CONTAINER_NAME" \ + --tmpfs /var/lib/postgresql/data:rw,size=1g \ + -p 127.0.0.1::5432 \ + -e POSTGRES_DB="$TEST_DATABASE" \ + -e POSTGRES_USER="$TEST_USER" \ + -e POSTGRES_PASSWORD="$TEST_PASSWORD" \ + "$POSTGRES_IMAGE" >/dev/null + +for _ in $(seq 1 60); do + if podman exec "$CONTAINER_NAME" pg_isready -U "$TEST_USER" -d "$TEST_DATABASE" >/dev/null 2>&1; then + break + fi + sleep 1 +done +podman exec "$CONTAINER_NAME" pg_isready -U "$TEST_USER" -d "$TEST_DATABASE" >/dev/null +HOST_PORT="$(podman port "$CONTAINER_NAME" 5432/tcp | sed -n 's/.*://p' | tail -n 1)" +if [[ ! "$HOST_PORT" =~ ^[0-9]+$ ]]; then + echo "Unable to determine ephemeral PostgreSQL port" >&2 + exit 1 +fi + +export DATABASE_URL="postgresql://${TEST_USER}:${TEST_PASSWORD}@127.0.0.1:${HOST_PORT}/${TEST_DATABASE}?schema=public" +export NODE_ENV=test +export AUTH_MODE=header +export KNOWLEDGE_CURSOR_SIGNING_SECRET="knowledge-thread-promotion-old-app-compat-secret-0001" +export KNOWLEDGE_SHARE_OLD_APP_CONFIRM=1 +export KNOWLEDGE_THREAD_PROMOTION_OLD_APP_CONFIRM=1 +export KNOWLEDGE_THREAD_PROMOTION_SCHEMA_INTEGRATION_CONFIRM=1 +export TZ=UTC +export KNOWLEDGE_THREAD_PROMOTION_OLD_APP_BASE_SHA="$BASE_SHA" +export OLD_APP_ROOT +export CURRENT_APP_ROOT="$ROOT_DIR" + +npm ci --prefix "$OLD_APP_ROOT/packages/backend" >/dev/null +npm run prisma:generate --prefix "$OLD_APP_ROOT/packages/backend" >/dev/null +npx --prefix "$OLD_APP_ROOT/packages/backend" prisma migrate deploy \ + --config "$OLD_APP_ROOT/packages/backend/prisma.config.ts" >/dev/null +OLD_MIGRATIONS="$(find "$OLD_APP_ROOT/packages/backend/prisma/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l)" +npm run build --prefix "$OLD_APP_ROOT/packages/backend" >/dev/null + +export KNOWLEDGE_SHARE_OLD_APP_MODE=seed +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs" + +npx --prefix "$ROOT_DIR/packages/backend" prisma migrate deploy \ + --config "$ROOT_DIR/packages/backend/prisma.config.ts" >/dev/null +npx --prefix "$ROOT_DIR/packages/backend" prisma migrate status \ + --config "$ROOT_DIR/packages/backend/prisma.config.ts" >/dev/null +NEW_MIGRATIONS="$(find "$ROOT_DIR/packages/backend/prisma/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l)" +npm run prisma:generate --prefix "$ROOT_DIR/packages/backend" >/dev/null + +export KNOWLEDGE_SHARE_OLD_APP_MODE=current-create +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs" +npm run build --prefix "$ROOT_DIR/packages/backend" >/dev/null +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-application-integration.mjs" >/dev/null + +export KNOWLEDGE_SHARE_OLD_APP_MODE=old-after +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs" +export KNOWLEDGE_SHARE_OLD_APP_MODE=current-after +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-old-app-compat.mjs" + +printf '{"baseline":"%s","oldMigrations":%s,"newMigrations":%s,"chatMessageType":"text","promotionActualRow":true,"oldApplicationReadWrite":true,"healthReadiness":true}\n' \ + "$BASE_SHA" "$OLD_MIGRATIONS" "$NEW_MIGRATIONS" +echo "knowledge thread promotion old-application compatibility: PASS" diff --git a/scripts/test-knowledge-thread-promotion-schema-postgres.sh b/scripts/test-knowledge-thread-promotion-schema-postgres.sh new file mode 100755 index 00000000..8c48afa7 --- /dev/null +++ b/scripts/test-knowledge-thread-promotion-schema-postgres.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONTAINER_NAME="erp4-knowledge-thread-promotion-schema-$$" +POSTGRES_IMAGE="${POSTGRES_IMAGE:-docker.io/library/postgres:15@sha256:6ab12ad4395ee49ab49fe19530f7e183c5a9c97fc47cf687b3e281bec5f91ee4}" +TEST_DATABASE="erp4_knowledge_thread_promotion_schema" +TEST_USER="erp4_knowledge_thread_promotion_schema" +TEST_PASSWORD="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(24).toString("hex"))')" + +cleanup() { + podman stop --time 5 "$CONTAINER_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +podman run --rm -d \ + --name "$CONTAINER_NAME" \ + --tmpfs /var/lib/postgresql/data:rw,size=1g \ + -p 127.0.0.1::5432 \ + -e POSTGRES_DB="$TEST_DATABASE" \ + -e POSTGRES_USER="$TEST_USER" \ + -e POSTGRES_PASSWORD="$TEST_PASSWORD" \ + "$POSTGRES_IMAGE" >/dev/null + +for _ in $(seq 1 60); do + if podman exec "$CONTAINER_NAME" pg_isready \ + -U "$TEST_USER" -d "$TEST_DATABASE" >/dev/null 2>&1; then + break + fi + sleep 1 +done +podman exec "$CONTAINER_NAME" pg_isready \ + -U "$TEST_USER" -d "$TEST_DATABASE" >/dev/null + +HOST_PORT="$(podman port "$CONTAINER_NAME" 5432/tcp | sed -n 's/.*://p' | tail -n 1)" +if [[ ! "$HOST_PORT" =~ ^[0-9]+$ ]]; then + echo "Unable to determine ephemeral PostgreSQL port" >&2 + exit 1 +fi + +export DATABASE_URL="postgresql://${TEST_USER}:${TEST_PASSWORD}@127.0.0.1:${HOST_PORT}/${TEST_DATABASE}?schema=public" +export KNOWLEDGE_THREAD_PROMOTION_SCHEMA_INTEGRATION_CONFIRM=1 +export TZ=UTC + +npm run prisma:generate --prefix "$ROOT_DIR/packages/backend" >/dev/null +npm run build --prefix "$ROOT_DIR/packages/backend" >/dev/null +npx --prefix "$ROOT_DIR/packages/backend" prisma migrate deploy \ + --config "$ROOT_DIR/packages/backend/prisma.config.ts" >/dev/null + +migration_diff="$(npx --prefix "$ROOT_DIR/packages/backend" prisma migrate diff \ + --config "$ROOT_DIR/packages/backend/prisma.config.ts" \ + --from-config-datasource \ + --to-schema "$ROOT_DIR/packages/backend/prisma/schema.prisma" \ + --script)" +promotion_drift_pattern='KnowledgeThreadPromotion|KnowledgeSynthesisGroupGrant|sourceThreadPromotionId' +if grep -Eq "$promotion_drift_pattern" <<<"$migration_diff"; then + echo 'Knowledge thread promotion migration/schema drift detected:' >&2 + grep -E "$promotion_drift_pattern" <<<"$migration_diff" >&2 + exit 1 +fi + +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-schema-integration.mjs" +node "$ROOT_DIR/packages/backend/scripts/knowledge-thread-promotion-application-integration.mjs" +npx --prefix "$ROOT_DIR/packages/backend" prisma migrate status \ + --config "$ROOT_DIR/packages/backend/prisma.config.ts" >/dev/null + +echo "knowledge thread promotion schema PostgreSQL integration: PASS"