diff --git a/apps/app/src/components/collaboration/CollaborativeDocContext.tsx b/apps/app/src/components/collaboration/CollaborativeDocContext.tsx index 596cfb545..edc4706c1 100644 --- a/apps/app/src/components/collaboration/CollaborativeDocContext.tsx +++ b/apps/app/src/components/collaboration/CollaborativeDocContext.tsx @@ -32,6 +32,8 @@ const CollaborativeDocContext = interface CollaborativeDocProviderProps { /** Unique document identifier for collaboration */ docId: string; + /** JWT token for Tiptap Cloud authentication */ + token: string | null; /** User's display name for collaboration cursors */ userName?: string; /** Loading state to show while the collaboration provider initializes */ @@ -46,7 +48,7 @@ interface CollaborativeDocProviderProps { * * @example * ```tsx - * }> + * }> * * * @@ -54,6 +56,7 @@ interface CollaborativeDocProviderProps { */ export function CollaborativeDocProvider({ docId, + token, userName = 'Anonymous', fallback = null, children, @@ -61,6 +64,7 @@ export function CollaborativeDocProvider({ const { ydoc, provider, status, isSynced, user } = useTiptapCollab({ docId, enabled: true, + token, userName, }); diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx index 9da5ad0bb..a235ef671 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx @@ -240,6 +240,13 @@ export function ProposalEditor({ draftRef, ]); + // -- Collaboration token --------------------------------------------------- + + const { data: collabTokenData } = trpc.decision.getCollabToken.useQuery( + { proposalProfileId: proposal.profileId }, + { staleTime: 1000 * 60 * 60 }, + ); + // -- Render ---------------------------------------------------------------- const userName = user.profile?.name ?? t('Anonymous'); @@ -247,6 +254,7 @@ export function ProposalEditor({ return ( } > diff --git a/apps/app/src/hooks/useTiptapCollab.ts b/apps/app/src/hooks/useTiptapCollab.ts index 22ac217b0..01e87d080 100644 --- a/apps/app/src/hooks/useTiptapCollab.ts +++ b/apps/app/src/hooks/useTiptapCollab.ts @@ -15,6 +15,8 @@ export interface CollabUser { export interface UseTiptapCollabOptions { docId: string | null; enabled?: boolean; + /** JWT token for Tiptap Cloud authentication */ + token: string | null; /** User's display name for the collaboration cursor */ userName?: string; } @@ -33,6 +35,7 @@ export interface UseTiptapCollabReturn { export function useTiptapCollab({ docId, enabled = true, + token, userName = 'Anonymous', }: UseTiptapCollabOptions): UseTiptapCollabReturn { const [status, setStatus] = useState('connecting'); @@ -48,7 +51,7 @@ export function useTiptapCollab({ }, [userName]); useEffect(() => { - if (!enabled || !docId) { + if (!enabled || !docId || !token) { setStatus('disconnected'); return; } @@ -63,7 +66,7 @@ export function useTiptapCollab({ const newProvider = new TiptapCollabProvider({ name: docId, appId, - token: 'notoken', // TODO: proper JWT auth + token, document: ydoc, onConnect: () => { setStatus('connected'); @@ -82,7 +85,7 @@ export function useTiptapCollab({ newProvider.destroy(); setProvider(null); }; - }, [docId, enabled, ydoc]); + }, [docId, enabled, token, ydoc]); // Update awareness when user info changes useEffect(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac4fc4122..3995e875b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1319,6 +1319,9 @@ importers: services/collab: dependencies: + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 ky: specifier: ^1.8.1 version: 1.14.2 @@ -1326,6 +1329,9 @@ importers: '@op/typescript-config': specifier: workspace:* version: link:../../configs/typescript-config + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 services/db: dependencies: diff --git a/services/api/src/routers/decision/proposals/getCollabToken.ts b/services/api/src/routers/decision/proposals/getCollabToken.ts new file mode 100644 index 000000000..f1147f1fb --- /dev/null +++ b/services/api/src/routers/decision/proposals/getCollabToken.ts @@ -0,0 +1,73 @@ +import { generateCollabToken } from '@op/collab/server'; +import { getProfileAccessUser, parseProposalData } from '@op/common'; +import { db } from '@op/db/client'; +import { TRPCError } from '@trpc/server'; +import { checkPermission, permission } from 'access-zones'; +import { z } from 'zod'; + +import { commonAuthedProcedure, router } from '../../../trpcFactory'; + +export const getCollabTokenRouter = router({ + getCollabToken: commonAuthedProcedure({ + rateLimit: { windowSize: 60, maxRequests: 30 }, + }) + .input( + z.object({ + proposalProfileId: z.uuid(), + }), + ) + .output(z.object({ token: z.string() })) + .query(async ({ ctx, input }) => { + const { user } = ctx; + const { proposalProfileId } = input; + + // Look up the proposal by its profile ID + const proposal = await db.query.proposals.findFirst({ + where: { profileId: proposalProfileId }, + columns: { + id: true, + proposalData: true, + profileId: true, + }, + }); + + if (!proposal) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Proposal not found', + }); + } + + // Verify user has access to the proposal's profile + const profileAccessUser = await getProfileAccessUser({ + user, + profileId: proposalProfileId, + }); + + const hasAccess = checkPermission( + { profile: permission.READ }, + profileAccessUser?.roles ?? [], + ); + + if (!hasAccess) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have access to this proposal', + }); + } + + // Extract the collaboration doc ID from the proposal data + const { collaborationDocId } = parseProposalData(proposal.proposalData); + + if (!collaborationDocId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Proposal does not have a collaboration document', + }); + } + + const token = generateCollabToken(user.id, [collaborationDocId]); + + return { token }; + }), +}); diff --git a/services/api/src/routers/decision/proposals/index.ts b/services/api/src/routers/decision/proposals/index.ts index 0d99bd11a..6832020a8 100644 --- a/services/api/src/routers/decision/proposals/index.ts +++ b/services/api/src/routers/decision/proposals/index.ts @@ -4,6 +4,7 @@ import { createProposalRouter } from './create'; import { deleteProposalRouter } from './delete'; import { exportProposalsRouter } from './export'; import { getProposalRouter } from './get'; +import { getCollabTokenRouter } from './getCollabToken'; import { getExportStatusRouter } from './getExportStatus'; import { listProposalsRouter } from './list'; import { submitProposalRouter } from './submit'; @@ -13,6 +14,7 @@ export const proposalsRouter = mergeRouters( acceptProposalInviteRouter, createProposalRouter, getProposalRouter, + getCollabTokenRouter, listProposalsRouter, submitProposalRouter, updateProposalRouter, diff --git a/services/collab/package.json b/services/collab/package.json index d3c34cced..78c9fc8eb 100644 --- a/services/collab/package.json +++ b/services/collab/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./server": "./src/server/index.ts", "./testing": "./__mocks__/index.ts" }, "main": "./src/index.ts", @@ -14,9 +15,11 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "jsonwebtoken": "^9.0.2", "ky": "^1.8.1" }, "devDependencies": { - "@op/typescript-config": "workspace:*" + "@op/typescript-config": "workspace:*", + "@types/jsonwebtoken": "^9.0.10" } } diff --git a/services/collab/src/server/index.ts b/services/collab/src/server/index.ts new file mode 100644 index 000000000..09e397986 --- /dev/null +++ b/services/collab/src/server/index.ts @@ -0,0 +1 @@ +export { generateCollabToken } from './token'; diff --git a/services/collab/src/server/token.ts b/services/collab/src/server/token.ts new file mode 100644 index 000000000..1cd0bd886 --- /dev/null +++ b/services/collab/src/server/token.ts @@ -0,0 +1,41 @@ +import jwt from 'jsonwebtoken'; + +function getTiptapSecret(): string { + const secret = process.env.TIPTAP_SECRET; + + if (!secret) { + throw new Error('Missing required environment variable: TIPTAP_SECRET'); + } + + return secret; +} + +/** + * Generate a Tiptap Cloud collaboration JWT for a user. + * + * The token restricts the user to only the specified document names. + * + * @see https://tiptap.dev/docs/collaboration/getting-started/authenticate + * + * @param userId - The authenticated user's identifier + * @param allowedDocumentNames - Document names the user may access (supports trailing wildcards) + * @returns Signed JWT string + */ +export function generateCollabToken( + userId: string, + allowedDocumentNames: string[], +): string { + const secret = getTiptapSecret(); + + return jwt.sign( + { + sub: userId, + allowedDocumentNames, + }, + secret, + { + algorithm: 'HS256', + expiresIn: 60 * 60 * 24, // 24 hours + }, + ); +}