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
+ },
+ );
+}