Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/api.collab.token.ts
T

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