Add two internal endpoints so an embedding app can run one DocuSeal
account per tenant, authenticated entirely by tokens (no shared Devise
admin session):
- POST /api/internal/provision_account — HMAC-signed (DOCUSEAL_PROVISION_SECRET)
handoff that idempotently creates an Account + owner User + access token
and returns them. Fails closed when the secret is unset.
- POST /api/internal/templates — X-Auth-Token-authed create-from-PDF that
builds a template under the caller's account via the same service the web
uploader uses, idempotent by external_id (scoped per account).
EmbedBuilderController/EmbedScoped already scope to user.account.templates,
so giving each tenant its own account makes that a real isolation boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Page/preview images rasterized at 1400px (~165 DPI) and palette-quantized
to as few as 4-256 colours read as a blurry, banded scan in the builder
and preview. Raise the default render width to 2200px (~260 DPI, override
via PAGE_MAX_WIDTH) and write PDF pages as truecolour PNG instead of a
quantized palette. Larger cached blobs, but crisp text and logos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EmbedBuilderController is the JWT re-entry point but runs behind
EmbedScoped's enforce_embed_scope! before_action. Once a prior open
pins an embed_scope into the session cookie, re-opening the builder
(the same template again, or a different one) hit `head :forbidden`
because /embed/builder was not in UNSCOPED_ALLOW — the concern locked
out its own re-entry endpoint. First open worked; every reopen 403'd.
The token, not the session, authenticates /embed/builder, so always
let it through; EmbedBuilderController then overwrites the scope from
the fresh token. Add a request spec covering the re-entry/re-scope case.
The embedding EHR spans an apex plus an app/tenant subdomain; frame-ancestors
must list each origin separately. Split EMBED_ALLOWED_ORIGIN on whitespace so
"https://enwella.comhttps://app.enwella.com" both become sources. The deploy
already sets two space-separated origins; the single-value form emitted one
malformed token and the browser blocked the iframe.
- Eager account user so Account#default_template_folder can pick an author.
- Pass author: in template fixtures; create the foreign account's user.
- Positive scope control tolerates the missing webpack manifest (reaching the
view proves the guard allowed the in-scope template).
- Time.current over Time.now (Rails/TimeZone).
Adds a JWT-authenticated entry point so the embedding app can mount
<docuseal-builder data-token="…"> without the host pre-establishing a
DocuSeal session via cross-origin cookies + an external auth gate.
- EmbedBuilderController (GET /embed/builder?token=…): verifies a short-lived
HS256 JWT against the owner account's API access token (the same key the
JSON API uses; raw value is recoverable via the encrypted `token` column),
signs that user in (a first-party session inside the iframe), records a
template-scoped grant in the session, and redirects into the regular
builder — /templates/:id/edit for an existing template, or /new?url=… to
download + create from `document_urls`. Requires `exp` and caps token
lifetime (replay bound). Only opens template_ids the account owns.
- EmbedScoped concern: confines an embed session to its own template (by id,
or by external_id for the /new→create→edit redirect) plus the create +
builder-support paths; refuses enumeration / other templates / the JSON
template API. Fails closed. Path rules mirror the allow-list the host app
enforced at the edge, so the builder keeps working while the cross-origin
cookie + gate machinery on the host side can be removed.
- builder.js shim now iframes /embed/builder?token=… (the server decides
edit vs. new) instead of a bare authenticated /new that 404s without a
pre-set session cookie.
- Request specs for token verification, ownership, exp/lifetime, and scope.
User model already includes :lockable (DB columns are migrated) but the
config block in `devise.rb` was entirely commented out, so the strategy
defaulted to :none — no lockout, no rate limit, nothing standing between
the internet and Devise's bcrypt loop.
Self-hosted deployments expose docuseal.<apex>/users/sign_in publicly
(the embedded builder requires an admin session). Lock the account on
10 failed attempts, recover via email OR 1-hour wait.
Adds `config/initializers/env_seed.rb` — idempotent, resilient
boot-time seeder that replaces both the manual /setup wizard run and
our standalone `bin/seed-docuseal-webhook` script. Works in dev and
prod: just set the env vars in the deploy config.
When `DOCUSEAL_ADMIN_EMAIL` + `DOCUSEAL_ADMIN_PASSWORD` are set, on
every boot the initializer upserts:
* Account (org row, name/timezone/locale from env)
* User (admin, password updated only if encrypted_password blank)
* AccessToken (rewritten when `DOCUSEAL_API_KEY` set and differs)
* EncryptedConfig APP_URL_KEY + ESIGN_CERTS_KEY
* WebhookUrl (when `DOCUSEAL_WEBHOOK_URL` set; events optional)
Resilience:
* Skips quietly when admin email/password missing — `/setup` still works
* Catches NoDatabaseError / ConnectionNotEstablished / StatementInvalid
so `db:create` before `db:migrate` doesn't crash boot
* Re-runs are no-ops (find_or_initialize_by + diff before save)
* Survives DB drop + recreate: rows are re-seeded on next boot
.env.example documents the new vars.
Lets the EnWella EHR mount <docuseal-builder> (and -form) without a
paid Pro license. Upstream's `EmbedScriptsController` returns an
"Upgrade to Pro" stub; we override it via the static-asset path so the
real shim wins without touching the controller.
JS shim (public/js/builder.js)
- Web component that decodes the JWT minted by the embedder (`name`,
`document_urls`, `external_id`, optional `template_id`).
- Iframes /templates/:id/edit for existing templates, /new?... for the
upload-from-URL flow.
- Relays a `save` postMessage from the inner builder up as a DOM
`CustomEvent('save')` on the outer element so @docuseal/react's
onSave callback fires.
- Discovers host from its own <script src> so embedders don't have to
set data-host. JWT signature not re-verified (session cookies +
shared HMAC at the calling app are authoritative).
Builder (app/javascript/template_builder/builder.vue)
- isEmbedded computed (window.parent !== window).
- Hide #title_container in embed (sheet already shows the consent name).
- Fire the `save` postMessage at the end of the manual SAVE handler so
the outer shim can dispatch the DOM event.
Rails glue
- application_controller.rb / config/application.rb: when
EMBED_ALLOWED_ORIGIN is set, add `frame-ancestors 'self' <origin>`
to the per-request CSP and drop the default `X-Frame-Options:
SAMEORIGIN` header (different scheme/host/port = different origin,
so SAMEORIGIN blocks the EHR's iframe even in same-domain prod).
- templates_uploads_controller.rb: persist `external_id` from the
upload params so the host app can later look the template up via
`GET /api/templates?external_id=...` (deterministic fallback link
path when the `template.created` webhook doesn't reach us).
- templates_uploads_controller.rb: skip the SSRF guard when fetching
documents in development — it rejects http / non-443 / localhost,
which is exactly the Active Storage URLs the EHR sends from
https://localhost:3000.
- templates_uploads/show.html.erb: carry `external_id` through the
resubmit form (used on the encrypted-PDF prompt path).
Local HTTPS dev (puma.rb, Procfile.dev, .env.example)
- Bind ssl://0.0.0.0:PORT when LOCAL_HTTPS_CERT/KEY are set (wired by
../bin/docuseal-dev to ../.certs/lvh.me.pem). EHR runs HTTPS too,
so HTTPS iframe avoids mixed-content blocking.
- Procfile.dev runs `puma -C config/puma.rb` directly so the ssl bind
applies.
- SSL_CERT_FILE example for trusting the mkcert root so DocuSeal's
outbound HTTPS to Rails (Active Storage downloads) verifies.