---
name: edvizion-pdfs
description: Generate PDFs and manage PDF templates from another Cloudflare Worker via the edvizion-pdfs service binding, or over HTTP with a Bearer token. Use when a Worker or service needs to render a record request (or other document type) to PDF, or to list, create, update, or delete PDF templates for an organization.
---

# edvizion-pdfs

Renders tenant-scoped PDF templates. Two transports, one API:

- **Service binding (preferred inside Cloudflare):** trusted caller, no JWT.
- **HTTP:** Bearer JWT, for callers outside the account.

Both expose the same methods, arguments, and error classes. Switching between
them is an import change.

## Install

```bash
npm i @edvizion/pdfs
```

| Import | Use |
| --- | --- |
| `@edvizion/pdfs/binding` | Worker-to-Worker over a service binding |
| `@edvizion/pdfs` | HTTP client (Workers, Node, browser) |
| `@edvizion/pdfs/contract` | types, zod schemas, error classes only |

## Binding setup

Service bindings are **account-scoped**: the calling Worker must be in the same
Cloudflare account as `edvizion-pdfs` (`4db724cf4e97b83b3b4f8f3d48dce6ce`,
Mvizdos). Cross-account callers must use HTTP.

`wrangler.jsonc`:

```jsonc
{
  "services": [
    {
      "binding": "PDFS",
      "service": "edvizion-pdfs",
      "entrypoint": "PDFService"
    }
  ]
}
```

`entrypoint` is required. `PDFService` is a named `WorkerEntrypoint` and is
**not reachable from the internet** — only Workers holding this binding can
call it. Omitting `entrypoint` binds the public HTTP handler instead, which
expects a JWT and will reject you.

`wrangler types` emits `Fetcher` for a service binding because it cannot see
the remote entrypoint's shape. Declare it yourself instead:

```ts
// env.d.ts
import type { PDFServiceBinding } from "@edvizion/pdfs/binding";

interface Env {
  PDFS: PDFServiceBinding;
}
```

## Usage

```ts
import { pdfs } from "@edvizion/pdfs/binding";

export default {
  async fetch(request: Request, env: Env) {
    const client = pdfs(env.PDFS, {
      tenantID: org.id,   // REQUIRED — the organization the data belongs to
      userID: user.id,    // for audit fields on template writes
    });

    const bytes = await client.generate({
      templateID: "tpl_standard_record_request",
      type: "record-request",
      payload: { /* see below */ },
    });

    return new Response(bytes, {
      headers: { "Content-Type": "application/pdf" },
    });
  },
};
```

**You are the trust boundary.** The service performs no token verification on
this transport — it trusts `tenantID` completely. Derive it from your own
verified session, never from a request body, query string, or header.

### Optional scope enforcement

```ts
pdfs(env.PDFS, { tenantID, userID, scopes: user.scopes });
```

Pass `scopes` and the service enforces them exactly as the HTTP edge does.
Omit it and the service assumes you have already authorized the action.
Forward them when you are acting on behalf of an end user.

Scopes: `pdf:generate`, `pdf:templates:read`, `pdf:templates:create`,
`pdf:templates:update`, `pdf:templates:delete`.

## Methods

```ts
generate({ templateID, payload, type?, validate? }): Promise<Uint8Array>
generateDetailed({ templateID, payload, type? }): Promise<GenerateOutput>
listTemplates({ type? }?): Promise<TemplateSummary[]>
getTemplate(templateID): Promise<TemplateDetail>
createTemplate({ type, name, html, css?, description?, config? }): Promise<TemplateDetail>
updateTemplate(templateID, { version, name?, html?, css?, description?, config? }): Promise<TemplateDetail>
deleteTemplate(templateID): Promise<void>
templateTypes(): Promise<readonly TemplateType[]>
withPrincipal(principal): PDFBindingClient   // same binding, different user
```

`generateDetailed` additionally returns `{ templateID, templateType,
templateVersion }` — useful for provenance logging. Do not log the payload.

### The `type` parameter

`type` is **compile-time only** and is never sent. It narrows `payload` to that
type's contract and pre-validates locally so a bad payload fails before the
round trip. The service always reads the real type from the stored template, so
declaring the wrong one is a local type error, never a way past validation.

Pass `validate: false` to skip the local check.

## Templates

A *template type* is a payload contract. A *template* is one stored HTML/CSS
layout rendering that contract. Many templates share one type.

Public templates (available to every organization):

| ID | Type |
| --- | --- |
| `tpl_standard_record_request` | `record-request` |
| `tpl_compact_record_request` | `record-request` |

Organizations can also create private templates, visible only to them.
`listTemplates()` returns public plus the caller's own, with `visibility` and
`editable` on each. Raw tenant IDs are never exposed.

### `record-request` payload

```ts
{
  student:   { firstName: string; lastName: string; dateOfBirth: string },
  requester: { schoolName: string; contactName: string; email: string;
               phone?: string; address?: string },
  registrar: { schoolName: string; contactName?: string; email?: string;
               fax?: string },
  recordsRequested: string[],        // at least one
  requestedAt: string,
  note?: string,
  removeRecReqAd?: boolean,
}
```

All required strings must be non-empty. `email` fields must be valid addresses.
Dates are free-form strings rendered verbatim — pick a format and be consistent.

Get the type at compile time:

```ts
import type { RecordRequestPayload } from "@edvizion/pdfs/contract";
```

## Errors

Both transports throw the same classes. Catch by class or by `.code`:

