diff --git a/js/app/packages/app/index.html b/js/app/packages/app/index.html index c70ddc9b64..9f0edf6f00 100644 --- a/js/app/packages/app/index.html +++ b/js/app/packages/app/index.html @@ -25,6 +25,8 @@ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> + + diff --git a/js/app/packages/app/public/markdown-golden.1.bin b/js/app/packages/app/public/markdown-golden.1.bin new file mode 100644 index 0000000000..4d4c191f38 Binary files /dev/null and b/js/app/packages/app/public/markdown-golden.1.bin differ diff --git a/js/app/packages/block-md/definition.ts b/js/app/packages/block-md/definition.ts index 5321498413..26e1607c06 100644 --- a/js/app/packages/block-md/definition.ts +++ b/js/app/packages/block-md/definition.ts @@ -5,8 +5,13 @@ import { loadResult, } from '@core/block'; import { ENABLE_MARKDOWN_LIVE_COLLABORATION } from '@core/constant/featureFlags'; +import { queryClient } from '@queries/client'; import { waitForDocumentSyncServiceReady } from '@queries/storage/document-location'; +import type { DocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; +import { documentLoadKeys } from '@queries/storage/documentLoad/keys'; import { storageServiceClient } from '@service-storage/client'; +import type { AccessLevel } from '@service-storage/generated/schemas/accessLevel'; +import type { DocumentMetadata } from '@service-storage/generated/schemas/documentMetadata'; import { makeFileFromBlob } from '@service-storage/util/makeFileFromBlob'; import { createSyncServiceSource } from '@service-sync/source'; import { err, ok } from 'neverthrow'; @@ -35,53 +40,64 @@ export const definition = defineBlock({ }); } - const [maybeDocument, maybeLocation, maybeToken] = await Promise.all([ - loadResult(storageServiceClient.getDocumentMetadata({ documentId })), - loadResult(storageServiceClient.getDocumentLocation({ documentId })), - storageServiceClient.permissionsTokens.createPermissionToken({ - document_id: documentId, - }), - ]); + const pre = queryClient.getQueryData( + documentLoadKeys.bundle(documentId).queryKey + ); - if (maybeToken.isErr()) { - return LoadErrors.UNAUTHORIZED; - } + let token: string; + let documentMetadata: DocumentMetadata; + let userAccessLevel: AccessLevel; + + if (pre) { + token = pre.token; + documentMetadata = pre.documentMetadata; + userAccessLevel = pre.userAccessLevel; + } else { + const [maybeDocument, maybeLocation, maybeToken] = await Promise.all([ + loadResult(storageServiceClient.getDocumentMetadata({ documentId })), + loadResult(storageServiceClient.getDocumentLocation({ documentId })), + storageServiceClient.permissionsTokens.createPermissionToken({ + document_id: documentId, + }), + ]); - const { token } = maybeToken.value; + if (maybeToken.isErr()) return LoadErrors.UNAUTHORIZED; + if (maybeDocument.isErr()) return err(maybeDocument.error); + if (maybeLocation.isErr()) return err(maybeLocation.error); - if (maybeDocument.isErr()) return err(maybeDocument.error); - if (maybeLocation.isErr()) return err(maybeLocation.error); + token = maybeToken.value.token; + const documentResult = maybeDocument.value; + documentMetadata = documentResult.documentMetadata; + userAccessLevel = documentResult.userAccessLevel; - const documentResult = maybeDocument.value; - const { documentMetadata, userAccessLevel } = documentResult; - let { data: location } = maybeLocation.value; + let { data: location } = maybeLocation.value; + if ( + location.type === 'presignedUrl' && + location.content.state === 'pending' + ) { + location = await waitForDocumentSyncServiceReady({ + documentId, + }).catch((error) => { + console.error( + 'Failed waiting for markdown sync-service location', + error + ); + return location; + }); + } - if ( - location.type === 'presignedUrl' && - location.content.state === 'pending' - ) { - location = await waitForDocumentSyncServiceReady({ - documentId, - }).catch((error) => { + // Markdown initialization and lifecycle persistence are backend-owned. + // If a markdown document still resolves to object storage here, opening + // it would require a backend repair/backfill path rather than a frontend + // sync-service mutation that leaves DB content metadata inconsistent. + if (location.type !== 'syncServiceContent') { console.error( - 'Failed waiting for markdown sync-service location', - error + 'Markdown document is not available in sync-service', + documentId, + location.content ); - return location; - }); - } - - // Markdown initialization and lifecycle persistence are backend-owned. - // If a markdown document still resolves to object storage here, opening - // it would require a backend repair/backfill path rather than a frontend - // sync-service mutation that leaves DB content metadata inconsistent. - if (location.type !== 'syncServiceContent') { - console.error( - 'Markdown document is not available in sync-service', - documentId, - location.content - ); - return LoadErrors.INVALID; + return LoadErrors.INVALID; + } } const { source: syncSource, doInitialSync } = createSyncServiceSource( diff --git a/js/app/packages/core/util/create.ts b/js/app/packages/core/util/create.ts index dade3ef8f5..0625c478b1 100644 --- a/js/app/packages/core/util/create.ts +++ b/js/app/packages/core/util/create.ts @@ -6,11 +6,14 @@ import { invalidateUserQuota } from '@queries/auth'; import { postNewHistoryItem } from '@queries/history/history'; import { setPreviewOnCreate } from '@queries/preview/preview'; import { refetchSoupEntity } from '@queries/soup/cache'; +import { seedDocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; import { cognitionApiServiceClient } from '@service-cognition/client'; import type { CreateChatRequest } from '@service-cognition/generated/schemas'; import { staticFileClient } from '@service-static-files/client'; import { storageServiceClient } from '@service-storage/client'; +import { AccessLevel } from '@service-storage/generated/schemas/accessLevel'; import type { PropertyInput } from '@service-storage/generated/schemas/propertyInput'; + import { uploadToPresignedUrl } from '@service-storage/util/uploadToPresignedUrl'; import { err, ok } from 'neverthrow'; import { isPaymentError } from './handlePaymentError'; @@ -45,7 +48,13 @@ export async function createMarkdownFile( if (result.isErr()) return; - const { documentId } = result.value; + const { documentId, documentMetadata, token } = result.value; + + seedDocumentLoadBundle(documentId, { + documentMetadata, + userAccessLevel: AccessLevel.owner, + token, + }); setPreviewOnCreate({ itemId: documentId, @@ -101,7 +110,13 @@ export async function createTask( if (result.isErr()) return; - const { documentId } = result.value; + const { documentId, documentMetadata, token } = result.value; + + seedDocumentLoadBundle(documentId, { + documentMetadata, + userAccessLevel: AccessLevel.owner, + token, + }); setPreviewOnCreate({ itemId: documentId, diff --git a/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts b/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts new file mode 100644 index 0000000000..c3d70b77fa --- /dev/null +++ b/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts @@ -0,0 +1,54 @@ +import { storageServiceClient } from '@service-storage/client'; +import type { AccessLevel } from '@service-storage/generated/schemas/accessLevel'; +import type { DocumentMetadata } from '@service-storage/generated/schemas/documentMetadata'; +import { queryClient } from '../../client'; +import { documentLoadKeys } from './keys'; + +export type DocumentLoadBundle = { + documentMetadata: DocumentMetadata; + userAccessLevel: AccessLevel; + token: string; +}; + +const STALE_TIME = 60 * 1000; +const GC_TIME = 60 * 1000; + +async function fetchDocumentLoadBundle( + documentId: string +): Promise { + const [maybeDocument, maybeToken] = await Promise.all([ + storageServiceClient.getDocumentMetadata({ documentId }), + storageServiceClient.permissionsTokens.createPermissionToken({ + document_id: documentId, + }), + ]); + + if (maybeToken.isErr()) throw new Error('UNAUTHORIZED'); + if (maybeDocument.isErr()) + throw new Error('Failed to fetch document metadata'); + + return { + documentMetadata: maybeDocument.value.documentMetadata, + userAccessLevel: maybeDocument.value.userAccessLevel, + token: maybeToken.value.token, + }; +} + +export function documentLoadQueryOptions(documentId: string) { + return { + queryKey: documentLoadKeys.bundle(documentId).queryKey, + queryFn: () => fetchDocumentLoadBundle(documentId), + staleTime: STALE_TIME, + gcTime: GC_TIME, + }; +} + +export function seedDocumentLoadBundle( + documentId: string, + bundle: DocumentLoadBundle +) { + queryClient.setQueryData( + documentLoadKeys.bundle(documentId).queryKey, + bundle + ); +} diff --git a/js/app/packages/queries/storage/documentLoad/keys.ts b/js/app/packages/queries/storage/documentLoad/keys.ts new file mode 100644 index 0000000000..ad520ab948 --- /dev/null +++ b/js/app/packages/queries/storage/documentLoad/keys.ts @@ -0,0 +1,7 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const documentLoadKeys = createQueryKeys('documentLoad', { + bundle: (documentId: string) => ({ + queryKey: [documentId], + }), +}); diff --git a/js/app/packages/service-clients/service-storage/client.ts b/js/app/packages/service-clients/service-storage/client.ts index 146c7d886f..516cc2a303 100644 --- a/js/app/packages/service-clients/service-storage/client.ts +++ b/js/app/packages/service-clients/service-storage/client.ts @@ -1052,7 +1052,11 @@ export const storageServiceClient = { } const response = result.value; - return ok({ documentId: response.documentId }); + return ok({ + documentId: response.documentId, + documentMetadata: response.documentMetadata, + token: response.token, + }); }, /** diff --git a/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownDocumentResponse.ts b/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownDocumentResponse.ts index 69f15b4a49..1d918d1a02 100644 --- a/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownDocumentResponse.ts +++ b/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownDocumentResponse.ts @@ -4,11 +4,16 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a markdown document. */ export interface CreateMarkdownDocumentResponse { - /** The document ID of the created markdown document. */ + /** The document ID of the created markdown document */ documentId: string; + /** Metadata for the created document */ + documentMetadata: DocumentResponseMetadata; + /** A pre-generated permission token that you can use for SS */ + token: string; } diff --git a/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownHandler200.ts b/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownHandler200.ts index 90d125dba2..0870ddc1d5 100644 --- a/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownHandler200.ts +++ b/js/app/packages/service-clients/service-storage/generated/schemas/createMarkdownHandler200.ts @@ -4,11 +4,16 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a markdown document. */ export type CreateMarkdownHandler200 = { - /** The document ID of the created markdown document. */ + /** The document ID of the created markdown document */ documentId: string; + /** Metadata for the created document */ + documentMetadata: DocumentResponseMetadata; + /** A pre-generated permission token that you can use for SS */ + token: string; }; diff --git a/js/app/packages/service-clients/service-storage/generated/schemas/createTaskHandler200.ts b/js/app/packages/service-clients/service-storage/generated/schemas/createTaskHandler200.ts index e3d43f17e7..5492d6a2b1 100644 --- a/js/app/packages/service-clients/service-storage/generated/schemas/createTaskHandler200.ts +++ b/js/app/packages/service-clients/service-storage/generated/schemas/createTaskHandler200.ts @@ -4,8 +4,10 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ + import type { CreateTaskHandler200TeamId } from './createTaskHandler200TeamId'; import type { CreateTaskHandler200TeamTaskId } from './createTaskHandler200TeamTaskId'; +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a task. @@ -13,8 +15,12 @@ import type { CreateTaskHandler200TeamTaskId } from './createTaskHandler200TeamT export type CreateTaskHandler200 = { /** The document ID of the created task. */ documentId: string; + /** Metadata for the created document */ + documentMetadata: DocumentResponseMetadata; /** The team this task number is scoped to. */ teamId?: CreateTaskHandler200TeamId; /** The task number assigned within the team. */ teamTaskId?: CreateTaskHandler200TeamTaskId; + /** A pre-generated permission token that you can use for SS */ + token: string; }; diff --git a/js/app/packages/service-clients/service-storage/generated/schemas/createTaskResponse.ts b/js/app/packages/service-clients/service-storage/generated/schemas/createTaskResponse.ts index 2d767d8e81..e7b11fec15 100644 --- a/js/app/packages/service-clients/service-storage/generated/schemas/createTaskResponse.ts +++ b/js/app/packages/service-clients/service-storage/generated/schemas/createTaskResponse.ts @@ -4,8 +4,10 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ + import type { CreateTaskResponseTeamId } from './createTaskResponseTeamId'; import type { CreateTaskResponseTeamTaskId } from './createTaskResponseTeamTaskId'; +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a task. @@ -13,8 +15,12 @@ import type { CreateTaskResponseTeamTaskId } from './createTaskResponseTeamTaskI export interface CreateTaskResponse { /** The document ID of the created task. */ documentId: string; + /** Metadata for the created document */ + documentMetadata: DocumentResponseMetadata; /** The team this task number is scoped to. */ teamId?: CreateTaskResponseTeamId; /** The task number assigned within the team. */ teamTaskId?: CreateTaskResponseTeamTaskId; + /** A pre-generated permission token that you can use for SS */ + token: string; } diff --git a/js/app/packages/service-clients/service-storage/generated/zod.ts b/js/app/packages/service-clients/service-storage/generated/zod.ts index 1a5a7355b9..74b23c6259 100644 --- a/js/app/packages/service-clients/service-storage/generated/zod.ts +++ b/js/app/packages/service-clients/service-storage/generated/zod.ts @@ -3727,7 +3727,86 @@ export const createMarkdownHandlerResponse = zod .object({ documentId: zod .string() - .describe('The document ID of the created markdown document.'), + .describe('The document ID of the created markdown document'), + documentMetadata: zod.object({ + branchedFromId: zod + .string() + .nullish() + .describe('The id of the document this document branched from'), + branchedFromVersionId: zod + .number() + .nullish() + .describe( + 'The id of the version this document branched from\nThis could be either DocumentInstance or DocumentBom id depending on\nthe file type' + ), + createdAt: zod.iso + .datetime({}) + .nullish() + .describe('The time the document was created'), + documentBom: zod + .array( + zod.object({ + id: zod.string().describe('The uuid of the bom part'), + path: zod + .string() + .describe('The file path of the bom part content'), + sha: zod + .string() + .describe( + 'The sha of the bom part content\nThere is an index on sha for more performant queries based on it.' + ), + }) + ) + .nullish() + .describe( + 'If the document is a DOCX document, the document_bom will be present' + ), + documentFamilyId: zod + .number() + .nullish() + .describe('The id of the document family this document belongs to'), + documentId: zod.string().describe('The document id'), + documentName: zod.string().describe('The name of the document'), + documentVersionId: zod + .number() + .describe( + 'The version of the document\nThis could be the document_instance_id or document_bom_id depending on\nthe file type' + ), + fileType: zod + .string() + .nullish() + .describe('The file type of the document'), + modificationData: zod + .unknown() + .optional() + .describe( + 'The modification data for the document instance.\nThis is only used for PDF documents.' + ), + owner: zod.string().describe('The owner of the document'), + sha: zod + .string() + .nullish() + .describe( + 'If the document is a PDF, this is the SHA of the pdf\nIf the document is a DOCX, this will not be present' + ), + subType: zod + .union([ + zod.null(), + zod + .enum(['task', 'snippet']) + .describe( + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + ), + ]) + .optional(), + updatedAt: zod.iso + .datetime({}) + .nullish() + .describe('The time the document instance \/ document BOM was updated'), + }), + token: zod + .string() + .describe('A pre-generated permission token that you can use for SS'), }) .describe('Response for creating a markdown document.'); @@ -3929,6 +4008,82 @@ export const createTaskHandlerBody = zod export const createTaskHandlerResponse = zod .object({ documentId: zod.string().describe('The document ID of the created task.'), + documentMetadata: zod.object({ + branchedFromId: zod + .string() + .nullish() + .describe('The id of the document this document branched from'), + branchedFromVersionId: zod + .number() + .nullish() + .describe( + 'The id of the version this document branched from\nThis could be either DocumentInstance or DocumentBom id depending on\nthe file type' + ), + createdAt: zod.iso + .datetime({}) + .nullish() + .describe('The time the document was created'), + documentBom: zod + .array( + zod.object({ + id: zod.string().describe('The uuid of the bom part'), + path: zod + .string() + .describe('The file path of the bom part content'), + sha: zod + .string() + .describe( + 'The sha of the bom part content\nThere is an index on sha for more performant queries based on it.' + ), + }) + ) + .nullish() + .describe( + 'If the document is a DOCX document, the document_bom will be present' + ), + documentFamilyId: zod + .number() + .nullish() + .describe('The id of the document family this document belongs to'), + documentId: zod.string().describe('The document id'), + documentName: zod.string().describe('The name of the document'), + documentVersionId: zod + .number() + .describe( + 'The version of the document\nThis could be the document_instance_id or document_bom_id depending on\nthe file type' + ), + fileType: zod + .string() + .nullish() + .describe('The file type of the document'), + modificationData: zod + .unknown() + .optional() + .describe( + 'The modification data for the document instance.\nThis is only used for PDF documents.' + ), + owner: zod.string().describe('The owner of the document'), + sha: zod + .string() + .nullish() + .describe( + 'If the document is a PDF, this is the SHA of the pdf\nIf the document is a DOCX, this will not be present' + ), + subType: zod + .union([ + zod.null(), + zod + .enum(['task', 'snippet']) + .describe( + 'The document sub type enum represents all values of document sub types.\nThese values should match the `document_sub_type_value` table in macrodb.' + ), + ]) + .optional(), + updatedAt: zod.iso + .datetime({}) + .nullish() + .describe('The time the document instance \/ document BOM was updated'), + }), teamId: zod .uuid() .nullish() @@ -3937,6 +4092,9 @@ export const createTaskHandlerResponse = zod .number() .nullish() .describe('The task number assigned within the team.'), + token: zod + .string() + .describe('A pre-generated permission token that you can use for SS'), }) .describe('Response for creating a task.'); diff --git a/js/app/packages/service-clients/service-storage/openapi.json b/js/app/packages/service-clients/service-storage/openapi.json index 62a78cf400..e0f070349f 100644 --- a/js/app/packages/service-clients/service-storage/openapi.json +++ b/js/app/packages/service-clients/service-storage/openapi.json @@ -4676,11 +4676,19 @@ "schema": { "type": "object", "description": "Response for creating a markdown document.", - "required": ["documentId"], + "required": ["documentId", "documentMetadata", "token"], "properties": { "documentId": { "type": "string", - "description": "The document ID of the created markdown document." + "description": "The document ID of the created markdown document" + }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document" + }, + "token": { + "type": "string", + "description": "A pre-generated permission token that you can use for SS" } } } @@ -4820,12 +4828,16 @@ "schema": { "type": "object", "description": "Response for creating a task.", - "required": ["documentId"], + "required": ["documentId", "documentMetadata", "token"], "properties": { "documentId": { "type": "string", "description": "The document ID of the created task." }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document" + }, "teamId": { "type": ["string", "null"], "format": "uuid", @@ -4835,6 +4847,10 @@ "type": ["integer", "null"], "format": "int32", "description": "The task number assigned within the team." + }, + "token": { + "type": "string", + "description": "A pre-generated permission token that you can use for SS" } } } @@ -11761,11 +11777,19 @@ "CreateMarkdownDocumentResponse": { "type": "object", "description": "Response for creating a markdown document.", - "required": ["documentId"], + "required": ["documentId", "documentMetadata", "token"], "properties": { "documentId": { "type": "string", - "description": "The document ID of the created markdown document." + "description": "The document ID of the created markdown document" + }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document" + }, + "token": { + "type": "string", + "description": "A pre-generated permission token that you can use for SS" } } }, @@ -11868,12 +11892,16 @@ "CreateTaskResponse": { "type": "object", "description": "Response for creating a task.", - "required": ["documentId"], + "required": ["documentId", "documentMetadata", "token"], "properties": { "documentId": { "type": "string", "description": "The document ID of the created task." }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document" + }, "teamId": { "type": ["string", "null"], "format": "uuid", @@ -11883,6 +11911,10 @@ "type": ["integer", "null"], "format": "int32", "description": "The task number assigned within the team." + }, + "token": { + "type": "string", + "description": "A pre-generated permission token that you can use for SS" } } }, diff --git a/js/bun.lock b/js/bun.lock index ec0ae9c0ba..ab75f4ccc7 100644 --- a/js/bun.lock +++ b/js/bun.lock @@ -846,7 +846,7 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inkibra/tauri-plugins": ["@inkibra/tauri-plugins@github:macro-inc/tauri-plugins#26537c8", { "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "macro-inc-tauri-plugins-26537c8"], + "@inkibra/tauri-plugins": ["@inkibra/tauri-plugins@github:macro-inc/tauri-plugins#26537c8", { "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "macro-inc-tauri-plugins-26537c8", "sha512-6p5xQAkS6Uw6J8Uh0vlj6MjKZXWps5E1Gu0hciBFq1E2BfPBZAgdeeafKnR3diNb4SXWK0JML49nAr+o5PhwKw=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -2556,7 +2556,7 @@ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pdfjs-dist": ["pdfjs-dist@github:macro-inc/pdf.js#f9b2ce6", { "dependencies": { "dommatrix": "^1.0.3", "web-streams-polyfill": "^3.2.1" }, "peerDependencies": { "worker-loader": "^3.0.8" }, "optionalPeers": ["worker-loader"] }, "macro-inc-pdf.js-f9b2ce6"], + "pdfjs-dist": ["pdfjs-dist@github:macro-inc/pdf.js#f9b2ce6", { "dependencies": { "dommatrix": "^1.0.3", "web-streams-polyfill": "^3.2.1" }, "peerDependencies": { "worker-loader": "^3.0.8" }, "optionalPeers": ["worker-loader"] }, "macro-inc-pdf.js-f9b2ce6", "sha512-HDFaPbCxLG2GHpf1smUG97nku5aPGBGaCcIbK05dGdp07TBLUlDcXsM4WcE82mO5W4RBPIBVYobX5N/6M6NFrA=="], "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], diff --git a/js/lexical-core/markdown-golden.ts b/js/lexical-core/markdown-golden.ts index 7266f52cc6..f6e4049c79 100644 --- a/js/lexical-core/markdown-golden.ts +++ b/js/lexical-core/markdown-golden.ts @@ -1,4 +1,5 @@ -import MARKDOWN_GOLDEN_URL from './markdown-golden.1.bin?url'; +// Served from /public so the URL is stable and can be preloaded from index.html. +const MARKDOWN_GOLDEN_URL = '/markdown-golden.1.bin'; let goldenPromise: Promise | null = null; diff --git a/js/lexical-core/scripts/generate-golden.ts b/js/lexical-core/scripts/generate-golden.ts index 513be6f470..acb6c6a21c 100644 --- a/js/lexical-core/scripts/generate-golden.ts +++ b/js/lexical-core/scripts/generate-golden.ts @@ -23,10 +23,12 @@ const GOLDEN_FILENAME = 'markdown-golden.1.bin'; // Copies need to land in multiple places because each Rust crate's Docker // build context is its own directory — include_bytes! can't reach outside // the context, so the file has to be physically present inside each one. +// The app/public copy is what the browser preloads via index.html. const OUT_PATHS = [ `${import.meta.dir}/../${GOLDEN_FILENAME}`, `${import.meta.dir}/../../../rust/cloud-storage/${GOLDEN_FILENAME}`, `${import.meta.dir}/../../../rust/sync-service/${GOLDEN_FILENAME}`, + `${import.meta.dir}/../../app/packages/app/public/${GOLDEN_FILENAME}`, ]; for (const outPath of OUT_PATHS) { diff --git a/rust/cloud-storage/Cargo.lock b/rust/cloud-storage/Cargo.lock index d61e584955..2b4e16c434 100644 --- a/rust/cloud-storage/Cargo.lock +++ b/rust/cloud-storage/Cargo.lock @@ -3778,6 +3778,7 @@ dependencies = [ "futures", "hex", "http-body-util", + "jsonwebtoken 10.3.0", "lexical_client", "macro_aws_config", "macro_db_migrator", diff --git a/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/create_permission_token.rs b/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/create_permission_token.rs index c344b2c7e7..d2e01c552d 100644 --- a/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/create_permission_token.rs +++ b/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/create_permission_token.rs @@ -5,12 +5,12 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use documents_hex::domain::permission_token::encode_permission_token; use entity_access::domain::models::EntityPermission; use entity_access::inbound::axum_extractors::DocumentAccessExtractor; -use model::{document::DocumentPermissionsToken, response::ErrorResponse, user::UserContext}; +use model::{response::ErrorResponse, user::UserContext}; use models_permissions::share_permission::access_level::{AccessLevel, ViewAccessLevel}; use serde::Deserialize; -use std::time::{SystemTime, UNIX_EPOCH}; use utoipa::ToSchema; use crate::api::context::ApiContext; @@ -49,36 +49,22 @@ pub async fn handler( users_access_level: DocumentAccessExtractor, Path(Params { document_id }): Path, ) -> Result { - // Get the current time - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as usize; - let user_id = if user_context.user_id.is_empty() { None } else { Some(user_context.user_id.clone()) }; - let document_permissions_token = DocumentPermissionsToken { - user_id, - document_id, - access_level: match users_access_level.entity_access_receipt.entity_permission() { - EntityPermission::AccessLevel { access_level } => *access_level, - _ => AccessLevel::View, - }, - exp: now + 3600, // Token expires in 1 hour - iss: "document_storage_service".to_string(), + let access_level = match users_access_level.entity_access_receipt.entity_permission() { + EntityPermission::AccessLevel { access_level } => *access_level, + _ => AccessLevel::View, }; - let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); - let token = jsonwebtoken::encode( - &header, - &document_permissions_token, - &jsonwebtoken::EncodingKey::from_secret( - state.config.document_permission_jwt.as_ref().as_bytes(), - ), + let token = encode_permission_token( + user_id, + document_id, + access_level, + state.config.document_permission_jwt.as_ref(), ) .map_err(|e| { tracing::error!(error=?e, "unable to encode jwt"); diff --git a/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/validate_permissions_token.rs b/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/validate_permissions_token.rs index 183b374896..385c492c8b 100644 --- a/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/validate_permissions_token.rs +++ b/rust/cloud-storage/document_storage_service/src/api/documents/permissions_token/validate_permissions_token.rs @@ -6,6 +6,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use documents_hex::domain::permission_token::ISSUER; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use model::{document::DocumentPermissionsToken, response::ErrorResponse, user::UserContext}; use utoipa::ToSchema; @@ -42,7 +43,7 @@ pub async fn handler( // Verify and decode the JWT let mut validation = Validation::new(Algorithm::HS256); - validation.set_issuer(&["document_storage_service"]); + validation.set_issuer(&[ISSUER]); let user_id = if user_context.user_id.is_empty() { None diff --git a/rust/cloud-storage/document_storage_service/src/main.rs b/rust/cloud-storage/document_storage_service/src/main.rs index 974187c8cf..e5177d8955 100644 --- a/rust/cloud-storage/document_storage_service/src/main.rs +++ b/rust/cloud-storage/document_storage_service/src/main.rs @@ -629,7 +629,6 @@ async fn main() -> anyhow::Result<()> { system_properties_service: system_properties_service.clone(), properties_service: properties_service.clone(), opensearch_client: Arc::new(opensearch_client), - config: Arc::new(config), jwt_validation_args, dss_auth_key, // Comms service fields @@ -647,7 +646,9 @@ async fn main() -> anyhow::Result<()> { markdown_initializer, documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader::default(), ), + document_permission_jwt_secret: config.document_permission_jwt.as_ref().to_string(), }, + config: Arc::new(config), channels_state: ChannelsRouterState::from_arc( channels_service, (*entity_access_service).clone(), diff --git a/rust/cloud-storage/documents/Cargo.toml b/rust/cloud-storage/documents/Cargo.toml index 1d5cb41557..81d8a3aa0e 100644 --- a/rust/cloud-storage/documents/Cargo.toml +++ b/rust/cloud-storage/documents/Cargo.toml @@ -31,6 +31,7 @@ ai_tools = [ axum = [ "dep:axum", "dep:axum-extra", + "dep:jsonwebtoken", "dep:lexical_client", "dep:model-error-response", "dep:model_user", @@ -111,6 +112,7 @@ tokio = { workspace = true, optional = true } # Axum dependencies (for inbound) axum = { workspace = true, optional = true } +jsonwebtoken = { workspace = true, optional = true } axum-extra = { workspace = true, optional = true } model-error-response = { path = "../model-error-response", optional = true } model_user = { path = "../model_user", optional = true, features = ["axum"] } diff --git a/rust/cloud-storage/documents/src/domain.rs b/rust/cloud-storage/documents/src/domain.rs index 137c5965cb..2803df036a 100644 --- a/rust/cloud-storage/documents/src/domain.rs +++ b/rust/cloud-storage/documents/src/domain.rs @@ -12,6 +12,8 @@ pub mod create; pub mod upload_finalize; pub mod models; +#[cfg(feature = "axum")] +pub mod permission_token; pub mod response; #[cfg(feature = "ports")] diff --git a/rust/cloud-storage/documents/src/domain/models.rs b/rust/cloud-storage/documents/src/domain/models.rs index a384ee9311..e9d790286e 100644 --- a/rust/cloud-storage/documents/src/domain/models.rs +++ b/rust/cloud-storage/documents/src/domain/models.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use macro_user_id::user_id::MacroUserIdStr; +use model::document::response::DocumentResponseMetadata; use model::document::{DocumentMetadata, FileType}; use super::response::DocumentResponse; @@ -43,6 +44,10 @@ pub enum DocumentError { /// An internal error occurred. #[error("{0}")] Internal(#[from] anyhow::Error), + /// JWT encoding failed. + #[cfg(feature = "axum")] + #[error(transparent)] + JwtEncoding(#[from] jsonwebtoken::errors::Error), } /// Response wrapper for the copy document endpoint. @@ -403,8 +408,12 @@ pub struct CreateMarkdownDocumentRequest { #[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct CreateMarkdownDocumentResponse { - /// The document ID of the created markdown document. + /// The document ID of the created markdown document pub document_id: String, + /// Metadata for the created document + pub document_metadata: DocumentResponseMetadata, + /// A pre-generated permission token that you can use for SS + pub token: String, } /// Request body for creating a task. @@ -436,6 +445,10 @@ pub struct CreateTaskRequest { pub struct CreateTaskResponse { /// The document ID of the created task. pub document_id: String, + /// Metadata for the created document + pub document_metadata: DocumentResponseMetadata, + /// A pre-generated permission token that you can use for SS + pub token: String, /// The team this task number is scoped to. #[serde(skip_serializing_if = "Option::is_none")] pub team_id: Option, diff --git a/rust/cloud-storage/documents/src/domain/permission_token.rs b/rust/cloud-storage/documents/src/domain/permission_token.rs new file mode 100644 index 0000000000..d76170ba8d --- /dev/null +++ b/rust/cloud-storage/documents/src/domain/permission_token.rs @@ -0,0 +1,38 @@ +//! Helpers for issuing document permission tokens (signed JWTs) used by the +//! sync service to authorize document access. + +use crate::domain::models::DocumentError; +use model::document::DocumentPermissionsToken; +use models_permissions::share_permission::access_level::AccessLevel; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// JWT issuer claim value +pub const ISSUER: &str = "document_storage_service"; + +/// Token lifetime in seconds (1 hour). +const TOKEN_TTL_SECS: usize = 3600; + +/// Sign a document permission token for the given user and document. +pub fn encode_permission_token( + user_id: Option, + document_id: String, + access_level: AccessLevel, + jwt_secret: &str, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + + Ok(jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &DocumentPermissionsToken { + user_id, + document_id, + access_level, + exp: now + TOKEN_TTL_SECS, + iss: ISSUER.to_string(), + }, + &jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes()), + )?) +} diff --git a/rust/cloud-storage/documents/src/inbound/axum_router.rs b/rust/cloud-storage/documents/src/inbound/axum_router.rs index db5e231080..39417cfc60 100644 --- a/rust/cloud-storage/documents/src/inbound/axum_router.rs +++ b/rust/cloud-storage/documents/src/inbound/axum_router.rs @@ -89,6 +89,7 @@ impl IntoResponse for DocumentError { DocumentError::Conflict(_) => StatusCode::CONFLICT, DocumentError::BadRequest(_) => StatusCode::BAD_REQUEST, DocumentError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + DocumentError::JwtEncoding(_) => StatusCode::INTERNAL_SERVER_ERROR, }; if status_code.is_server_error() { @@ -130,6 +131,8 @@ pub struct DocumentRouterState { /// Backend-owned document creation use case. #[cfg(feature = "document_create_adapters")] pub creator: DefaultDocumentCreator, + /// JWT secret for signing document permission tokens. + pub document_permission_jwt_secret: String, } // Manual Clone impl so T and Svc don't need to be Clone (they're behind Arc). @@ -143,6 +146,7 @@ impl Clone for DocumentRouterState { lexical_client: self.lexical_client.clone(), #[cfg(feature = "document_create_adapters")] creator: self.creator.clone(), + document_permission_jwt_secret: self.document_permission_jwt_secret.clone(), } } } diff --git a/rust/cloud-storage/documents/src/inbound/axum_router/create_markdown.rs b/rust/cloud-storage/documents/src/inbound/axum_router/create_markdown.rs index b900a38e3d..078f4eff7f 100644 --- a/rust/cloud-storage/documents/src/inbound/axum_router/create_markdown.rs +++ b/rust/cloud-storage/documents/src/inbound/axum_router/create_markdown.rs @@ -4,13 +4,14 @@ use axum::{Json, extract::State}; use entity_access::domain::ports::EntityAccessService; use entity_access::inbound::axum_extractors::ProjectBodyAccessLevelExtractor; use model_user::axum_extractor::MacroUserExtractor; -use models_permissions::share_permission::access_level::EditAccessLevel; +use models_permissions::share_permission::access_level::{AccessLevel, EditAccessLevel}; use super::DocumentRouterState; use crate::domain::create::{MarkdownSubtype, NewDocumentMetadata, NewMarkdownTextDocument}; use crate::domain::models::{ CreateMarkdownDocumentRequest, CreateMarkdownDocumentResponse, DocumentError, }; +use crate::domain::permission_token::encode_permission_token; use crate::domain::ports::DocumentService; use crate::domain::ports::create::DocumentCreationService; @@ -50,7 +51,7 @@ pub async fn create_markdown_handler< let created = state .creator .create_markdown_text( - user_context.macro_user_id, + user_context.macro_user_id.clone(), NewMarkdownTextDocument { metadata: metadata.build(), markdown: req.markdown.unwrap_or_default(), @@ -59,7 +60,28 @@ pub async fn create_markdown_handler< ) .await?; + let document_id = created.document_id().to_string(); + let document_metadata = created + .response() + .document_response + .document_metadata + .metadata + .clone(); + + let token = encode_permission_token( + Some(user_context.macro_user_id.as_ref().to_string()), + document_id.clone(), + AccessLevel::Edit, + &state.document_permission_jwt_secret, + ) + .map_err(|e| { + tracing::error!(error=?e, "failed to encode permission token"); + DocumentError::Internal(e.into()) + })?; + Ok(Json(CreateMarkdownDocumentResponse { - document_id: created.document_id().to_string(), + document_id, + document_metadata, + token, })) } diff --git a/rust/cloud-storage/documents/src/inbound/axum_router/create_task.rs b/rust/cloud-storage/documents/src/inbound/axum_router/create_task.rs index 6579b907e7..b43780f65c 100644 --- a/rust/cloud-storage/documents/src/inbound/axum_router/create_task.rs +++ b/rust/cloud-storage/documents/src/inbound/axum_router/create_task.rs @@ -7,11 +7,12 @@ use entity_access::inbound::axum_extractors::{ OptionalMacroUserTeamExtractor, ProjectBodyAccessLevelExtractor, }; use model_user::axum_extractor::MacroUserExtractor; -use models_permissions::share_permission::access_level::EditAccessLevel; +use models_permissions::share_permission::access_level::{AccessLevel, EditAccessLevel}; use super::DocumentRouterState; use crate::domain::create::{MarkdownSubtype, NewDocumentMetadata, NewMarkdownTextDocument}; use crate::domain::models::{CreateTaskRequest, CreateTaskResponse, DocumentError}; +use crate::domain::permission_token::encode_permission_token; use crate::domain::ports::DocumentService; use crate::domain::ports::create::DocumentCreationService; use task_dedup::NewTask; @@ -63,7 +64,7 @@ pub async fn create_task_handler< let created = state .creator .create_markdown_text( - user_context.macro_user_id, + user_context.macro_user_id.clone(), NewMarkdownTextDocument { metadata: metadata.build(), markdown: markdown.clone(), @@ -90,8 +91,21 @@ pub async fn create_task_handler< }, ); + let token = encode_permission_token( + Some(user_context.macro_user_id.as_ref().to_string()), + document_id.clone(), + AccessLevel::Edit, + &state.document_permission_jwt_secret, + ) + .map_err(|e| { + tracing::error!(error=?e, "failed to encode permission token"); + DocumentError::Internal(e.into()) + })?; + Ok(Json(CreateTaskResponse { document_id, + document_metadata: task_metadata.metadata.clone(), + token, team_id: task_metadata.team_id, team_task_id: task_metadata.team_task_id, })) diff --git a/rust/sync-service/src/websocket.rs b/rust/sync-service/src/websocket.rs index 76818d66b0..c3271c6578 100644 --- a/rust/sync-service/src/websocket.rs +++ b/rust/sync-service/src/websocket.rs @@ -66,7 +66,6 @@ pub fn broadcast_awareness( Ok(()) } - // Max receiving websocket message is 1Mb const MAX_MESSAGE_SIZE: usize = 1000 * 1000;