@vantageos/convex-doc-forge
v0.1.5
Published
Convex component — data layer for VantageDocForge (templates, render jobs, audit)
Downloads
1,125
Readme
@vantageos/convex-doc-forge
Convex component — data layer for the VantageDocForge trinity.
Stores templates, render jobs, and audit records. Calls the Railway renderer sidecar (vantage-doc-forge-renderer) via a Convex action over HTTP. Pattern: @convex-dev/rag (Convex handles data; external service handles compute).
Architecture
Consumer (vantage-immo / vCRM / MCP server)
│
▼ ctx.runAction(components.docForge.actions.render_now, {...})
@vantageos/convex-doc-forge (this package)
- doc_forge_tenant_tokens table ← dynamic API token registry (sha256 hashes only)
- templates table ← template metadata + asset_storage_id
- render_jobs table ← job status machine (queued → rendering → done|failed)
- render_audit table ← immutable audit log per render
- render_now action → HTTP POST /v1/render → Railway renderer sidecar
│
▼
vantage-doc-forge-renderer (Python Railway)
docxtpl + python-pptx + LibreOffice headlessToken management (v0.1.4+)
The doc_forge_tenant_tokens table enables dynamic self-service API key issuance. The dashboard team writes tokens via:
// In host app — wrap component mutations as api.docForge.*
await ctx.runMutation(components.docForge.mutations.issue_tenant_token, {
token_hash: sha256hex(rawBearerToken), // NEVER store the raw token
org_id: "org_clerk_xxx",
role: "admin", // or "readonly"
});
// Revoke
await ctx.runMutation(components.docForge.mutations.revoke_tenant_token, {
token_hash: sha256hex(rawBearerToken),
});The MCP server resolves tokens against this table on every authenticated request (primary). The MCP_TENANT_TOKENS env JSON map is a static fallback for dev/bootstrap.
Install
npm install @vantageos/convex-doc-forgeIn your Convex deployment's convex/convex.config.ts:
import { defineApp } from "convex/server";
import docForge from "@vantageos/convex-doc-forge/convex.config";
const app = defineApp();
app.use(docForge);
export default app;Renderer config (passed as args by the host app)
render_now does not read env vars from the Convex dashboard — Convex components cannot access the host deployment's env vars (convex env set has no --component flag). The host app reads its own env and passes config as args on every call:
const result = await ctx.runAction(components.docForge.actions.render_now, {
// ...
renderer_url: process.env.CONVEX_DOC_FORGE_RENDERER_URL!,
renderer_key: process.env.CONVEX_DOC_FORGE_RENDERER_KEY!,
});Set these in the host deployment's Convex dashboard (not inside the component):
| Variable | Description |
|---|---|
| CONVEX_DOC_FORGE_RENDERER_URL | Base URL of the Railway renderer (e.g. https://doc-forge.railway.app) |
| CONVEX_DOC_FORGE_RENDERER_KEY | Bearer token for renderer auth |
Schema
templates
| Field | Type | Notes |
|---|---|---|
| name | string | Template name (unique per tenant+team) |
| team | string | Logical group (e.g. iris-rh, pujol) |
| tenant_id | string | Clerk org ID |
| template_type | "doc-binary" | Always doc-binary (V1) |
| renderer_kind | "docxtpl" \| "python-pptx" \| "jinja2-html" | Which renderer to use |
| input_schema | string | JSON Schema serialized as a JSON string (JSON.stringify(schema)) — avoids Convex $-key rejection |
| asset_storage_id | Id<"_storage"> | Convex file storage reference to the .docx/.pptx asset |
| output_formats | string[] | e.g. ["docx", "pdf"] |
| version | string | SemVer |
render_jobs
Status machine: queued → rendering → done | failed
render_audit
Immutable log. One row per successful render. context_hash = SHA-256 of JSON.stringify(context).
Functions
Queries (public)
// List templates for a tenant (optionally filter by team or type)
docForge.queries.list_templates({ tenant_id, team?, type? })
// Get a single template by ID
docForge.queries.get_template({ template_id })
// Audit history for a tenant (optionally filter by template or timestamp)
docForge.queries.list_render_history({ tenant_id, template_id?, since? })Mutations (public)
// Insert or update a template (upsert by name+team+tenant_id)
docForge.mutations.upsert_template({
name, team, tenant_id, renderer_kind,
input_schema, asset_storage_id, output_formats, version, createdBy
})
// Delete a template and its storage asset
// Returns null on success. Throws if:
// - template not found OR tenant_id does not match (opaque "Template not found" — no existence oracle)
// - render_jobs referencing this template exist (see deletion policy below)
docForge.mutations.delete_template({ template_id, tenant_id })
// Queue an async render job (returns job ID, use list_render_history to poll)
docForge.mutations.queue_render({ template_id, context, output_format, createdBy })delete_template — deletion policy
Policy: REFUSE if render_jobs exist (not cascade).
If any render_jobs row references the template, delete_template throws:
cannot delete: N render_jobs reference this templateRationale: render_audit rows carry immutable compliance trails. Cascading deletes
would silently destroy audit history. Callers must drain or archive jobs before
deleting a template. A future force_delete flag (cascade) can be added when
the use case requires it.
Deletion order when no jobs are linked:
ctx.db.delete(template._id)— removes the metadata rowctx.storage.delete(template.asset_storage_id)— removes the .docx/.pptx binary
Rationale for this order: if storage delete fails after the row is already gone,
the worst case is an orphaned blob (recoverable via storage GC). The inverse order
risks a live metadata row pointing to a deleted blob — a dangling reference that
is not recoverable and breaks any subsequent get_template → signed URL flow.
Actions (public)
// Synchronous render: calls Railway renderer, stores output, writes audit
// Returns signed URL (TTL 7 days)
docForge.actions.render_now({
template_id, context, output_format,
actor, // Clerk subject
source?, // "manual" | "workflow" | "agent" (default "manual")
renderer_url, // host app reads from its own env (e.g. process.env.CONVEX_DOC_FORGE_RENDERER_URL)
renderer_key, // host app reads from its own env (e.g. process.env.CONVEX_DOC_FORGE_RENDERER_KEY)
})
// → { output_url, render_job_id, duration_ms, pages? }Asset ingestion
Before calling upsert_template, the host app must upload the .docx/.pptx binary into the component's isolated storage namespace and obtain a storageId. The three-step flow:
Step 1 — get a signed upload URL (from a host app action):
// convex/myActions.ts
import { action } from "./_generated/server";
import { components } from "./_generated/api";
export const getDocForgeUploadUrl = action({
args: {},
handler: async (ctx) =>
await ctx.runMutation(components.docForge.mutations.generate_upload_url, {}),
});Step 2 — POST the raw bytes to the signed URL:
curl -X POST "<uploadUrl>" \
-H "Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document" \
--data-binary @template.docx
# → { "storageId": "kg2abc..." }Step 3 — register the asset as a template and then render:
const templateId = await ctx.runMutation(
components.docForge.mutations.upsert_template,
{
name: "proposition-audit",
team: "iris-rh",
tenant_id: "org_clerk_abc",
renderer_kind: "docxtpl",
// input_schema must be a JSON-stringified string — Convex rejects $-prefixed keys (e.g. $schema, $ref)
input_schema: JSON.stringify({ type: "object", properties: { client_name: { type: "string" } } }),
asset_storage_id: storageId, // from step 2
output_formats: ["pdf", "docx"],
version: "1.0.0",
createdBy: "clerk_user_id",
},
);
// Now render_now works end-to-end:
// renderer_url + renderer_key are read from the host app's env and passed as args
const result = await ctx.runAction(components.docForge.actions.render_now, {
template_id: templateId,
context: { client_name: "Iris RH" },
output_format: "pdf",
actor: "clerk_user_id",
source: "manual",
renderer_url: process.env.CONVEX_DOC_FORGE_RENDERER_URL!,
renderer_key: process.env.CONVEX_DOC_FORGE_RENDERER_KEY!,
});
// → { output_url, render_job_id, duration_ms }See scripts/smoke-render.md in this repo for the full copy-paste fleet smoke harness.
Renderer contract
render_now calls the sidecar:
POST {CONVEX_DOC_FORGE_RENDERER_URL}/v1/render
Authorization: Bearer {CONVEX_DOC_FORGE_RENDERER_KEY}
Content-Type: application/json
{
"template_url": "<Convex signed URL for the .docx asset>",
"context": { ...JSON data matching input_schema... },
"output_format": "pdf" | "docx" | "pptx" | "doc"
}
→ {
"output_bytes": "<base64>", // OR
"output_url": "<url>",
"duration_ms": 420,
"pages": 3
}Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run (30 tests, no network)Open decisions (see decisions/rfc-rendering-engine.md)
- npm scope:
@vantageos/convex-doc-forge(internal) — migrate to@convex-dev/doc-forgeif submitted upstream. - Asset storage: Convex
_storage(default) — hook for R2/S3 when volume justifies. - Sync vs async:
render_nowis sync (<10s);queue_render+ subscription for async flows. - Consumer install: pnpm workspace monorepo (fleet) vs npm public (external).
Orchestrator: Hephaistos — VantageOS Team | 2026-06-19
