143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
/**
|
|
* Realtime-collab JWT issuer (Phase 05 Task #59).
|
|
*
|
|
* Issues a short-lived (15 min) JWT bound to `{ userId, tenantId,
|
|
* resourceType, resourceId }` for the requesting browser tab. Hocuspocus
|
|
* verifies this token in `onAuthenticate` against the same shared
|
|
* `HOCUSPOCUS_AUTH_SECRET` — only callers whose token matches the
|
|
* documentName triple are admitted.
|
|
*
|
|
* Activation:
|
|
* - `HOCUSPOCUS_AUTH_SECRET` env must be set (else 503)
|
|
* - `realtime-collab-enabled` feature-flag must evaluate true for the
|
|
* tenant (else 403 — graceful degradation, editor falls back to
|
|
* single-user mode)
|
|
*
|
|
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
|
|
*/
|
|
import type { ActionFunctionArgs } from "react-router";
|
|
import jwt from "jsonwebtoken";
|
|
|
|
import { getUserInfo } from "~/utils/session.server";
|
|
import { evaluateFlagForContext } from "~/utils/services/.server/featureFlagEvaluator.server";
|
|
import { getTenantUserType } from "~/utils/db/tenants.db.server";
|
|
import { getRowById } from "~/utils/db/entities/rows.db.server";
|
|
import { getUserRowPermission } from "~/utils/helpers/.server/PermissionsService";
|
|
import { knowledgeBaseArticleBelongsToTenant } from "~/modules/knowledgeBase/db/kbArticles.db.server";
|
|
|
|
const TOKEN_TTL_SECONDS = 60 * 15; // 15 minutes — matches spec.
|
|
const TOKEN_REFRESH_SKEW_SECONDS = 60;
|
|
const TOKEN_REFRESH_AFTER_SECONDS = TOKEN_TTL_SECONDS - TOKEN_REFRESH_SKEW_SECONDS;
|
|
|
|
type CollabTokenRequest = {
|
|
tenantId?: unknown;
|
|
resourceType?: unknown;
|
|
resourceId?: unknown;
|
|
};
|
|
|
|
function isNonEmptyString(value: unknown): value is string {
|
|
return typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
}
|
|
|
|
function isDocumentNamePart(value: unknown, options: { allowColon?: boolean } = {}): value is string {
|
|
return isNonEmptyString(value) && (options.allowColon === true || !value.includes(":"));
|
|
}
|
|
|
|
/**
|
|
* Resource-level authorization. Tenant membership alone is NOT sufficient:
|
|
* - "row" entities carry per-record ACLs (RowPermission), so a member could
|
|
* otherwise mint a collab token for a row they cannot read. Require at least
|
|
* read access to the specific row, scoped to the tenant.
|
|
* - "kb-article" content has no per-article ACL but must belong to the tenant,
|
|
* so a token can't be minted for a global/other-tenant article id.
|
|
* - Unknown resource types fail closed.
|
|
*/
|
|
async function isCollabResourceAuthorized(args: { userId: string; tenantId: string; resourceType: string; resourceId: string }): Promise<boolean> {
|
|
const { userId, tenantId, resourceType, resourceId } = args;
|
|
switch (resourceType) {
|
|
case "row": {
|
|
const row = await getRowById(resourceId, { tenantId });
|
|
if (!row) {
|
|
return false;
|
|
}
|
|
const permission = await getUserRowPermission(row, tenantId, userId);
|
|
return permission.canRead === true || permission.canUpdate === true;
|
|
}
|
|
case "kb-article":
|
|
return await knowledgeBaseArticleBelongsToTenant(resourceId, tenantId);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function action({ request }: ActionFunctionArgs) {
|
|
if (request.method !== "POST") {
|
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
}
|
|
|
|
const secret = process.env.HOCUSPOCUS_AUTH_SECRET?.trim();
|
|
if (!secret) {
|
|
return Response.json({ error: "collab_disabled", reason: "missing-server-secret" }, { status: 503 });
|
|
}
|
|
|
|
const session = await getUserInfo(request);
|
|
if (!session.userId) {
|
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
let body: CollabTokenRequest;
|
|
try {
|
|
body = (await request.json()) as CollabTokenRequest;
|
|
} catch {
|
|
return Response.json({ error: "bad_request", reason: "invalid-json" }, { status: 400 });
|
|
}
|
|
if (!isDocumentNamePart(body.tenantId) || !isDocumentNamePart(body.resourceType) || !isDocumentNamePart(body.resourceId, { allowColon: true })) {
|
|
return Response.json({ error: "bad_request", reason: "missing-fields" }, { status: 400 });
|
|
}
|
|
|
|
const membership = await getTenantUserType(session.userId, body.tenantId);
|
|
if (!membership) {
|
|
return Response.json({ error: "forbidden", reason: "tenant-membership-required" }, { status: 403 });
|
|
}
|
|
|
|
// Per-tenant feature-flag gate. `realtime-collab-enabled` defaults to
|
|
// disabled when the flag row does not exist, so self-hosted installs that
|
|
// do not seed the flag stay on single-user mode automatically.
|
|
const flag = await evaluateFlagForContext("realtime-collab-enabled", {
|
|
flagName: "realtime-collab-enabled",
|
|
tenantId: body.tenantId,
|
|
userId: session.userId,
|
|
});
|
|
if (!flag.enabled) {
|
|
return Response.json({ error: "feature_disabled", reason: flag.reason }, { status: 403 });
|
|
}
|
|
|
|
const authorized = await isCollabResourceAuthorized({
|
|
userId: session.userId,
|
|
tenantId: body.tenantId,
|
|
resourceType: body.resourceType,
|
|
resourceId: body.resourceId,
|
|
});
|
|
if (!authorized) {
|
|
return Response.json({ error: "forbidden", reason: "resource-access-denied" }, { status: 403 });
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{
|
|
userId: session.userId,
|
|
tenantId: body.tenantId,
|
|
resourceType: body.resourceType,
|
|
resourceId: body.resourceId,
|
|
},
|
|
secret,
|
|
{ algorithm: "HS256", expiresIn: TOKEN_TTL_SECONDS },
|
|
);
|
|
|
|
return Response.json({
|
|
token,
|
|
expiresIn: TOKEN_TTL_SECONDS,
|
|
refreshAfter: TOKEN_REFRESH_AFTER_SECONDS,
|
|
documentName: `${body.tenantId}:${body.resourceType}:${body.resourceId}`,
|
|
});
|
|
}
|