```ts
import {
  PDFError,
  TemplateNotFoundError,
  TemplateNotEditableError,
  TemplateVersionConflictError,
  InvalidTemplatePayloadError,
  PDFGenerationFailedError,
} from "@edvizion/pdfs/contract";

try {
  await client.generate({ templateID, payload });
} catch (error) {
  if (error instanceof InvalidTemplatePayloadError) {
    return badRequest(error.issues); // [{ path, message }]
  }
  if (error instanceof TemplateNotFoundError) return notFound();
  if (error instanceof PDFGenerationFailedError) return retryLater();
  throw error;
}
```

| Code | Means |
| --- | --- |
| `template_not_found` | no such template **or** it belongs to another organization |
| `template_not_editable` | the template is public; public templates are read-only |
| `template_version_conflict` | stale `version` on update — re-read and retry |
| `invalid_template_payload` | payload failed the type's schema; see `.issues` |
| `invalid_template_markup` | template HTML/CSS violated the security policy |
| `payload_too_large` | body over the limit (512 KB generate, 1 MB template) |
| `insufficient_scope` | a forwarded scope was missing |
| `pdf_generation_failed` | the renderer failed; safe to retry |

**Cross-tenant access returns `template_not_found`, never a permission error** —
a response must never reveal that another organization's template exists. Do not
special-case it in your UI.

Optimistic concurrency on update:

```ts
const current = await client.getTemplate(id);
await client.updateTemplate(id, { version: current.version, name: "New" });
// TemplateVersionConflictError => someone else changed it; re-read.
```

## Authoring template HTML

Only needed if your app lets users edit templates. Templates are
[Liquid](https://liquidjs.com/); stored markup is a **body fragment** — the
service supplies the document shell, CSS, and a strict CSP.

```liquid
<p>{{ student.firstName }} {{ student.lastName }}</p>
{% if note %}<p>{{ note }}</p>{% endif %}
{% unless removeRecReqAd %}<p class="ad">…</p>{% endunless %}
<ul>{% for r in recordsRequested %}<li>{{ r }}</li>{% endfor %}</ul>
```

- Output is **already escaped** — never add `| escape`, it double-escapes.
- Optional fields are safe inside `{% if %}`; a path the schema does not
  declare (a typo) is a hard error.
- Rejected on save: `<script>`, `<iframe>`/`<object>`/`<embed>`, `<form>`,
  `<link>`/`<meta>`/`<base>`, inline `on*=` handlers, `javascript:` URLs,
  external or protocol-relative `src`/`href`, CSS `@import` / `expression()` /
  external `url()`. Images must be `data:` URIs.
- `{% include %}`, `{% render %}`, `{% layout %}` are removed.

## HTTP transport

For callers outside the Cloudflare account:

```ts
import { PDFClient } from "@edvizion/pdfs";

const client = new PDFClient({
  baseURL: "https://api.edvizion.com/pdf",
  token: () => getAccessToken(), // string or () => string | Promise<string>
});
```

Identical methods. The tenant comes from the JWT's verified `org_id` claim
rather than a principal argument, so there is nothing to pass. The token needs
the scope for each route. `GET /health` and `GET /openapi.json` are
unauthenticated.

## Testing your integration

**This service is already fully unit tested** — 239 tests covering tenant
isolation and the cross-tenant matrix, payload validation per template type,
template markup security, optimistic concurrency, error-code mapping across
both transports, OpenAPI conformance, and the RPC boundary itself.

**Do not re-test any of that.** Test only what is yours: that you call the
client with the right principal, that you map its errors onto your own
responses, and that your own logic around it is correct.

Stub the binding with a plain object — no miniflare, no Workers runtime:

```ts
import { pdfs, type PDFServiceBinding } from "@edvizion/pdfs/binding";

const stub = {
  generate: vi.fn(async () => ({
    ok: true as const,
    value: {
      pdf: new Uint8Array([1]),
      templateID: "tpl_a",
      templateType: "record-request" as const,
      templateVersion: 1,
    },
  })),
  listTemplates: vi.fn(async () => ({ ok: true as const, value: [] })),
  getTemplate: vi.fn(async () => ({ ok: true as const, value: {} as never })),
  createTemplate: vi.fn(async () => ({ ok: true as const, value: {} as never })),
  updateTemplate: vi.fn(async () => ({ ok: true as const, value: {} as never })),
  deleteTemplate: vi.fn(async () => ({ ok: true as const, value: undefined })),
  templateTypes: vi.fn(async () => ({ ok: true as const, value: ["record-request"] as const })),
} satisfies PDFServiceBinding;

const client = pdfs(stub, { tenantID: "org_a", userID: "user_a" });
```

`satisfies PDFServiceBinding` catches a missing or misshapen method at compile
time. Methods return a result union, so error paths are easy to drive:

```ts
const failing = {
  ...stub,
  getTemplate: async () => ({
    ok: false as const,
    error: { code: "template_not_found" as const, message: "x" },
  }),
};
// client.getTemplate(...) now rejects with a real TemplateNotFoundError.
```

**A stub is a stub.** It will happily accept a cross-tenant read or a garbage
payload that the real service rejects. That is expected and fine — unit tests
against a double are not integration tests, and the service's own suite already
covers those rules. Do not build elaborate fakes trying to simulate them.

For genuine end-to-end confidence, exercise the deployed service.

## Gotchas

- **`entrypoint: "PDFService"` is mandatory** in the binding config. Without it
  you bind the JWT-protected HTTP handler and every call fails on auth.
- **`tenantID` must come from your verified session.** The service does not
  check it on this transport.
- Generated PDFs are never stored. Persist the bytes yourself if you need them.
- Never log payloads — they carry student data. Log `templateID`,
  `templateType`, `templateVersion` from `generateDetailed` instead.
- Browser Run has real latency; PDF generation is not sub-millisecond. Do not
  call it in a tight loop or a hot path.
- HTTP mount is `/pdf` (`https://api.edvizion.com/pdf/...`). Omitting the
  prefix 404s.
