Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,82 @@
|
||||
# Hocuspocus realtime-collaboration server image.
|
||||
#
|
||||
# Built from the monorepo root (build context = repository root) so the
|
||||
# Docker layer cache can reuse pnpm install layers shared with the api /
|
||||
# worker / web images. Final image is `node:20-bookworm-slim` running
|
||||
# `apps/hocuspocus/dist/index.js` on port 1234.
|
||||
FROM --platform=linux/amd64 node:20-bookworm-slim AS base
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
ENV CI="true"
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /myapp
|
||||
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
ADD prisma prisma
|
||||
ADD apps apps
|
||||
ADD packages packages
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
FROM base AS production-deps
|
||||
WORKDIR /myapp
|
||||
COPY --from=deps /myapp/node_modules /myapp/node_modules
|
||||
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
ADD apps apps
|
||||
ADD packages packages
|
||||
RUN pnpm prune --prod
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /myapp
|
||||
COPY --from=deps /myapp/node_modules /myapp/node_modules
|
||||
COPY --from=deps /myapp/apps /myapp/apps
|
||||
COPY --from=deps /myapp/packages /myapp/packages
|
||||
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
ADD prisma prisma
|
||||
ADD prisma.config.ts prisma.config.ts
|
||||
ADD tsconfig.json tsconfig.json
|
||||
ADD packages/db packages/db
|
||||
RUN pnpm exec prisma generate
|
||||
RUN pnpm --filter @toolctrl/db build
|
||||
ADD . .
|
||||
RUN pnpm --filter @toolctrl/hocuspocus build
|
||||
|
||||
FROM base AS hocuspocus
|
||||
ARG APP_REVISION=unknown
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOCUSPOCUS_PORT="1234"
|
||||
ENV HOCUSPOCUS_HOST="0.0.0.0"
|
||||
ENV APP_REVISION="${APP_REVISION}"
|
||||
LABEL org.opencontainers.image.revision="${APP_REVISION}"
|
||||
EXPOSE 1234
|
||||
|
||||
WORKDIR /myapp
|
||||
COPY --from=production-deps /myapp/node_modules /myapp/node_modules
|
||||
COPY --from=build /myapp/package.json /myapp/package.json
|
||||
COPY --from=build /myapp/pnpm-lock.yaml /myapp/pnpm-lock.yaml
|
||||
COPY --from=build /myapp/pnpm-workspace.yaml /myapp/pnpm-workspace.yaml
|
||||
COPY --from=build /myapp/tsconfig.json /myapp/tsconfig.json
|
||||
COPY --from=build /myapp/apps/hocuspocus/package.json /myapp/apps/hocuspocus/package.json
|
||||
COPY --from=deps /myapp/apps/hocuspocus/node_modules /myapp/apps/hocuspocus/node_modules
|
||||
COPY --from=build /myapp/apps/hocuspocus/dist /myapp/apps/hocuspocus/dist
|
||||
COPY --from=build /myapp/packages/db /myapp/packages/db
|
||||
COPY --from=deps /myapp/packages/db/node_modules /myapp/packages/db/node_modules
|
||||
COPY --from=build /myapp/packages/db/generated/prisma/client /myapp/packages/db/generated/prisma/client
|
||||
COPY --from=build /myapp/packages/logger /myapp/packages/logger
|
||||
COPY --from=deps /myapp/packages/logger/node_modules /myapp/packages/logger/node_modules
|
||||
COPY --from=build /myapp/prisma /myapp/prisma
|
||||
COPY --from=build /myapp/prisma.config.ts /myapp/prisma.config.ts
|
||||
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('net').connect(${HOCUSPOCUS_PORT:-1234}, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"
|
||||
|
||||
CMD ["node", "apps/hocuspocus/dist/index.js"]
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@toolctrl/hocuspocus",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "node scripts/build.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "4.0.0",
|
||||
"@hocuspocus/extension-logger": "4.0.0",
|
||||
"@hocuspocus/server": "4.0.0",
|
||||
"@toolctrl/db": "workspace:*",
|
||||
"@toolctrl/logger": "workspace:*",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "13.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^25.0.0",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { builtinModules } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const entryPoint = path.join(appDir, "src/index.ts");
|
||||
const outputFile = path.join(appDir, "dist/index.js");
|
||||
const nodeBuiltins = new Set([...builtinModules, ...builtinModules.map((moduleName) => `node:${moduleName}`)]);
|
||||
|
||||
const externalizeRuntimeDependencies = {
|
||||
name: "externalize-runtime-dependencies",
|
||||
setup(buildContext) {
|
||||
buildContext.onResolve({ filter: /.*/ }, (args) => {
|
||||
if (nodeBuiltins.has(args.path)) {
|
||||
return { path: args.path, external: true };
|
||||
}
|
||||
if (isExternalRuntimePackage(args.path)) {
|
||||
return { path: args.path, external: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
await mkdir(path.dirname(outputFile), { recursive: true });
|
||||
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile: outputFile,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node20",
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: 'import { createRequire as __toolctrlCreateRequire } from "node:module"; const require = __toolctrlCreateRequire(import.meta.url);',
|
||||
},
|
||||
plugins: [externalizeRuntimeDependencies],
|
||||
logLevel: "warning",
|
||||
});
|
||||
|
||||
function isBarePackageSpecifier(specifier) {
|
||||
return !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("#");
|
||||
}
|
||||
|
||||
function isExternalRuntimePackage(specifier) {
|
||||
if (!isBarePackageSpecifier(specifier)) {
|
||||
return false;
|
||||
}
|
||||
// Hocuspocus + Y.js + Prisma + native pino are required at runtime
|
||||
// and never bundled — they ship as part of node_modules.
|
||||
return (
|
||||
specifier.startsWith("@hocuspocus/") ||
|
||||
specifier === "yjs" ||
|
||||
specifier.startsWith("yjs/") ||
|
||||
specifier === "y-protocols" ||
|
||||
specifier.startsWith("y-protocols/") ||
|
||||
specifier === "jsonwebtoken" ||
|
||||
specifier.startsWith("jsonwebtoken/") ||
|
||||
specifier === "@prisma/client" ||
|
||||
specifier.startsWith("@prisma/client/") ||
|
||||
specifier === "@prisma/adapter-pg" ||
|
||||
specifier.startsWith("@prisma/adapter-pg/") ||
|
||||
specifier === "pino" ||
|
||||
specifier.startsWith("pino/") ||
|
||||
specifier === "ws" ||
|
||||
specifier === "lib0" ||
|
||||
specifier.startsWith("lib0/")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getHocuspocusRuntimeConfig } from "./config.js";
|
||||
|
||||
describe("getHocuspocusRuntimeConfig", () => {
|
||||
it("uses production-safe defaults when optional env is absent", () => {
|
||||
expect(getHocuspocusRuntimeConfig({})).toEqual({
|
||||
port: 1234,
|
||||
host: "0.0.0.0",
|
||||
authSecret: null,
|
||||
maxPayloadBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers Hocuspocus-specific env over generic process settings", () => {
|
||||
expect(
|
||||
getHocuspocusRuntimeConfig({
|
||||
HOCUSPOCUS_PORT: "4321",
|
||||
HOCUSPOCUS_HOST: " 127.0.0.1 ",
|
||||
HOCUSPOCUS_AUTH_SECRET: " hocuspocus-secret ",
|
||||
HOCUSPOCUS_SECRET: "legacy-secret",
|
||||
HOCUSPOCUS_MAX_PAYLOAD_BYTES: "2048",
|
||||
PORT: "9999",
|
||||
})
|
||||
).toEqual({
|
||||
port: 4321,
|
||||
host: "127.0.0.1",
|
||||
authSecret: "hocuspocus-secret",
|
||||
maxPayloadBytes: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy auth secret and generic port", () => {
|
||||
expect(
|
||||
getHocuspocusRuntimeConfig({
|
||||
PORT: "2345",
|
||||
HOCUSPOCUS_SECRET: " legacy-secret ",
|
||||
})
|
||||
).toEqual({
|
||||
port: 2345,
|
||||
host: "0.0.0.0",
|
||||
authSecret: "legacy-secret",
|
||||
maxPayloadBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blank host and blank secrets as absent", () => {
|
||||
expect(
|
||||
getHocuspocusRuntimeConfig({
|
||||
HOCUSPOCUS_HOST: " ",
|
||||
HOCUSPOCUS_AUTH_SECRET: " ",
|
||||
HOCUSPOCUS_SECRET: " ",
|
||||
})
|
||||
).toMatchObject({
|
||||
host: "0.0.0.0",
|
||||
authSecret: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Runtime configuration for the Hocuspocus realtime-collaboration server.
|
||||
*
|
||||
* Reads from env on boot; never references env after init so unit tests can
|
||||
* override via the optional argument.
|
||||
*/
|
||||
export type HocuspocusRuntimeConfig = {
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
readonly authSecret: string | null;
|
||||
readonly maxPayloadBytes: number;
|
||||
};
|
||||
|
||||
export function getHocuspocusRuntimeConfig(env: NodeJS.ProcessEnv = process.env): HocuspocusRuntimeConfig {
|
||||
const port = Number(env.HOCUSPOCUS_PORT ?? env.PORT ?? 1234);
|
||||
const host = env.HOCUSPOCUS_HOST?.trim() || "0.0.0.0";
|
||||
const authSecret = env.HOCUSPOCUS_AUTH_SECRET?.trim() || env.HOCUSPOCUS_SECRET?.trim() || null;
|
||||
const maxPayloadBytes = Number(env.HOCUSPOCUS_MAX_PAYLOAD_BYTES ?? 10 * 1024 * 1024);
|
||||
return Object.freeze({ port, host, authSecret, maxPayloadBytes });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { parseDocumentName, verifyCollabToken } from "./auth.js";
|
||||
|
||||
const SECRET = "test-secret-please-rotate-in-prod-min-32-bytes";
|
||||
|
||||
function signToken(claims: Record<string, unknown>, expiresInSeconds = 60): string {
|
||||
return jwt.sign(claims, SECRET, { algorithm: "HS256", expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
describe("parseDocumentName", () => {
|
||||
it("splits a well-formed name into three parts", () => {
|
||||
expect(parseDocumentName("tenant_abc:kb-article:art_123")).toEqual({
|
||||
tenantId: "tenant_abc",
|
||||
resourceType: "kb-article",
|
||||
resourceId: "art_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves colons inside the resourceId", () => {
|
||||
expect(parseDocumentName("t:kb:foo:bar:baz")).toEqual({
|
||||
tenantId: "t",
|
||||
resourceType: "kb",
|
||||
resourceId: "foo:bar:baz",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed names", () => {
|
||||
expect(parseDocumentName("")).toBeNull();
|
||||
expect(parseDocumentName("tenant_only")).toBeNull();
|
||||
expect(parseDocumentName("tenant:type")).toBeNull();
|
||||
expect(parseDocumentName(":kb:art")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyCollabToken", () => {
|
||||
const docName = "tenant_abc:kb-article:art_123";
|
||||
|
||||
it("accepts a valid token whose claims match the document name", () => {
|
||||
const token = signToken({ userId: "user_1", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
|
||||
const ctx = verifyCollabToken(token, docName, { secret: SECRET });
|
||||
expect(ctx).toEqual({ userId: "user_1", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
|
||||
});
|
||||
|
||||
it("accepts `sub` as a userId fallback", () => {
|
||||
const token = signToken({ sub: "user_2", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })?.userId).toBe("user_2");
|
||||
});
|
||||
|
||||
it("refuses tokens signed with a different secret", () => {
|
||||
const token = jwt.sign({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" }, "other-secret", { algorithm: "HS256" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses expired tokens", () => {
|
||||
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" }, -120);
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET, clockTolerance: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses cross-tenant token reuse", () => {
|
||||
const token = signToken({ userId: "u", tenantId: "OTHER", resourceType: "kb-article", resourceId: "art_123" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses token whose resourceType differs from the document", () => {
|
||||
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "crm-note", resourceId: "art_123" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses token whose resourceId differs from the document", () => {
|
||||
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "other" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses tokens with no userId / sub claim", () => {
|
||||
const token = signToken({ tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
|
||||
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses missing or empty token", () => {
|
||||
expect(verifyCollabToken("", docName, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Hocuspocus authentication hook.
|
||||
*
|
||||
* Verifies a short-lived JWT issued by the web app's collab-token endpoint.
|
||||
* Claims:
|
||||
* { sub: userId, tenantId, resourceType, resourceId, exp }
|
||||
*
|
||||
* Refuses the connection (Hocuspocus closes the WebSocket with a 403-style
|
||||
* close-code) when:
|
||||
* - HOCUSPOCUS_AUTH_SECRET is unset on the server side
|
||||
* - the JWT is missing, malformed, expired, or signed with the wrong secret
|
||||
* - the JWT's tenantId / resourceType / resourceId do not match the
|
||||
* documentName (`${tenantId}:${resourceType}:${resourceId}`) the client
|
||||
* is requesting — defence-in-depth against cross-tenant doc access.
|
||||
*
|
||||
* Returns a context object stored on the Hocuspocus connection: persistence
|
||||
* + permission-revoke handlers downstream pull tenantId from this context.
|
||||
*
|
||||
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
|
||||
*/
|
||||
import { Extension, onAuthenticatePayload } from "@hocuspocus/server";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
export type HocuspocusConnectionContext = {
|
||||
readonly userId: string;
|
||||
readonly tenantId: string;
|
||||
readonly resourceType: string;
|
||||
readonly resourceId: string;
|
||||
};
|
||||
|
||||
export type AuthExtensionOptions = {
|
||||
readonly secret: string;
|
||||
/** Optional clock-skew tolerance in seconds; defaults to 30s. */
|
||||
readonly clockTolerance?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse `tenantId:resourceType:resourceId` document-name into its parts.
|
||||
* Returns `null` for malformed names — caller refuses the connection.
|
||||
*/
|
||||
export function parseDocumentName(documentName: string): {
|
||||
tenantId: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
} | null {
|
||||
if (typeof documentName !== "string" || documentName.length === 0) return null;
|
||||
// resourceId may itself contain `:` (e.g. namespaced slugs); split into 3 parts only.
|
||||
const parts = documentName.split(":");
|
||||
if (parts.length < 3) return null;
|
||||
const [tenantId, resourceType, ...rest] = parts;
|
||||
const resourceId = rest.join(":");
|
||||
if (!tenantId || !resourceType || !resourceId) return null;
|
||||
return { tenantId, resourceType, resourceId };
|
||||
}
|
||||
|
||||
type TokenClaims = {
|
||||
sub?: string;
|
||||
userId?: string;
|
||||
tenantId?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify the JWT + check claim/document name parity. Returns the connection
|
||||
* context on success or `null` on any failure (caller throws to refuse).
|
||||
*/
|
||||
export function verifyCollabToken(
|
||||
token: string,
|
||||
documentName: string,
|
||||
options: AuthExtensionOptions,
|
||||
): HocuspocusConnectionContext | null {
|
||||
if (!token) return null;
|
||||
const parsed = parseDocumentName(documentName);
|
||||
if (!parsed) return null;
|
||||
|
||||
let claims: TokenClaims;
|
||||
try {
|
||||
claims = jwt.verify(token, options.secret, {
|
||||
algorithms: ["HS256"],
|
||||
clockTolerance: options.clockTolerance ?? 30,
|
||||
}) as TokenClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = claims.userId ?? claims.sub;
|
||||
if (!userId || typeof userId !== "string") return null;
|
||||
if (claims.tenantId !== parsed.tenantId) return null;
|
||||
if (claims.resourceType !== parsed.resourceType) return null;
|
||||
if (claims.resourceId !== parsed.resourceId) return null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
tenantId: parsed.tenantId,
|
||||
resourceType: parsed.resourceType,
|
||||
resourceId: parsed.resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hocuspocus extension hook. Wired in `apps/hocuspocus/src/index.ts`.
|
||||
*
|
||||
* When `secret` is empty/null, the extension refuses every connection — this
|
||||
* is the safe default if HOCUSPOCUS_AUTH_SECRET is unset on the server side
|
||||
* (mis-configured prod deploy should not silently fall through to anonymous
|
||||
* collaboration).
|
||||
*/
|
||||
export function createAuthExtension(options: AuthExtensionOptions): Extension {
|
||||
return {
|
||||
async onAuthenticate(data: onAuthenticatePayload) {
|
||||
const context = verifyCollabToken(data.token, data.documentName, options);
|
||||
if (!context) {
|
||||
throw new Error("hocuspocus: authentication refused");
|
||||
}
|
||||
return context satisfies HocuspocusConnectionContext;
|
||||
},
|
||||
} as Extension;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchCollabDoc, storeCollabDoc, type DatabaseExtensionDeps } from "./database.js";
|
||||
|
||||
type StubCollabRow = {
|
||||
tenantId: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
ydocState: Buffer;
|
||||
version: number;
|
||||
lastUpdatedAt: Date;
|
||||
};
|
||||
|
||||
function createStubDb() {
|
||||
const rows = new Map<string, StubCollabRow>();
|
||||
const findUnique = vi.fn(async ({ where }: { where: { tenantId_resourceType_resourceId: { tenantId: string; resourceType: string; resourceId: string } }; select?: unknown }) => {
|
||||
const { tenantId, resourceType, resourceId } = where.tenantId_resourceType_resourceId;
|
||||
const key = `${tenantId}:${resourceType}:${resourceId}`;
|
||||
const row = rows.get(key);
|
||||
return row ? { ydocState: row.ydocState } : null;
|
||||
});
|
||||
type UpsertArgs = {
|
||||
where: { tenantId_resourceType_resourceId: { tenantId: string; resourceType: string; resourceId: string } };
|
||||
create: StubCollabRow;
|
||||
update: { ydocState: Buffer; version: { increment: number }; lastUpdatedAt: Date };
|
||||
};
|
||||
const upsert = vi.fn(async ({ where, create, update }: UpsertArgs) => {
|
||||
const { tenantId, resourceType, resourceId } = where.tenantId_resourceType_resourceId;
|
||||
const key = `${tenantId}:${resourceType}:${resourceId}`;
|
||||
const existing = rows.get(key);
|
||||
if (existing) {
|
||||
existing.ydocState = update.ydocState;
|
||||
existing.version += update.version.increment;
|
||||
existing.lastUpdatedAt = update.lastUpdatedAt;
|
||||
return existing;
|
||||
}
|
||||
rows.set(key, { ...create });
|
||||
return create;
|
||||
});
|
||||
return {
|
||||
db: { collabDoc: { findUnique, upsert } },
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeps(): { deps: DatabaseExtensionDeps; rows: Map<string, StubCollabRow> } {
|
||||
const stub = createStubDb();
|
||||
return {
|
||||
rows: stub.rows,
|
||||
deps: {
|
||||
db: stub.db as unknown as DatabaseExtensionDeps["db"],
|
||||
maxPayloadBytes: 1024,
|
||||
clock: () => new Date("2026-05-17T12:00:00Z"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchCollabDoc", () => {
|
||||
it("returns null when no row exists for the document", async () => {
|
||||
const { deps } = buildDeps();
|
||||
const result = await fetchCollabDoc("t:kb-article:art_1", deps);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed document names", async () => {
|
||||
const { deps } = buildDeps();
|
||||
expect(await fetchCollabDoc("malformed", deps)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a Uint8Array when the row exists", async () => {
|
||||
const { deps, rows } = buildDeps();
|
||||
rows.set("t:kb-article:art_1", {
|
||||
tenantId: "t",
|
||||
resourceType: "kb-article",
|
||||
resourceId: "art_1",
|
||||
ydocState: Buffer.from([1, 2, 3, 4]),
|
||||
version: 7,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
const result = await fetchCollabDoc("t:kb-article:art_1", deps);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(Array.from(result ?? [])).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeCollabDoc", () => {
|
||||
it("inserts a new row on first store", async () => {
|
||||
const { deps, rows } = buildDeps();
|
||||
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([9, 9, 9]), deps);
|
||||
const row = rows.get("t:kb-article:art_1");
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.version).toBe(1);
|
||||
expect(Array.from(row?.ydocState ?? new Uint8Array())).toEqual([9, 9, 9]);
|
||||
});
|
||||
|
||||
it("increments version on subsequent stores", async () => {
|
||||
const { deps, rows } = buildDeps();
|
||||
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([1]), deps);
|
||||
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([2]), deps);
|
||||
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([3]), deps);
|
||||
expect(rows.get("t:kb-article:art_1")?.version).toBe(3);
|
||||
expect(Array.from(rows.get("t:kb-article:art_1")?.ydocState ?? new Uint8Array())).toEqual([3]);
|
||||
});
|
||||
|
||||
it("refuses payloads exceeding maxPayloadBytes", async () => {
|
||||
const { deps } = buildDeps();
|
||||
const large = new Uint8Array(2048);
|
||||
await expect(storeCollabDoc("t:kb:art_1", large, deps)).rejects.toThrow(/exceeds HOCUSPOCUS_MAX_PAYLOAD_BYTES/);
|
||||
});
|
||||
|
||||
it("is a no-op for malformed document names", async () => {
|
||||
const { deps, rows } = buildDeps();
|
||||
await storeCollabDoc("malformed", new Uint8Array([1]), deps);
|
||||
expect(rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("isolates tenants — same resourceType/Id under different tenant gets its own row", async () => {
|
||||
const { deps, rows } = buildDeps();
|
||||
await storeCollabDoc("tenant_a:kb:art_1", new Uint8Array([1]), deps);
|
||||
await storeCollabDoc("tenant_b:kb:art_1", new Uint8Array([2]), deps);
|
||||
expect(rows.size).toBe(2);
|
||||
expect(Array.from(rows.get("tenant_a:kb:art_1")?.ydocState ?? new Uint8Array())).toEqual([1]);
|
||||
expect(Array.from(rows.get("tenant_b:kb:art_1")?.ydocState ?? new Uint8Array())).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Hocuspocus database extension persisting Y.js docs to Postgres
|
||||
* (`CollabDoc` table).
|
||||
*
|
||||
* `fetch({ documentName })` → Loads BLOB from Postgres on connect; returns
|
||||
* `null` when no row exists (Hocuspocus then
|
||||
* initialises a fresh in-memory Y.Doc).
|
||||
* `store({ documentName, state })` → Upserts the BLOB, bumps `version`,
|
||||
* refreshes `lastUpdatedAt`. Debounced by
|
||||
* Hocuspocus per the server-level
|
||||
* `debounce` / `maxDebounce` settings.
|
||||
*
|
||||
* The DocumentName is parsed via `parseDocumentName()` (see auth.ts) and is
|
||||
* authoritative for the `(tenantId, resourceType, resourceId)` triple. Auth
|
||||
* has already verified that the connection has access to this triple; here
|
||||
* we just persist the bytes.
|
||||
*
|
||||
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
|
||||
*/
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import type { Extension } from "@hocuspocus/server";
|
||||
import type { DbClient } from "@toolctrl/db/client.server";
|
||||
|
||||
import { parseDocumentName } from "./auth.js";
|
||||
|
||||
export type DatabaseExtensionDeps = {
|
||||
/** Prisma client (write-able). */
|
||||
readonly db: Pick<DbClient, "collabDoc">;
|
||||
/** Maximum bytes we will store per doc — protects from runaway state-bloat. */
|
||||
readonly maxPayloadBytes: number;
|
||||
/**
|
||||
* Optional clock hook for deterministic tests. Defaults to `() => new Date()`.
|
||||
*/
|
||||
readonly clock?: () => Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal `fetch` implementation — exported for unit-testing.
|
||||
*/
|
||||
export async function fetchCollabDoc(documentName: string, deps: DatabaseExtensionDeps): Promise<Uint8Array | null> {
|
||||
const parsed = parseDocumentName(documentName);
|
||||
if (!parsed) return null;
|
||||
const row = await deps.db.collabDoc.findUnique({
|
||||
where: {
|
||||
tenantId_resourceType_resourceId: {
|
||||
tenantId: parsed.tenantId,
|
||||
resourceType: parsed.resourceType,
|
||||
resourceId: parsed.resourceId,
|
||||
},
|
||||
},
|
||||
select: { ydocState: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
// Prisma returns `Buffer` for Bytes columns — wrap to Uint8Array for Y.js.
|
||||
return new Uint8Array(row.ydocState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal `store` implementation — exported for unit-testing.
|
||||
*/
|
||||
export async function storeCollabDoc(documentName: string, state: Uint8Array, deps: DatabaseExtensionDeps): Promise<void> {
|
||||
const parsed = parseDocumentName(documentName);
|
||||
if (!parsed) return;
|
||||
if (state.byteLength > deps.maxPayloadBytes) {
|
||||
throw new Error(
|
||||
`hocuspocus: refusing to persist Y.js state of ${state.byteLength} bytes — exceeds HOCUSPOCUS_MAX_PAYLOAD_BYTES=${deps.maxPayloadBytes}`,
|
||||
);
|
||||
}
|
||||
const now = (deps.clock ?? (() => new Date()))();
|
||||
const buffer = Buffer.from(state);
|
||||
await deps.db.collabDoc.upsert({
|
||||
where: {
|
||||
tenantId_resourceType_resourceId: {
|
||||
tenantId: parsed.tenantId,
|
||||
resourceType: parsed.resourceType,
|
||||
resourceId: parsed.resourceId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId: parsed.tenantId,
|
||||
resourceType: parsed.resourceType,
|
||||
resourceId: parsed.resourceId,
|
||||
ydocState: buffer,
|
||||
version: 1,
|
||||
lastUpdatedAt: now,
|
||||
},
|
||||
update: {
|
||||
ydocState: buffer,
|
||||
version: { increment: 1 },
|
||||
lastUpdatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Hocuspocus database extension wired to the Prisma client.
|
||||
*/
|
||||
export function createDatabaseExtension(deps: DatabaseExtensionDeps): Extension {
|
||||
return new Database({
|
||||
fetch: async ({ documentName }) => fetchCollabDoc(documentName, deps),
|
||||
store: async ({ documentName, state }) => storeCollabDoc(documentName, state, deps),
|
||||
}) as unknown as Extension;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const listen = vi.fn(async () => undefined);
|
||||
|
||||
class MockServer {
|
||||
static instances: MockServer[] = [];
|
||||
|
||||
readonly options: unknown;
|
||||
readonly listen = listen;
|
||||
|
||||
constructor(options: unknown) {
|
||||
this.options = options;
|
||||
MockServer.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class MockLogger {
|
||||
readonly options: unknown;
|
||||
|
||||
constructor(options: unknown) {
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockLogger,
|
||||
MockServer,
|
||||
authExtension: { name: "auth-extension" },
|
||||
createAuthExtension: vi.fn(),
|
||||
createDatabaseExtension: vi.fn(),
|
||||
databaseExtension: { name: "database-extension" },
|
||||
dbClient: { collabDoc: {} },
|
||||
getHocuspocusRuntimeConfig: vi.fn(),
|
||||
getWriteDbClient: vi.fn(),
|
||||
hocuspocusLogger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
listen,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@hocuspocus/server", () => ({ Server: mocks.MockServer }));
|
||||
vi.mock("@hocuspocus/extension-logger", () => ({ Logger: mocks.MockLogger }));
|
||||
vi.mock("@toolctrl/db/client.server", () => ({ getWriteDbClient: mocks.getWriteDbClient }));
|
||||
vi.mock("./config.js", () => ({ getHocuspocusRuntimeConfig: mocks.getHocuspocusRuntimeConfig }));
|
||||
vi.mock("./extensions/auth.js", () => ({ createAuthExtension: mocks.createAuthExtension }));
|
||||
vi.mock("./extensions/database.js", () => ({ createDatabaseExtension: mocks.createDatabaseExtension }));
|
||||
vi.mock("./logger.js", () => ({ hocuspocusLogger: mocks.hocuspocusLogger }));
|
||||
|
||||
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "../package.json");
|
||||
const processExit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
function packageScripts(): Record<string, string> {
|
||||
return JSON.parse(readFileSync(packageJsonPath, "utf8")).scripts;
|
||||
}
|
||||
|
||||
describe("hocuspocus entrypoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.MockServer.instances.length = 0;
|
||||
mocks.listen.mockResolvedValue(undefined);
|
||||
mocks.createAuthExtension.mockReturnValue(mocks.authExtension);
|
||||
mocks.createDatabaseExtension.mockReturnValue(mocks.databaseExtension);
|
||||
mocks.getWriteDbClient.mockReturnValue(mocks.dbClient);
|
||||
mocks.getHocuspocusRuntimeConfig.mockReturnValue({
|
||||
authSecret: "collab-secret",
|
||||
host: "127.0.0.1",
|
||||
maxPayloadBytes: 4096,
|
||||
port: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("production start script runs compiled Node output without tsx", () => {
|
||||
const scripts = packageScripts();
|
||||
|
||||
expect(scripts.start).toBe("node dist/index.js");
|
||||
expect(scripts.start).not.toContain("tsx");
|
||||
});
|
||||
|
||||
it("boots a standalone Hocuspocus server with auth and database extensions", async () => {
|
||||
await import("./index.js");
|
||||
|
||||
expect(mocks.createAuthExtension).toHaveBeenCalledWith({ secret: "collab-secret" });
|
||||
expect(mocks.createDatabaseExtension).toHaveBeenCalledWith({
|
||||
db: mocks.dbClient,
|
||||
maxPayloadBytes: 4096,
|
||||
});
|
||||
expect(mocks.MockServer.instances).toHaveLength(1);
|
||||
expect(mocks.MockServer.instances[0]?.options).toMatchObject({
|
||||
address: "127.0.0.1",
|
||||
debounce: 2000,
|
||||
maxDebounce: 10000,
|
||||
name: "toolctrl-hocuspocus",
|
||||
port: 4321,
|
||||
quiet: true,
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(mocks.MockServer.instances[0]?.listen).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hocuspocusLogger.info).toHaveBeenCalledWith({ port: 4321, host: "127.0.0.1" }, "hocuspocus listening");
|
||||
});
|
||||
|
||||
it("wires Hocuspocus extension logs into the app logger", async () => {
|
||||
await import("./index.js");
|
||||
|
||||
const options = mocks.MockServer.instances[0]?.options as { extensions: Array<{ options?: { log?: (message: string) => void } }> };
|
||||
const loggerExtension = options.extensions[0];
|
||||
loggerExtension?.options?.log?.("connected");
|
||||
|
||||
expect(mocks.hocuspocusLogger.info).toHaveBeenCalledWith({ msg: "connected" }, "hocuspocus");
|
||||
});
|
||||
|
||||
it("logs and exits when auth secret is missing", async () => {
|
||||
mocks.getHocuspocusRuntimeConfig.mockReturnValue({
|
||||
authSecret: null,
|
||||
host: "0.0.0.0",
|
||||
maxPayloadBytes: 1024,
|
||||
port: 1234,
|
||||
});
|
||||
|
||||
await import("./index.js");
|
||||
|
||||
expect(mocks.hocuspocusLogger.error).toHaveBeenCalledWith("HOCUSPOCUS_AUTH_SECRET is not set — refusing to boot");
|
||||
expect(processExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("logs startup failures and exits non-zero", async () => {
|
||||
const error = new Error("listen failed");
|
||||
mocks.listen.mockRejectedValueOnce(error);
|
||||
|
||||
await import("./index.js");
|
||||
|
||||
expect(mocks.hocuspocusLogger.error).toHaveBeenCalledWith({ err: error }, "hocuspocus failed to start");
|
||||
expect(processExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Hocuspocus realtime-collaboration server entrypoint.
|
||||
*
|
||||
* Runs as a standalone Node service (separate container) — see
|
||||
* docs/features/realtime-collab.md. Persistence + auth + cleanup
|
||||
* extensions are wired in subsequent commits; this entrypoint is the
|
||||
* smallest possible bootable shell so the container scaffold can land
|
||||
* before persistence + auth concerns.
|
||||
*
|
||||
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
|
||||
*/
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { getWriteDbClient } from "@toolctrl/db/client.server";
|
||||
|
||||
import { getHocuspocusRuntimeConfig } from "./config.js";
|
||||
import { createAuthExtension } from "./extensions/auth.js";
|
||||
import { createDatabaseExtension } from "./extensions/database.js";
|
||||
import { hocuspocusLogger } from "./logger.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = getHocuspocusRuntimeConfig();
|
||||
|
||||
if (!config.authSecret) {
|
||||
hocuspocusLogger.error("HOCUSPOCUS_AUTH_SECRET is not set — refusing to boot");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new Server({
|
||||
name: "toolctrl-hocuspocus",
|
||||
port: config.port,
|
||||
address: config.host,
|
||||
timeout: 30_000,
|
||||
debounce: 2_000,
|
||||
maxDebounce: 10_000,
|
||||
quiet: true,
|
||||
extensions: [
|
||||
new Logger({
|
||||
log: (message) => {
|
||||
hocuspocusLogger.info({ msg: message }, "hocuspocus");
|
||||
},
|
||||
}),
|
||||
createAuthExtension({ secret: config.authSecret }),
|
||||
createDatabaseExtension({
|
||||
db: getWriteDbClient(),
|
||||
maxPayloadBytes: config.maxPayloadBytes,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
hocuspocusLogger.info({ port: config.port, host: config.host }, "hocuspocus listening");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
hocuspocusLogger.error({ err: error }, "hocuspocus failed to start");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createChildLogger, defaultLogger } from "@toolctrl/logger";
|
||||
|
||||
export const hocuspocusLogger = createChildLogger(defaultLogger, { component: "hocuspocus" });
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user