From 4485afc1691d1f3a6ddbfe07e36b95f66901c766 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 11 Jun 2026 20:34:13 +0000 Subject: [PATCH 1/7] create returns the metadata and location --- js/app/packages/app/index.html | 2 + .../packages/app/public/markdown-golden.1.bin | Bin 0 -> 784 bytes js/app/packages/block-md/definition.ts | 92 +++++----- .../packages/block-md/util/mdCreateCache.ts | 20 +++ js/app/packages/core/util/create.ts | 19 +- .../service-clients/service-storage/client.ts | 6 +- .../schemas/createMarkdownDocumentResponse.ts | 5 + .../schemas/createMarkdownHandler200.ts | 5 + .../generated/schemas/createTaskHandler200.ts | 6 + .../generated/schemas/createTaskResponse.ts | 6 + .../service-storage/generated/zod.ts | 162 ++++++++++++++++++ .../service-storage/openapi.json | 40 ++++- js/lexical-core/markdown-golden.ts | 3 +- js/lexical-core/scripts/generate-golden.ts | 2 + rust/cloud-storage/Cargo.lock | 1 + .../document_storage_service/src/main.rs | 3 +- rust/cloud-storage/documents/Cargo.toml | 2 + rust/cloud-storage/documents/src/domain.rs | 2 + .../documents/src/domain/models.rs | 11 +- .../documents/src/domain/permission_token.rs | 34 ++++ .../documents/src/inbound/axum_router.rs | 3 + .../inbound/axum_router/create_markdown.rs | 28 ++- .../src/inbound/axum_router/create_task.rs | 30 +++- 23 files changed, 427 insertions(+), 55 deletions(-) create mode 100644 js/app/packages/app/public/markdown-golden.1.bin create mode 100644 js/app/packages/block-md/util/mdCreateCache.ts create mode 100644 rust/cloud-storage/documents/src/domain/permission_token.rs 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 0000000000000000000000000000000000000000..4d4c191f3893458dfa7f067352e1dc0d89581cd2 GIT binary patch literal 784 zcmYk4KWGzC9LK-E_uk#*E{REFkfMVUlC&U76H(}t4pI=Jt+6Q-i8M6VCJ>WwIinTC z(spt#3T_I5gOi&>>EPB$2M5uiqf_ak6$;hgyS7OmcaQge|KI2LYqq^MPOb4D+d0Tv z1Qe%AQ$P|)ic@*FaIki`ktde(vgyUk^|w(F&KrtF=M)r#VvW*ULT~B2vQg`X7V3>= z&2w9Jt>L)~exuzo7u(*$s&6)0HMiv}f4SpYJ=g29iqfJ{bA0!SKN;BA`gqS@Zo0~A zw|!(t#W(RHQK&Iko5(17jXj|x&xC;BPqLSwJnBNTK@o9Tg!2J8 zcFAKJ#HC$=yn$;bR$d_k>oLV15T%4%C%Ou|Lc{3_86U}pyw-rWgPK literal 0 HcmV?d00001 diff --git a/js/app/packages/block-md/definition.ts b/js/app/packages/block-md/definition.ts index 5321498413..4f852a9ce8 100644 --- a/js/app/packages/block-md/definition.ts +++ b/js/app/packages/block-md/definition.ts @@ -7,11 +7,14 @@ import { import { ENABLE_MARKDOWN_LIVE_COLLABORATION } from '@core/constant/featureFlags'; import { waitForDocumentSyncServiceReady } from '@queries/storage/document-location'; 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'; import MarkdownBlock from './component/Block'; import type { MarkdownRewriteOutput } from './signal/rewriteSignal'; +import { consumeMdCreate } from './util/mdCreateCache'; export const definition = defineBlock({ name: 'md', @@ -35,53 +38,62 @@ 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 = consumeMdCreate(documentId); - 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/block-md/util/mdCreateCache.ts b/js/app/packages/block-md/util/mdCreateCache.ts new file mode 100644 index 0000000000..b7c91dd815 --- /dev/null +++ b/js/app/packages/block-md/util/mdCreateCache.ts @@ -0,0 +1,20 @@ +import type { AccessLevel } from '@service-storage/generated/schemas/accessLevel'; +import type { DocumentMetadata } from '@service-storage/generated/schemas/documentMetadata'; + +export type MdCreateCacheEntry = { + documentMetadata: DocumentMetadata; + userAccessLevel: AccessLevel; + token: string; +}; + +const cache = new Map(); + +export function cacheMdCreate(documentId: string, entry: MdCreateCacheEntry) { + cache.set(documentId, entry); +} + +export function consumeMdCreate(documentId: string): MdCreateCacheEntry | undefined { + const entry = cache.get(documentId); + cache.delete(documentId); + return entry; +} diff --git a/js/app/packages/core/util/create.ts b/js/app/packages/core/util/create.ts index d891d62251..46f56236c2 100644 --- a/js/app/packages/core/util/create.ts +++ b/js/app/packages/core/util/create.ts @@ -16,7 +16,10 @@ 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 { cacheMdCreate } from '@block-md/util/mdCreateCache'; + import { uploadToPresignedUrl } from '@service-storage/util/uploadToPresignedUrl'; import { err, ok } from 'neverthrow'; import { isPaymentError } from './handlePaymentError'; @@ -50,7 +53,13 @@ export async function createMarkdownFile( if (result.isErr()) return; - const { documentId } = result.value; + const { documentId, documentMetadata, token } = result.value; + + cacheMdCreate(documentId, { + documentMetadata, + userAccessLevel: AccessLevel.owner, + token, + }); setPreviewOnCreate({ itemId: documentId, @@ -106,7 +115,13 @@ export async function createTask( if (result.isErr()) return; - const { documentId } = result.value; + const { documentId, documentMetadata, token } = result.value; + + cacheMdCreate(documentId, { + documentMetadata, + userAccessLevel: AccessLevel.owner, + token, + }); setPreviewOnCreate({ itemId: documentId, diff --git a/js/app/packages/service-clients/service-storage/client.ts b/js/app/packages/service-clients/service-storage/client.ts index 9fa7b934ff..fa1c62e2d1 100644 --- a/js/app/packages/service-clients/service-storage/client.ts +++ b/js/app/packages/service-clients/service-storage/client.ts @@ -1034,7 +1034,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..f1d7f61680 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,6 +4,7 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a markdown document. @@ -11,4 +12,8 @@ export interface CreateMarkdownDocumentResponse { /** The document ID of the created markdown document. */ documentId: string; + /** Metadata for the created document, so callers can skip a round-trip fetch. */ + documentMetadata: DocumentResponseMetadata; + /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + 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..f5653f8d23 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,6 +4,7 @@ * document_storage_service * OpenAPI spec version: 0.1.0 */ +import type { DocumentResponseMetadata } from './documentResponseMetadata'; /** * Response for creating a markdown document. @@ -11,4 +12,8 @@ export type CreateMarkdownHandler200 = { /** The document ID of the created markdown document. */ documentId: string; + /** Metadata for the created document, so callers can skip a round-trip fetch. */ + documentMetadata: DocumentResponseMetadata; + /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + 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..c859998f28 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, so callers can skip a round-trip fetch. */ + documentMetadata: DocumentResponseMetadata; /** The team this task number is scoped to. */ teamId?: CreateTaskHandler200TeamId; /** The task number assigned within the team. */ teamTaskId?: CreateTaskHandler200TeamTaskId; + /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + 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..3ddcbd7d36 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, so callers can skip a round-trip fetch. */ + documentMetadata: DocumentResponseMetadata; /** The team this task number is scoped to. */ teamId?: CreateTaskResponseTeamId; /** The task number assigned within the team. */ teamTaskId?: CreateTaskResponseTeamTaskId; + /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + 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 ebda929fd0..5b56634497 100644 --- a/js/app/packages/service-clients/service-storage/generated/zod.ts +++ b/js/app/packages/service-clients/service-storage/generated/zod.ts @@ -3699,6 +3699,87 @@ export const createMarkdownHandlerResponse = zod documentId: zod .string() .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']) + .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( + 'Permission token for the sync service, so callers can skip a round-trip fetch.' + ), }) .describe('Response for creating a markdown document.'); @@ -3900,6 +3981,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']) + .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() @@ -3908,6 +4065,11 @@ export const createTaskHandlerResponse = zod .number() .nullish() .describe('The task number assigned within the team.'), + token: zod + .string() + .describe( + 'Permission token for the sync service, so callers can skip a round-trip fetch.' + ), }) .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 2e85463d68..68837c3aca 100644 --- a/js/app/packages/service-clients/service-storage/openapi.json +++ b/js/app/packages/service-clients/service-storage/openapi.json @@ -4550,11 +4550,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." + }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document, so callers can skip a round-trip fetch." + }, + "token": { + "type": "string", + "description": "Permission token for the sync service, so callers can skip a round-trip fetch." } } } @@ -4694,12 +4702,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, so callers can skip a round-trip fetch." + }, "teamId": { "type": ["string", "null"], "format": "uuid", @@ -4709,6 +4721,10 @@ "type": ["integer", "null"], "format": "int32", "description": "The task number assigned within the team." + }, + "token": { + "type": "string", + "description": "Permission token for the sync service, so callers can skip a round-trip fetch." } } } @@ -11605,11 +11621,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." + }, + "documentMetadata": { + "$ref": "#/components/schemas/DocumentResponseMetadata", + "description": "Metadata for the created document, so callers can skip a round-trip fetch." + }, + "token": { + "type": "string", + "description": "Permission token for the sync service, so callers can skip a round-trip fetch." } } }, @@ -11712,12 +11736,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, so callers can skip a round-trip fetch." + }, "teamId": { "type": ["string", "null"], "format": "uuid", @@ -11727,6 +11755,10 @@ "type": ["integer", "null"], "format": "int32", "description": "The task number assigned within the team." + }, + "token": { + "type": "string", + "description": "Permission token for the sync service, so callers can skip a round-trip fetch." } } }, 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 781a03dd78..70de47289c 100644 --- a/js/lexical-core/scripts/generate-golden.ts +++ b/js/lexical-core/scripts/generate-golden.ts @@ -16,10 +16,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 98deaa39b0..a791bfb48a 100644 --- a/rust/cloud-storage/Cargo.lock +++ b/rust/cloud-storage/Cargo.lock @@ -3760,6 +3760,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/main.rs b/rust/cloud-storage/document_storage_service/src/main.rs index d5317010ef..4b2160826b 100644 --- a/rust/cloud-storage/document_storage_service/src/main.rs +++ b/rust/cloud-storage/document_storage_service/src/main.rs @@ -673,7 +673,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 @@ -690,7 +689,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 0e8fa53c74..93c340a200 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:model-error-response", "dep:model_user", "dep:task_dedup", @@ -110,6 +111,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..5a1974500a 100644 --- a/rust/cloud-storage/documents/src/domain/models.rs +++ b/rust/cloud-storage/documents/src/domain/models.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use macro_user_id::user_id::MacroUserIdStr; use model::document::{DocumentMetadata, FileType}; +use model::document::response::DocumentResponseMetadata; use super::response::DocumentResponse; use model::sync_service::SyncServiceVersionID; @@ -403,8 +404,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 +441,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..0ee22929f2 --- /dev/null +++ b/rust/cloud-storage/documents/src/domain/permission_token.rs @@ -0,0 +1,34 @@ +//! Helpers for issuing document permission tokens (signed JWTs) used by the +//! sync service to authorize document access. + +use model::document::DocumentPermissionsToken; +use models_permissions::share_permission::access_level::AccessLevel; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 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; + + jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &DocumentPermissionsToken { + user_id, + document_id, + access_level, + exp: now + TOKEN_TTL_SECS, + iss: "document_storage_service".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 5cfe43912c..6b31eeacd7 100644 --- a/rust/cloud-storage/documents/src/inbound/axum_router.rs +++ b/rust/cloud-storage/documents/src/inbound/axum_router.rs @@ -126,6 +126,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). @@ -138,6 +140,7 @@ impl Clone for DocumentRouterState { task_dedup_service: self.task_dedup_service.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 3171faab3c..cb599bbc6c 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 @@ -6,8 +6,10 @@ use entity_access::domain::ports::EntityAccessService; use entity_access::inbound::axum_extractors::{ OptionalMacroUserTeamExtractor, ProjectBodyAccessLevelExtractor, }; +use model::document::DocumentPermissionsToken; use model_user::axum_extractor::MacroUserExtractor; -use models_permissions::share_permission::access_level::EditAccessLevel; +use models_permissions::share_permission::access_level::{AccessLevel, EditAccessLevel}; +use std::time::{SystemTime, UNIX_EPOCH}; use super::DocumentRouterState; use crate::domain::create::{MarkdownSubtype, NewDocumentMetadata, NewMarkdownTextDocument}; @@ -63,7 +65,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(), @@ -89,8 +91,32 @@ pub async fn create_task_handler< }, ); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &DocumentPermissionsToken { + user_id: Some(user_context.macro_user_id.as_ref().to_string()), + document_id: document_id.clone(), + access_level: AccessLevel::Edit, + exp: now + 3600, + iss: "document_storage_service".to_string(), + }, + &jsonwebtoken::EncodingKey::from_secret( + state.document_permission_jwt_secret.as_bytes(), + ), + ) + .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, })) From 3cb821aa0e7b867157b9f8c08e1581d78896eb30 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 11 Jun 2026 20:54:26 +0000 Subject: [PATCH 2/7] use the helper --- .../src/inbound/axum_router/create_task.rs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) 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 cb599bbc6c..5166c0136a 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 @@ -6,14 +6,13 @@ use entity_access::domain::ports::EntityAccessService; use entity_access::inbound::axum_extractors::{ OptionalMacroUserTeamExtractor, ProjectBodyAccessLevelExtractor, }; -use model::document::DocumentPermissionsToken; use model_user::axum_extractor::MacroUserExtractor; use models_permissions::share_permission::access_level::{AccessLevel, EditAccessLevel}; -use std::time::{SystemTime, UNIX_EPOCH}; 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; @@ -91,22 +90,11 @@ pub async fn create_task_handler< }, ); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as usize; - let token = jsonwebtoken::encode( - &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), - &DocumentPermissionsToken { - user_id: Some(user_context.macro_user_id.as_ref().to_string()), - document_id: document_id.clone(), - access_level: AccessLevel::Edit, - exp: now + 3600, - iss: "document_storage_service".to_string(), - }, - &jsonwebtoken::EncodingKey::from_secret( - state.document_permission_jwt_secret.as_bytes(), - ), + 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"); From f6e4896975703d2bef5ba3e812f4384889c7e6fa Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 11 Jun 2026 21:38:11 +0000 Subject: [PATCH 3/7] format --- js/app/packages/block-md/util/mdCreateCache.ts | 4 +++- js/app/packages/core/util/create.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/js/app/packages/block-md/util/mdCreateCache.ts b/js/app/packages/block-md/util/mdCreateCache.ts index b7c91dd815..9e84884e57 100644 --- a/js/app/packages/block-md/util/mdCreateCache.ts +++ b/js/app/packages/block-md/util/mdCreateCache.ts @@ -13,7 +13,9 @@ export function cacheMdCreate(documentId: string, entry: MdCreateCacheEntry) { cache.set(documentId, entry); } -export function consumeMdCreate(documentId: string): MdCreateCacheEntry | undefined { +export function consumeMdCreate( + documentId: string +): MdCreateCacheEntry | undefined { const entry = cache.get(documentId); cache.delete(documentId); return entry; diff --git a/js/app/packages/core/util/create.ts b/js/app/packages/core/util/create.ts index 46f56236c2..75728dafb6 100644 --- a/js/app/packages/core/util/create.ts +++ b/js/app/packages/core/util/create.ts @@ -1,5 +1,6 @@ import { DEFAULT_CHAT_NAME } from '@block-chat/definition'; import type { CodeFileExtension } from '@block-code/util/languageSupport'; +import { cacheMdCreate } from '@block-md/util/mdCreateCache'; import { PaywallKey, usePaywallState } from '@core/constant/PaywallState'; import { isNativeMobilePlatform } from '@core/mobile/isNativeMobilePlatform'; import { PROPERTY_OPTION_IDS, SYSTEM_PROPERTY_IDS } from '@property/constants'; @@ -18,7 +19,6 @@ 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 { cacheMdCreate } from '@block-md/util/mdCreateCache'; import { uploadToPresignedUrl } from '@service-storage/util/uploadToPresignedUrl'; import { err, ok } from 'neverthrow'; From f8d429bc6eca7cf247eb6cc6f4bfdcb09823d439 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 11 Jun 2026 21:46:08 +0000 Subject: [PATCH 4/7] format --- .../schemas/createMarkdownDocumentResponse.ts | 6 +++--- .../schemas/createMarkdownHandler200.ts | 6 +++--- .../generated/schemas/createTaskHandler200.ts | 4 ++-- .../generated/schemas/createTaskResponse.ts | 4 ++-- .../service-storage/generated/zod.ts | 14 +++++-------- .../service-storage/openapi.json | 20 +++++++++---------- 6 files changed, 25 insertions(+), 29 deletions(-) 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 f1d7f61680..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 @@ -10,10 +10,10 @@ 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, so callers can skip a round-trip fetch. */ + /** Metadata for the created document */ documentMetadata: DocumentResponseMetadata; - /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + /** 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 f5653f8d23..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 @@ -10,10 +10,10 @@ 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, so callers can skip a round-trip fetch. */ + /** Metadata for the created document */ documentMetadata: DocumentResponseMetadata; - /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + /** 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 c859998f28..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 @@ -15,12 +15,12 @@ import type { DocumentResponseMetadata } from './documentResponseMetadata'; export type CreateTaskHandler200 = { /** The document ID of the created task. */ documentId: string; - /** Metadata for the created document, so callers can skip a round-trip fetch. */ + /** 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; - /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + /** 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 3ddcbd7d36..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 @@ -15,12 +15,12 @@ import type { DocumentResponseMetadata } from './documentResponseMetadata'; export interface CreateTaskResponse { /** The document ID of the created task. */ documentId: string; - /** Metadata for the created document, so callers can skip a round-trip fetch. */ + /** 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; - /** Permission token for the sync service, so callers can skip a round-trip fetch. */ + /** 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 5b56634497..8430e81daf 100644 --- a/js/app/packages/service-clients/service-storage/generated/zod.ts +++ b/js/app/packages/service-clients/service-storage/generated/zod.ts @@ -3698,7 +3698,7 @@ 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() @@ -3764,7 +3764,7 @@ export const createMarkdownHandlerResponse = zod .union([ zod.null(), zod - .enum(['task']) + .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.' ), @@ -3777,9 +3777,7 @@ export const createMarkdownHandlerResponse = zod }), token: zod .string() - .describe( - 'Permission token for the sync service, so callers can skip a round-trip fetch.' - ), + .describe('A pre-generated permission token that you can use for SS'), }) .describe('Response for creating a markdown document.'); @@ -4046,7 +4044,7 @@ export const createTaskHandlerResponse = zod .union([ zod.null(), zod - .enum(['task']) + .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.' ), @@ -4067,9 +4065,7 @@ export const createTaskHandlerResponse = zod .describe('The task number assigned within the team.'), token: zod .string() - .describe( - 'Permission token for the sync service, so callers can skip a round-trip fetch.' - ), + .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 68837c3aca..420018caff 100644 --- a/js/app/packages/service-clients/service-storage/openapi.json +++ b/js/app/packages/service-clients/service-storage/openapi.json @@ -4554,15 +4554,15 @@ "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, so callers can skip a round-trip fetch." + "description": "Metadata for the created document" }, "token": { "type": "string", - "description": "Permission token for the sync service, so callers can skip a round-trip fetch." + "description": "A pre-generated permission token that you can use for SS" } } } @@ -4710,7 +4710,7 @@ }, "documentMetadata": { "$ref": "#/components/schemas/DocumentResponseMetadata", - "description": "Metadata for the created document, so callers can skip a round-trip fetch." + "description": "Metadata for the created document" }, "teamId": { "type": ["string", "null"], @@ -4724,7 +4724,7 @@ }, "token": { "type": "string", - "description": "Permission token for the sync service, so callers can skip a round-trip fetch." + "description": "A pre-generated permission token that you can use for SS" } } } @@ -11625,15 +11625,15 @@ "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, so callers can skip a round-trip fetch." + "description": "Metadata for the created document" }, "token": { "type": "string", - "description": "Permission token for the sync service, so callers can skip a round-trip fetch." + "description": "A pre-generated permission token that you can use for SS" } } }, @@ -11744,7 +11744,7 @@ }, "documentMetadata": { "$ref": "#/components/schemas/DocumentResponseMetadata", - "description": "Metadata for the created document, so callers can skip a round-trip fetch." + "description": "Metadata for the created document" }, "teamId": { "type": ["string", "null"], @@ -11758,7 +11758,7 @@ }, "token": { "type": "string", - "description": "Permission token for the sync service, so callers can skip a round-trip fetch." + "description": "A pre-generated permission token that you can use for SS" } } }, From cab95e61c67e61b5d656c7ec1b4999400edc4700 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Mon, 15 Jun 2026 21:28:50 +0000 Subject: [PATCH 5/7] pr feedback --- js/app/packages/block-md/definition.ts | 8 ++- .../packages/block-md/util/mdCreateCache.ts | 22 -------- js/app/packages/core/util/create.ts | 6 +-- .../documentLoad/documentLoadBundle.ts | 50 +++++++++++++++++++ .../queries/storage/documentLoad/keys.ts | 7 +++ .../create_permission_token.rs | 34 ++++--------- .../validate_permissions_token.rs | 3 +- .../documents/src/domain/models.rs | 4 ++ .../documents/src/domain/permission_token.rs | 12 +++-- .../documents/src/inbound/axum_router.rs | 1 + 10 files changed, 91 insertions(+), 56 deletions(-) delete mode 100644 js/app/packages/block-md/util/mdCreateCache.ts create mode 100644 js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts create mode 100644 js/app/packages/queries/storage/documentLoad/keys.ts diff --git a/js/app/packages/block-md/definition.ts b/js/app/packages/block-md/definition.ts index 4f852a9ce8..c2d00ceb4c 100644 --- a/js/app/packages/block-md/definition.ts +++ b/js/app/packages/block-md/definition.ts @@ -14,7 +14,9 @@ import { createSyncServiceSource } from '@service-sync/source'; import { err, ok } from 'neverthrow'; import MarkdownBlock from './component/Block'; import type { MarkdownRewriteOutput } from './signal/rewriteSignal'; -import { consumeMdCreate } from './util/mdCreateCache'; +import { queryClient } from '@queries/client'; +import { documentLoadKeys } from '@queries/storage/documentLoad/keys'; +import type { DocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; export const definition = defineBlock({ name: 'md', @@ -38,7 +40,9 @@ export const definition = defineBlock({ }); } - const pre = consumeMdCreate(documentId); + const pre = queryClient.getQueryData( + documentLoadKeys.bundle(documentId).queryKey + ); let token: string; let documentMetadata: DocumentMetadata; diff --git a/js/app/packages/block-md/util/mdCreateCache.ts b/js/app/packages/block-md/util/mdCreateCache.ts deleted file mode 100644 index 9e84884e57..0000000000 --- a/js/app/packages/block-md/util/mdCreateCache.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { AccessLevel } from '@service-storage/generated/schemas/accessLevel'; -import type { DocumentMetadata } from '@service-storage/generated/schemas/documentMetadata'; - -export type MdCreateCacheEntry = { - documentMetadata: DocumentMetadata; - userAccessLevel: AccessLevel; - token: string; -}; - -const cache = new Map(); - -export function cacheMdCreate(documentId: string, entry: MdCreateCacheEntry) { - cache.set(documentId, entry); -} - -export function consumeMdCreate( - documentId: string -): MdCreateCacheEntry | undefined { - const entry = cache.get(documentId); - cache.delete(documentId); - return entry; -} diff --git a/js/app/packages/core/util/create.ts b/js/app/packages/core/util/create.ts index 6a7c2a2067..10604ed79e 100644 --- a/js/app/packages/core/util/create.ts +++ b/js/app/packages/core/util/create.ts @@ -1,6 +1,6 @@ import { DEFAULT_CHAT_NAME } from '@block-chat/definition'; import type { CodeFileExtension } from '@block-code/util/languageSupport'; -import { cacheMdCreate } from '@block-md/util/mdCreateCache'; +import { seedDocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; import { PaywallKey, usePaywallState } from '@core/constant/PaywallState'; import { isNativeMobilePlatform } from '@core/mobile/isNativeMobilePlatform'; import { PROPERTY_OPTION_IDS, SYSTEM_PROPERTY_IDS } from '@property/constants'; @@ -56,7 +56,7 @@ export async function createMarkdownFile( const { documentId, documentMetadata, token } = result.value; - cacheMdCreate(documentId, { + seedDocumentLoadBundle(documentId, { documentMetadata, userAccessLevel: AccessLevel.owner, token, @@ -118,7 +118,7 @@ export async function createTask( const { documentId, documentMetadata, token } = result.value; - cacheMdCreate(documentId, { + seedDocumentLoadBundle(documentId, { documentMetadata, userAccessLevel: AccessLevel.owner, token, 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..812d8b569a --- /dev/null +++ b/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts @@ -0,0 +1,50 @@ +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/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/documents/src/domain/models.rs b/rust/cloud-storage/documents/src/domain/models.rs index 5a1974500a..b3718a0bfc 100644 --- a/rust/cloud-storage/documents/src/domain/models.rs +++ b/rust/cloud-storage/documents/src/domain/models.rs @@ -44,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. diff --git a/rust/cloud-storage/documents/src/domain/permission_token.rs b/rust/cloud-storage/documents/src/domain/permission_token.rs index 0ee22929f2..d76170ba8d 100644 --- a/rust/cloud-storage/documents/src/domain/permission_token.rs +++ b/rust/cloud-storage/documents/src/domain/permission_token.rs @@ -1,10 +1,14 @@ //! 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; @@ -14,21 +18,21 @@ pub fn encode_permission_token( document_id: String, access_level: AccessLevel, jwt_secret: &str, -) -> Result { +) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as usize; - jsonwebtoken::encode( + Ok(jsonwebtoken::encode( &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), &DocumentPermissionsToken { user_id, document_id, access_level, exp: now + TOKEN_TTL_SECS, - iss: "document_storage_service".to_string(), + 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 0b9f21d512..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() { From f5c7752f8ada3dc908a2b2e651cd30f21c76046e Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Tue, 16 Jun 2026 19:35:53 +0000 Subject: [PATCH 6/7] run formatter --- js/app/packages/block-md/definition.ts | 6 +++--- js/app/packages/core/util/create.ts | 2 +- .../queries/storage/documentLoad/documentLoadBundle.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/js/app/packages/block-md/definition.ts b/js/app/packages/block-md/definition.ts index c2d00ceb4c..26e1607c06 100644 --- a/js/app/packages/block-md/definition.ts +++ b/js/app/packages/block-md/definition.ts @@ -5,7 +5,10 @@ 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'; @@ -14,9 +17,6 @@ import { createSyncServiceSource } from '@service-sync/source'; import { err, ok } from 'neverthrow'; import MarkdownBlock from './component/Block'; import type { MarkdownRewriteOutput } from './signal/rewriteSignal'; -import { queryClient } from '@queries/client'; -import { documentLoadKeys } from '@queries/storage/documentLoad/keys'; -import type { DocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; export const definition = defineBlock({ name: 'md', diff --git a/js/app/packages/core/util/create.ts b/js/app/packages/core/util/create.ts index 6e250eb1e9..0625c478b1 100644 --- a/js/app/packages/core/util/create.ts +++ b/js/app/packages/core/util/create.ts @@ -1,12 +1,12 @@ import { DEFAULT_CHAT_NAME } from '@block-chat/definition'; import type { CodeFileExtension } from '@block-code/util/languageSupport'; -import { seedDocumentLoadBundle } from '@queries/storage/documentLoad/documentLoadBundle'; import { PaywallKey, usePaywallState } from '@core/constant/PaywallState'; import { PROPERTY_OPTION_IDS, SYSTEM_PROPERTY_IDS } from '@property/constants'; 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'; diff --git a/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts b/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts index 812d8b569a..c3d70b77fa 100644 --- a/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts +++ b/js/app/packages/queries/storage/documentLoad/documentLoadBundle.ts @@ -24,7 +24,8 @@ async function fetchDocumentLoadBundle( ]); if (maybeToken.isErr()) throw new Error('UNAUTHORIZED'); - if (maybeDocument.isErr()) throw new Error('Failed to fetch document metadata'); + if (maybeDocument.isErr()) + throw new Error('Failed to fetch document metadata'); return { documentMetadata: maybeDocument.value.documentMetadata, @@ -46,5 +47,8 @@ export function seedDocumentLoadBundle( documentId: string, bundle: DocumentLoadBundle ) { - queryClient.setQueryData(documentLoadKeys.bundle(documentId).queryKey, bundle); + queryClient.setQueryData( + documentLoadKeys.bundle(documentId).queryKey, + bundle + ); } From 90a0d03201afd844a6e1646dcbe62138c7b4e552 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Tue, 16 Jun 2026 19:42:56 +0000 Subject: [PATCH 7/7] run formatter --- rust/cloud-storage/documents/src/domain/models.rs | 2 +- rust/sync-service/src/websocket.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/rust/cloud-storage/documents/src/domain/models.rs b/rust/cloud-storage/documents/src/domain/models.rs index b3718a0bfc..e9d790286e 100644 --- a/rust/cloud-storage/documents/src/domain/models.rs +++ b/rust/cloud-storage/documents/src/domain/models.rs @@ -2,8 +2,8 @@ use chrono::{DateTime, Utc}; use macro_user_id::user_id::MacroUserIdStr; -use model::document::{DocumentMetadata, FileType}; use model::document::response::DocumentResponseMetadata; +use model::document::{DocumentMetadata, FileType}; use super::response::DocumentResponse; use model::sync_service::SyncServiceVersionID; 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;