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;