A web tool for producing ACOR training-course completion certificates as print-ready PDFs. Staff sign in with their Microsoft (Entra ID) accounts, create certificates, and come back later to see, edit, and re-download the ones they've made. The ornamental frame and flourishes are the exact artwork from the original certificates; only the parts that change each time are editable:
- Logos — 1, 2, 3 or more, spread across the top (uploaded and reused across certificates). They are all drawn at the same height whatever their proportions, and the row is aligned to the span of the signature rules at the foot of the page, so the top and bottom of the certificate share one margin and nothing crowds the ornamental frame. A row too wide for that column closes its gaps and then scales all the logos down together, so they stay equal to each other.
- Student names — set in the Julietta Messie script (leave blank for a nameless template)
- Course title — one or more lines (auto-shrinks so it never collides with the signatures)
- Course details — the short date / host / collaborators lines
- Signers — as many as you need, evenly spread along the bottom, each either a blank rule to sign by hand or a digital signature that the signer approves
Each certificate can be one page or a whole class, exported as a single combined PDF or a ZIP of one PDF per student. Certificates are stored as data and re-rendered on demand, so edits are always reflected.
A signer can be set to digital signature instead of a blank rule. That person is then asked to approve the certificate, and the signature they give is printed on it. Until every digital signature is in, downloads are watermarked DRAFT and the filename says so — so a half-signed certificate can be circulated for review without being mistaken for the finished thing.
Capturing a signature. Four ways in, all ending in the same vector outlines:
| Way | When it is the right one |
|---|---|
| Use the camera | The best result, and the one to reach for. Sign a blank sheet in dark ink and hold it up to the camera: it captures a real pen stroke, with the pressure and speed a mouse cannot reproduce. |
| Upload a photo | A photo taken earlier, or a flatbed scan. Same pipeline as the camera. |
| Draw it | Fine on a tablet or trackpad; on a mouse it looks like a mouse drew it. |
| Type it | A name set in a script face. A last resort on a phone, and honest about being one. |
The camera path takes a short burst of frames and keeps the sharpest — scored by the variance of a Laplacian, which is high on a crisp frame and flattened by blur or a shaky hand. A hand-held sheet under a webcam is the worst case for a tracer, and this removes most of it without asking anyone to hold still on cue. Only the area inside the on-screen guide is kept, so the desk, the edges of the paper and the signer's fingers never reach the tracer.
Whichever way it arrives, the image is traced to vector outlines in the browser
(public/vectorize.js): lighting is flattened so a photo thresholds like a flatbed scan,
grain and dust are discarded, and pinholes in strokes are closed. The signature is
therefore resolution-free — as crisp at print size as the type around it — and can be
recoloured to the certificate's ink. Nothing is stored as an image.
How faithful the trace is, measured as the pixel overlap between the outline and the ink it was traced from, on one real scan and four degraded copies of it:
| input | overlap | ink missed |
|---|---|---|
| clean scan, 573px | 94.8% | 2.7% |
| phone photo, shadow + grain | 92.6% | 4.0% |
| small export, 300px | 93.3% | 2.6% |
| faint, on grey paper | 92.6% | 3.9% |
| heavily compressed JPEG | 95.2% | 2.4% |
Three decisions get it there, and each was measured rather than assumed:
- The contour is an iso-line of the greyscale, not the edges of the ink pixels. Pixel edges can only ever produce a staircase on whole-pixel corners, so a two-pixel hairline becomes a zigzag ribbon and curve fitting has to choose between keeping the zigzag and rounding it to a lump. Interpolating the crossing point from the grey values either side reads the anti-aliased ramp — which is exactly where the true edge, and the pen's taper, is recorded.
- Straight segments, not splines. Fitting curves through the simplified points scores worse (94.4% against 97.2% on the same input) and costs a third more characters: the spline overshoots at every high-curvature point, which on a hairline is where the lumps come from. Simplification, by contrast, guarantees the outline stays within tolerance of the true contour.
- Feature sizes are measured in stroke widths. A signature's meaningful small parts — the dot on an i, the counter inside an a — are a stroke wide by definition. The earlier rule discarded anything under a fixed fraction of the image area, which on a large photo meant throwing away a dot three stroke-widths across.
The tolerance and the file-size cap deserve their own note, because they were the whole problem. The cap used to be 9,000 characters, backed off by 70% per attempt, and on a detailed signature it ran away to a tolerance of three pixels — welding neighbouring hairlines into filled lens shapes and losing about 28% of the ink. It is now 45,000 with a 25% step, which the trace above never comes close to needing.
How big it prints. A signature should cover most of its ruled line, the way one written by hand does. The foot of the page is the tightest part of the layout, though, so the size is settled per page rather than fixed: the mark is offered 80px of a 210px-wide rule, the course title gives way to it (down to a floor), and anything still spare is spent on the signature up to a 96px ceiling. On a certificate with a long title and a full info block the signature falls back instead, to a 52px floor. A 2:1 signature went from covering 50% of its rule to 77–83%; a wider one reaches the 200px width cap. Each keeps its own proportions — a long flowing signature sits shorter rather than being stretched to a common height — and the space reserved above the rule is the same for every signer, so the name captions share a baseline whether that person has signed yet or not.
Two things fell out of doing this by measurement. A long title over four info lines with
any digital signature used to overlap the signers outright: the title bottomed out at
its 22px floor and nothing else was allowed to shrink, so the info block now gives way
too. And the editor preview, which draws the page under a CSS scale(), was fitting
against screen pixels where the PDF fitted against page pixels — so a preview could show
a smaller title than the print. Both are fixed in __certFit.
The camera needs a secure context: it works over HTTPS and on localhost, and
nowhere else. Over plain HTTP the tab says so and points at Upload instead of failing
silently. The stream is released when the tab is left or the dialog closed, so nothing
holds the recording light on behind a pane nobody is looking at.
Your signature, kept. /profile is a signer's own page: capture a signature once,
name it, mark one as the default, delete the ones that did not come out. The default is
preselected the next time they are asked to approve, which makes signing a single click.
The page sits behind identity alone, not app access — an ACOR staff member who
countersigns certificates but never creates them still needs somewhere to keep this.
Deleting a saved copy never disturbs a certificate already approved with it.
Who has to prove who they are. This is the one asymmetry worth knowing:
| Signer | What the link asks of them |
|---|---|
An @acorjordan.org address (see SIGNER_INTERNAL_DOMAINS) |
Must sign in with Microsoft, and the signed-in address must match the one the request was sent to. They have accounts; a forwarded link must not be enough to sign in their name. |
| Anyone else | The link is enough. No account, no sign-in. Making an outside collaborator join an ACOR tenant to countersign one certificate is the kind of friction that ends with someone emailing a JPEG instead. |
Internal signers who lack the Certifier role can still approve: signing in proves identity, which is a different question from being allowed to create certificates.
Sending the request. Two ways, because they suit different people. Email the
request sends it from the app through Microsoft Graph, with the requester's own
address as Reply-To so a signer with a question answers a colleague rather than a
mailbox nobody reads. Get a link hands the owner the link and a pre-filled draft to
send themselves, which is still the better choice for an outside collaborator who has
never heard of us. The link comes back either way, and if the send fails the request
has not: the panel says so and the link is already in front of you.
A signer who has an account never needs a link at all: pending requests appear under
"Waiting for your signature" — on the landing page for anyone who uses the app, and on
/profile for a signer who has no app access and so never sees that page.
What an approval is worth. Only the SHA-256 of a link token is stored, so the database never holds anything that can be used to sign. A token expires (30 days by default), is burned the moment a decision is recorded, and authorizes exactly one signer on one certificate. An approval snapshots the signature geometry rather than pointing at the signer's library, so editing or deleting their saved copy later cannot alter a signature already given. And each approval records a fingerprint of the certificate's wording: if the certificate is edited afterwards the approval reads as stale and the signature is withheld until it is approved again. (Recipient names are excluded from that fingerprint — otherwise adding one late student would invalidate every signature on a 40-person course. Signers see the full class list when they sign.)
A certificate is private when it is made: its owner, anyone they add to it, and administrators. Nothing changes visibility on its own — a certificate becomes shared because someone chose to share it, never because someone forgot a setting.
| Open and download | Edit, request signatures, delete | Change sharing | |
|---|---|---|---|
| Owner | yes | yes | yes |
| Shared with (named by address) | yes | yes | yes |
| Everyone at ACOR (when the owner turns it on) | yes | no | no |
| Administrator | yes | yes | yes |
A share carries the same rights as ownership. A course is usually run by more than one person, and making one of them the only one who can fix a typo in forty certificates just moves the work rather than removing it. The trade is deliberate and worth stating: someone you share with can also delete it. The confirmation says whose certificate it is when it is not yours.
Shares are keyed by email address, not by account, for the same reason signers are: you can hand a certificate to a colleague who has never signed in, and it is waiting for them the first time they do. Until then the share list says "has not signed in yet" rather than pretending they have it.
"Everyone at ACOR" means anyone who can use the app may open and download it, and nobody else gains the right to change it. It exists so that a standard course everyone should be able to find does not need a share row per colleague. Turning it off again does not disturb the people named explicitly — the two lists are independent.
A certificate you cannot see returns 404, not 403. Whether a certificate exists is itself something only people who can see it should learn.
Administrators keep the override, and the list says so with a badge rather than silently: seeing someone else's work should never look the same as seeing your own.
Off unless configured, and never load-bearing. Every message is a nudge towards something already visible in the app — a pending signature, a certificate now shared — so a message that is filtered or never sent delays someone; it does not lose anything.
Sending goes through Microsoft Graph using the Entra app registration the app
already has for sign-in: grant it the application permission Mail.Send (admin
consent required), set MAIL_FROM to the mailbox to send as, and that is the whole
setup. No SMTP host, no second set of credentials, no local spool. There is no queue and
no retry — Graph either accepts the message or it does not, and either way the request
that triggered it carries on and the failure is logged.
| When | Who gets it |
|---|---|
| A signature is requested with Email the request | the signer, with the link, the expiry, and whether they must sign in first |
| A signer approves or declines | the certificate's owner alone — a shared certificate should not turn one signature into five identical messages |
| A certificate is shared with someone | that person, because a share is otherwise silent |
Every message carries a real person's address as Reply-To, and a copy is kept in the
sending mailbox's Sent Items — which is the first thing anyone asks for when a signer
says they never got it. /api/health reports whether mail is working, because that is
the kind of thing that quietly stops being true after a secret rotation.
Not here yet: reminders for a signature that has been outstanding for a week. They need something that runs on a schedule rather than in response to a request, which is a different shape of thing from everything above.
| Piece | What it is |
|---|---|
server.js |
Express app: sessions, auth, routes, boot/migrations |
src/db.js |
Postgres pool + all queries |
src/auth.js |
Entra ID OIDC login + role/group authorization + middleware |
src/certificates.js, src/logos.js, src/signatures.js |
REST APIs (behind auth, ownership-checked) |
src/signing.js |
Where a certificate's signing stands: content hashes, per-signer state, draft-or-final |
src/approvals.js |
Approval links, the token-scoped signing API, and the internal-domain identity check |
src/signature.js |
Validates incoming signature geometry and builds the SVG server-side |
src/render.js |
Shared Puppeteer/Chromium PDF rendering + DB logo inlining |
public/cert-template.js |
Single source of truth for the certificate layout — used by both the live browser preview and the server-side PDF, so what you see is what you get |
public/vectorize.js |
Raster → vector tracing for signatures (no dependencies) |
public/signature-pad.js |
The capture dialog: camera / upload / draw / type / saved |
public/sign.html, public/sign.js |
The signer's review-and-approve page |
public/profile.html, public/profile.js |
A signer's own signature library |
public/* |
Front-end: list view + editor + live preview (vanilla JS, no build step) |
db/schema.sql, scripts/migrate.js |
Idempotent schema, run on every boot; seeds the shared ACOR logo |
assets/ |
Extracted frame art + the fonts (Carlito, EB Garamond, Julietta Messie, Alex Brush) |
Data model: users, logos (bytes in Postgres; owner NULL = shared), certificates,
certificate_recipients, certificate_signers (identity + approval state + the signature
snapshot), signatures (a person's reusable signature library, one of which may be
flagged is_default), app_meta (one-time migration markers), and the session store.
Only one signature per person can be the default, and that is a partial unique index rather than a check in application code — two concurrent "make this my default" requests would otherwise both succeed and leave the library with two.
Signers moved out of the old certificates.signers jsonb column into their own table,
because approvals have to stay attached to the right person when the list is reordered.
The column is left in place, unused, and backfilled from once — the schema is applied on
every boot, so the backfill is guarded by a marker in app_meta rather than being
re-runnable.
Requirements: Node 18+, Docker (for a local Postgres), and a local
Chrome/Chromium (auto-detected; or set PUPPETEER_EXECUTABLE_PATH).
npm install
docker compose up -d db # local Postgres on host port 5433
cp .env.example .env # then edit as needed
# Run with Microsoft login bypassed (acts as a local admin) for UI work:
DATABASE_URL=postgres://certifier:certifier@localhost:5433/certifier \
AUTH_DISABLED=true npm startOpen http://localhost:4321. AUTH_DISABLED=true is dev-only — the app refuses to
start with it set when NODE_ENV=production.
To exercise real Microsoft login locally, fill in the ENTRA_* values in .env (a test
app registration with ENTRA_REDIRECT_URI=http://localhost:4321/auth/callback) and run
without AUTH_DISABLED.
- Your certificates — the landing page lists what you've created. Admins get a "Show all users' certificates" toggle. Click New certificate to start.
- In the editor: pick/upload logos, set the course title, type, and course details, edit signers, and enter student names (one per line — blank = nameless template, one = single cert, many = one page per student).
- For each signer, choose Signature line (sign by hand after printing) or Digital signature (give an address, save, then Request signature to get a link to send). If you are yourself one of the signers, Sign now does it in place.
- Watch the live preview; Save to persist. Download PDF for the combined file or Individual ZIP for one PDF per student. From the list, each card also has PDF / ZIP / Edit / Delete.
A certificate with digital signatures still outstanding downloads as a watermarked draft. Once everyone has approved, the same buttons give you the final file.
My signature in the header opens /profile, where you capture and keep your own
signature. Do this once, from the camera, and every certificate you are asked to sign
afterwards is a single click.
All configuration is via environment variables (see .env.example):
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string (managed/external) |
DATABASE_SSL |
true if the DB requires TLS |
SESSION_SECRET |
Long random string for signing session cookies |
PUBLIC_BASE_URL |
Public URL of the app (post-logout redirect) |
ENTRA_TENANT_ID / ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET |
Entra app registration |
ENTRA_REDIRECT_URI |
Must match a redirect URI on the app registration, ends /auth/callback |
ENTRA_REQUIRED_ROLE |
App role required to use the app (e.g. Certifier.User) |
ENTRA_ADMIN_ROLE |
App role granting admin (see all certificates) |
ENTRA_REQUIRED_GROUP |
Optional: security-group object id to gate on instead of / in addition to a role |
SIGNER_INTERNAL_DOMAINS |
Signer domains that must sign in and match (default acorjordan.org) |
MAIL_FROM |
Mailbox to send notifications from. Unset = the app sends no mail |
MAIL_REDIRECT_TO |
Send everything here instead of to the real recipient (staging) |
MAIL_DISABLED |
true turns sending off without unpicking the configuration |
APPROVAL_LINK_DAYS |
How long an approval link stays usable (default 30) |
PORT |
Listen port (default 4321) |
TRUST_PROXY |
Set to 1 behind an ingress/proxy (enables secure cookies) |
PUPPETEER_EXECUTABLE_PATH |
Chromium path (set in the Docker image to /usr/bin/chromium) |
Access is fail-closed: if neither a required role nor group is configured, everyone is denied. Signed-in users who lack the role land on an "access needed" page.
- App registration → New registration. Single tenant. Add a Web redirect URI:
https://<your-host>/auth/callback(andhttp://localhost:4321/auth/callbackfor dev). - Certificates & secrets → New client secret. Copy it into
ENTRA_CLIENT_SECRET. - App roles → create two: value
Certifier.Userand valueCertifier.Admin(allowed member types: Users/Groups). - Enterprise applications → your app → Users and groups → assign people/groups to the appropriate role.
- Copy the Directory (tenant) ID and Application (client) ID into
ENTRA_TENANT_ID/ENTRA_CLIENT_ID, setENTRA_REQUIRED_ROLE=Certifier.UserandENTRA_ADMIN_ROLE=Certifier.Admin.
(If you'd rather gate on a security group, set ENTRA_REQUIRED_GROUP to the group's
object id and add the groups optional claim to the token on the app registration.)
- Optional, for email: API permissions → Add a permission → Microsoft Graph →
Application permissions →
Mail.Send→ then Grant admin consent. SetMAIL_FROMto the mailbox to send from. Application permission means the app can send as any mailbox in the tenant, so it is worth narrowing with an application access policy scoped to just that one. Skip this and the app simply sends nothing.
The image bundles Chromium and runs as a non-root user.
docker build -t ghcr.io/<owner>/certifier:local .
docker run --rm -p 4321:4321 --env-file .env ghcr.io/<owner>/certifier:local.github/workflows/docker.yml builds and pushes to
ghcr.io/<owner>/<repo> on every push to main and on v* tags (PRs build only).
It authenticates with the built-in GITHUB_TOKEN (packages: write) — no extra secrets
needed. Tags produced: latest (default branch), short SHA, branch name, and semver on
tags.
Deployment is owned by your team. The container needs the env vars above (put secrets in a
Secret, the rest in a ConfigMap), an external Postgres reachable via DATABASE_URL,
and an Ingress terminating TLS with TRUST_PROXY=1. Point liveness/readiness at
GET /api/health (returns 503 until Postgres is reachable). Minimal starting point:
apiVersion: apps/v1
kind: Deployment
metadata: { name: certifier }
spec:
replicas: 2
selector: { matchLabels: { app: certifier } }
template:
metadata: { labels: { app: certifier } }
spec:
containers:
- name: certifier
image: ghcr.io/<owner>/<repo>:latest
ports: [{ containerPort: 4321 }]
envFrom:
- secretRef: { name: certifier-secrets } # DATABASE_URL, SESSION_SECRET, ENTRA_*
- configMapRef: { name: certifier-config } # PUBLIC_BASE_URL, ENTRA_REQUIRED_ROLE, TRUST_PROXY=1, ...
readinessProbe: { httpGet: { path: /api/health, port: 4321 } }
livenessProbe: { httpGet: { path: /api/health, port: 4321 } }
---
apiVersion: v1
kind: Service
metadata: { name: certifier }
spec:
selector: { app: certifier }
ports: [{ port: 80, targetPort: 4321 }]Sessions are stored in Postgres, so multiple replicas share login state.
- The display serif for headings is EB Garamond — a close match to the originals (the exact original font was flattened to outlines in the source PDFs and isn't recoverable).
- The frame/flourish artwork lives in
assets/; the original certificates are kept insamples/for reference. You don't need to regenerate these unless the design changes. - The shared ACOR logo is
assets/acor_logo.png, and the database copy is kept matching it on every boot. Replacing that file is all it takes to change the logo everywhere, including on certificates that already exist: the seed updates the existing row rather than adding one, because certificates reference their logos by id. Trim any white margin out of a replacement — the certificate sizes a row of logos by height, so a built-in margin makes a logo render smaller than the ones beside it. Nothing else can write that row: a global logo cannot be uploaded, edited or deleted through the app.