Skip to content

Security review – October 2026

Scope: the whole server (server/, 863 routes: 153 public, 65 user, 89 admin, 556 sub-account), the public pages, the MCP endpoint, the mobile API, the Docker setup and dependencies – before the open-source release. Method: automated harnesses that enumerate the router (tenant isolation, stored XSS, secret leaks) plus manual review of authentication, CSRF, SSRF, file handling, secrets, webhooks, DoS and supply chain. Design and protections: security.md.

Severity Found Fixed
Critical 0 0
High 4 4
Medium 10 10
Low 18 18
Info / hardening 6 5 (+1 documented)

New test files: tests/tenant-isolation.test.ts, tests/xss.test.ts, tests/secret-leaks.test.ts, tests/security.test.ts, tests/privacy.test.ts (shared fixtures: tests/security-fixtures.ts).

ID Severity Location Finding Fix Test
H-1 High modules/mcp.ts (agency-key guard) Agency API keys could download full database backups (/api/admin/backup, /api/admin/backups/*) with password hashes and 2FA secrets, and pair mobile devices / start OAuth / manage push as the admin. Keys are refused on backups, OAuth, push and mobile pairing (besides users, me, keys, auth). tenant-isolation › agency keys manage data…
H-2 High core/app.ts clientIp Behind a proxy the left-most X-Forwarded-For entry was trusted – any client could pick its address and dodge per-IP rate limits and login lockouts. Right-most entry added by our proxies (PGHL_PROXY_HOPS). security › limits public writes … X-Forwarded-For
H-3 High services/messaging.ts (HTTP SMS gateway) Plain fetch to the sub-account’s gateway URL, response body echoed into the message error: SSRF with response reading (cloud metadata, internal services) for any client login with settings access or a sub-account key. safeFetch with size and time limits. security › an HTTP SMS gateway cannot point at internal addresses
H-4 High services/net.ts isPrivateIp IPv4-mapped IPv6 literals in hex (http://[::ffff:7f00:1]/) passed the SSRF guard → webhooks, REST hooks, workflow HTTP steps and uptime checks could reach localhost/metadata (blind, status reflected). Node BlockList over all private/transition ranges (mapped addresses matched as IPv4, NAT64, 6to4, Teredo, site-local…). security › treats every spelling of internal addresses as private; refuses IPv4-mapped literals
M-1 Medium modules/calendar-sync.ts iCal import used a host-name blocklist (bypass via DNS names, mapped IPv6, redirects) and plain fetch; events from the response are stored. safeFetch (resolved-IP check, redirect re-check, 5 MB cap). security › iCal import URLs go through the guard
M-2 Medium modules/docs.ts POST /api/docs Documents are unique by URL agency-wide; the upsert ran with force and moved another sub-account’s document (and returned it) to whoever posted the URL. Refused (409) when the URL belongs to another project or to the agency inbox – only an admin session may move it; response scoped. tenant-isolation › creating or upserting by url/email/slug…; security › a document link … is not moved
M-3 Medium settings JSON, users.totp_secret, webhooks.secret, rest_hooks.secret SMTP passwords, Twilio/SMS tokens, the inbound key, 2FA secrets and webhook signing secrets were stored in plain text. Sealed (AES-256-GCM), self-healing on read; key rotation tool npm run rotate-secret with data key + previous keys. security › secrets at rest (5 tests), key rotation
M-4 Medium modules/core.ts login Wrong 2FA codes only counted per IP – a distributed guess of 6-digit codes (after a password leak) was not rate limited per account. Wrong codes count towards the account lockout and are audited. security › wrong 2FA codes from many addresses lock the account
M-5 Medium core/csv.ts, services/report-engine.ts CSV exports wrote cell values verbatim: a lead named =HYPERLINK(…) from a public form became a formula in Excel (CSV injection). Cells starting with = + - @ TAB CR get a leading ' (plain numbers untouched). security › CSV exports neutralise spreadsheet formulas
M-6 Medium services/markdown.ts, services/community.ts Link/image patterns were quadratic ([a]( repeated: 50 KB ≈ 0.6 s) – community members could slow every page view of a feed. Linear patterns (one level of (…) in URLs still works). security › markdown links/images are linear on hostile input
M-7 Medium core/app.ts request handling Bodies (up to 10 MB) were read before authentication on every route; public routes accepted 10 MB. Authentication on headers first; 1 MB public limit with registered exceptions (app.services.bodyLimits); /mcp large bodies only with a valid key. security › caps bodies …; MCP takes large bodies only with a valid key; mobile › APK upload
M-8 Medium public write routes Booking, reviews, surveys, contract signing, help-center feedback etc. had no limit besides the global 900 requests/min. 120 public writes/min per address (provider webhooks and MCP excluded). security › limits public writes per address…
M-9 Medium modules/contacts.ts PATCH /api/custom-fields/:id The update was scoped, but the response read the field by id only – any sub-account could read another one’s custom field (IDOR). Response scoped, 404 for foreign ids. tenant-isolation (member/key); security › PATCH of another sub-account’s custom field
M-10 Medium services/snapshots.ts, modules/calendar-sync.ts Snapshots (agency templates, SaaS sign-ups, funnel copy) carried the calendar’s private feed token and imported iCal URLs into other sub-accounts; /cal/<token>.ics then served the source calendar’s appointments (names, e-mails, phones) to the copy’s owner, and downloaded snapshot files leaked private Google/Outlook iCal URLs. Feed token, iCal URLs and status are stripped on export and on apply; an ambiguous token serves nothing and is replaced when its owner opens the feed. security › snapshots never carry a calendar feed token…
L-1 Low modules/reports.ts, objects-reports.ts /api/reports/sources listed custom objects and property names/options of every sub-account. Sources/fields carry their sub-accounts and are filtered per caller (scopeSources). tenant-isolation (member/key)
L-2 Low core/util.ts scrypt N=2^14, p=1 (below OWASP); synchronous hashing blocked the event loop. N=2^15, r=8, p=3, async with concurrency cap, upgrade at login. security › hashes passwords with scrypt …
L-3 Low login Unknown e-mails skipped scrypt (timing reveals existing accounts). Dummy verification. security › hashes passwords … (verifyPasswordAsync with no hash)
L-4 Low startSession A session id the browser brought to the login stayed valid (fixation, shared devices). Deleted at login. security › a session id brought to the login never survives it
L-5 Low PATCH /api/users/:id A password set by the admin left the user’s other sessions alive. Other sessions revoked (devices already were). security › a password set by the admin signs that person out everywhere
L-6 Low POST /api/me/password Current-password check not throttled (stolen session could guess it). Account-style lockout. security › guessing the current password … is throttled
L-7 Low conversations, tickets, workflows, video-recycle, portals Inbound SMS/mail keys, support-mail key, workflow webhook keys, signed clip URLs and portal CSRF tokens compared with !==. safeEqual (constant time). security › constant-time secret comparison
L-8 Low modules/attribution.ts safeBack Consent redirect allowed control characters – /\t/evil.example becomes //evil.example in browsers (open redirect). Control characters refused. security › the consent redirect stays on the site
L-9 Low modules/core.ts uploads, modules/forms.ts Stored extension came from the client’s file name (evil.html); form uploads were not checked by content. Shared magic-byte check (core/files.ts), extension from the checked type. security › stores uploads …; form uploads must match their type
L-10 Low core/app.ts serveStatic Prefix check let a sibling folder with the same name prefix through; a malformed % gave a 500. Path must be inside the web folder (root + sep); 400 on bad encoding. security › sends HSTS … (sibling folder)
L-11 Low list endpoints limit=-1 became LIMIT -1 (= unlimited) on several lists incl. public chat history and help-center search. clampLimit (1…max). security › clamps page sizes
L-12 Low services/net.ts Redirects kept Authorization/Cookie when leaving the origin; only an idle timeout (slow-drip responses). Credentials dropped cross-origin; hard deadline. security › does not forward credentials when a redirect leaves the origin
L-13 Low core/app.ts No Permissions-Policy, no COOP on the app, server pages without any CSP, Node default timeouts (slowloris). Headers added, baseline CSP, header/request/keep-alive timeouts. security › sends HSTS …; public pages … get a baseline CSP
L-14 Low outgoing webhooks / REST hooks Signature sha256(secret + body) is a secret-prefix construction (length-extension prone). Additional x-pghl-signature-256: sha256=<HMAC>; old header kept for existing receivers. security › keeps webhook signing secrets sealed and signs with HMAC-SHA256
L-15 Low services/messaging.ts Mail header values relied on nodemailer to drop line breaks. oneLine() for subject, to, reply-to, from name. security › email headers never carry line breaks
L-16 Low services/route-call.ts In-process calls could switch to another sub-account without a membership check (no exploitable caller found). Same membership check as x-location-id. security › callRoute refuses to switch …
L-17 Low modules/video-recycle.ts Autopilot stored a foreign website id as blog target (later calls were scoped, so no data access). Must be a website of the sub-account. tenant-isolation (member/key)
L-18 Low maskSettings The inbound webhook key was readable via GET /api/location by members without the settings area. Masked like other secrets. security › stores SMTP/SMS credentials … sealed
I-1 Info security module Exports, downloads and backups were not audited; rights changes had no detail. app.afterRead hook → export:* entries; users.rights.update with changed fields; lockouts and 2FA failures audited. privacy › exports …; logs rights changes
I-2 Info contacts No GDPR access/erasure across modules (form files were never deleted, tickets and partner data stayed). modules/privacy.ts: export/erase per contact and agency-wide by e-mail, UI buttons, MCP tools, hooks. privacy.test.ts
I-3 Info Dockerfile, compose.yaml, deploy/hostinger.compose.yaml Container ran as root; the Hostinger variant kept the git token in .git/config on the volume. USER node, cap_drop: [ALL], no-new-privileges; remote removed after clone. build
I-4 Info core/db.ts Deleted rows stayed readable in free pages of the database file. secure_delete = FAST, trusted_schema = OFF. full suite
I-5 Info objects-reports.ts A deferred report-source rebuild crashed when the app closed right after start (CLI, tests). Guarded. security › key rotation
I-6 Info dependencies npm audit: 2 moderate, dev-only (vitest/@vitest/mocker, GHSA-82fw-gwwq-j7x9 – file read through the Vitest browser/UI server). Production: 0. Lockfile: 260 packages, all with integrity hashes from registry.npmjs.org. Documented; needs the vitest 5 major upgrade (no new installs in this change). Never expose a Vitest UI/browser server. –
  • SQL: all queries parameterised; identifiers interpolated only from code or validated keys (KEY_RE, allow-lists).
  • XSS: 5,000+ public page renders over 27 page types with a stored payload – all escaped; funnel/blog pages are sandboxed (CSP: sandbox, no allow-same-origin); admin previews of owner HTML run in sandboxed iframes.
  • Webhooks: Stripe (5-minute tolerance), Twilio, Meta (unsigned refused), Todoist, OpenShorts, inbound HMAC – all verified with constant-time comparison.
  • Portals: magic links (32 bytes, 30 minutes, single use), Origin check plus per-session form token, strict CSP, X-Frame-Options: DENY; next/back parameters restricted to portal paths.
  • MCP: every tool goes through the REST API with the caller’s key (same rights, guards and audit); the isolation harness found no tool returning another sub-account’s data.
  • Mobile tokens: only /api/mobile/*, own rate limit, revoked with password changes.
  • CORS, session cookie flags, invite links, cspExtra (only code-defined https/wss origins).
  • File serving: uploads, form files, community images, lesson downloads, backups and academy docs resolve names through the database or strict patterns.
  • Upgrade Vitest to 5.x (dev-only advisory).
  • Optional PGHL_SETUP_TOKEN for the first-run setup page (today: bootstrap the admin via environment).
  • Nonce-based CSP for the remaining server pages with inline scripts (booking, forms, invoices).
  • Pin GitHub Actions by commit SHA once the CI workflows land (open-source branch).

Follow-up review: versions 0.0.37–0.0.40

Section titled “Follow-up review: versions 0.0.37–0.0.40”

Scope: everything added since the review above – record-level access and sales (sequences, quotes, duplicates), the hosted cloud mode, Rechnung 2.0, the consent ledger, forms in depth, the website builder 2.0, courses with hosted video, telephony (IVR, queues, power dialer, call intelligence), the 24 importers, the Cognee memory, the community extras and the iOS web app. Method: reading the new modules like an attacker (what can a restricted person, a tenant, the operator or an anonymous visitor reach that the author did not think of), then extending the existing harnesses – the leak harness of tests/record-access-leaks.test.ts got seeds for the data that hangs on a contact in the conversation area – and one regression test per finding in tests/sec-review-access.test.ts, tests/sec-review-cloud.test.ts and tests/sec-review-public.test.ts (each fails on the code before the fix). Severity assumes the feature is switched on (record access, cloud mode).

ID Severity Location Finding and exploit sketch Fix Test
R-1 High modules/dialer.ts, modules/record-access.ts The power dialer handed out contacts the rules hide. A person limited to their own contacts creates a call list from a tag or smart list (the list is built server-side, so the “named records” check never sees the contacts) and presses Nächster Kontakt: the answer carries name, phone, e-mail, tags, the last three notes and the last calls of any contact. The entries, callbacks and the list endpoint listed everyone too. The dialer asks app.services.recordAccess.contactFilter before it picks an entry; lists, entries and callbacks drop what the person may not open; entry routes answer 404. access › the power dialer neither lists nor hands out …
R-2 High modules/telephony.ts, call-intel.ts, voice-ai.ts, browser-phone.ts Call log and conversation intelligence were outside the rules. GET /api/calls (also ?q= over voicemail transcripts), /api/calls/:id, /recording (the audio), /notes, /api/call-intel* (full transcripts, summaries, objections, scores), /api/voice-ai/calls* and /api/phone/calls/:id/* answered for every call of the sub-account to anybody with the Konversationen area. Calls are found by id or Twilio sid. CHILD_ROUTES in modules/record-access.ts now covers these paths (id and sid) and CONTACT_LISTS filters the lists; a hidden phone field also hides the call’s numbers. access › the call log …; a call cannot be opened …; call intelligence …; the AI phone agent’s sessions …
R-3 Medium modules/email-import.ts, channels.ts, meta-channels.ts, mailbox-mail.ts, teaminbox.ts Drafts of all threads (/api/conversations/drafts: contact, subject, text), media files of a message (`/api/messages/channels meta/media/:id), mailbox attachments (/api/mail/attachments/:messageId/:index) and **POST /api/inbox/bulk`** (assign, close, snooze by conversation id) ignored the rules: a restricted person could read drafts and files of, and change, conversations they cannot open. Media and attachments are child routes of their conversation; drafts are filtered per conversation and contact; the inbox bulk route filters its ids like the other bulk routes.
R-4 Medium modules/mk-segments.ts, campaigns.ts, services/mobile-read.ts, record-access.ts Segment members, campaign recipients, the phone app’s call list (/api/mobile/calls) and reports on the Anrufe source (grouped by contact) listed names, e-mails and numbers of contacts outside the person’s view. The extended leak harness found /api/mobile/calls by itself. List filters, a contact clause in the app’s call query and a calls entry in the report row scope. access › segment members and campaign recipients …; the phone app’s call list …; reports on the call log …
R-5 Medium modules/cognee.ts The knowledge memory holds notes, contacts and conversations as text; “ask the memory” (also the MCP tool ghl_memory_recall and the in-app agent) only checked the member’s areas, so a person who sees only their own contacts could ask about every note, contact and conversation of the sub-account. For a narrowed person the recall refuses contacts, notes and conversations (wiki, tickets, courses and remembered notes stay); /api/cognee/info stops offering them. access › the Cognee memory
C-1 High modules/reports.ts The operator read every customer’s data through reports. POST /api/reports/run with scope: "all" as an admin ran over all sub-accounts – contacts, invoices, deals, grouped by contact name – without the customer’s support consent (the consent guard only watched the sub-account of the request). Same for boards that use the report engine. Admin report scopes (run, sources) skip customers not open for support (hiddenLocations). cloud › reports across all sub-accounts …
C-2 Medium modules/security.ts GET /api/admin/audit listed the audit trail of every customer (summaries carry names of contacts, invoices, pages). Entries of customers not open for support are left out. cloud › the operator’s audit log …
C-3 Medium modules/core.ts (users), modules/cloud.ts User management was a back door to a customer’s account. An operator admin could set a customer login’s password (PATCH /api/users/:id), reset its 2FA or passkeys, sign it out, delete it, or create a login of their own with the customer’s sub-account in locations – opening the account without the consent. GET /api/users listed all customers’ people. The cloud guard refuses these writes for customers’ logins and sub-accounts without consent; the list leaves the customers’ people out. cloud › user management …
C-4 Medium modules/funnels.ts, tickets.ts, agency.ts, cloud.ts A site or help center could claim somebody else’s login host. The check for a site’s custom domain looked only at other sites; a tenant could point login.client.example, the agency’s white-label domain or <other-customer>.<cloud domain> at their own page. A host decides which pages the server serves, so the claimed login address showed the claimer’s page (phishing with a valid certificate – the TLS “ask” endpoint approves every claimed host) and the real login stopped working. One check, app.services.domainTaken: the app’s own address, agency and client login domains, sites, help centers, and (cloud) the service’s domain and all its sub-domains for everybody but the operator. cloud › a site or help center cannot take over a login domain …
C-5 Medium modules/cloud.ts, services/course-video.ts, community-posts-ext.ts The storage fair-use meter counted only media uploads. Course videos (2 GB each, unlimited number), community videos, call audio and meeting recordings were free: a trial account without a card could fill the shared disk. storageUsedBytes counts all of them; the uploads ask storageGate when they start (course and community video) or in the guard (raw audio). cloud › the storage fair-use meter …
P-1 Low modules/telephony.ts verifyTwilio The Twilio signature covers the URL and the form fields. A request with another body type was verified as “URL only” while the handlers read the body – one signed request without fields (e.g. a status callback seen in a log) paired with a JSON body of the attacker’s choosing would have been accepted. Only form bodies are accepted. public › Twilio webhooks only take forms
P-2 Low modules/agent-bridge.ts The 8-character pairing code of the local bridge (it hands out a device token) was drawn with Math.random. crypto.randomInt. public › device pairing …
P-3 Low modules/telephony-dni.ts A pool that shares numbers when all are busy wrote a session row for every anonymous visit and read all of them on every visit: a flood from a few addresses grows the table and slows every page with the number snippet. At most 5,000 running sessions per pool get a row; the rest still get a number. public › a pool that shares its numbers …
P-4 Low services/community-posts-ext.ts A community member could start any number of 500 MB video uploads in sequence (only open uploads were counted); unattached ones live a day. Three videos’ worth per member and day; the team is not limited. access › community members and their videos
ID Severity Finding Status
O-1 Low Sign-up pre-hijack (cloud-signup.ts): the password is chosen on the form before the address is proven. Somebody who enters a stranger’s address and their own password gets an account for that stranger the moment the stranger clicks the verification link in the (unsolicited) mail. Fixed after 0.0.41 – see follow-up fixes.
O-2 Low Public forms, booking and chat update an existing contact by e-mail or phone (upsertContact): a visitor who enters a known phone number with their own e-mail address replaces the stored e-mail. This is how the CRM deduplicates; the owner sees the change in the timeline. Fixed after 0.0.41 – see follow-up fixes.
O-3 Info Counters and aggregates (call statistics, counts in lists, reports on sources outside the five record types) are not narrowed by record access, and automations, keys and MCP connections see everything by design (sales.md). A restricted person with the Automationen area can build an automation that reads more than they can see. Documented; give such people no automation area.
O-4 Info The operator runs the server and the database: the support consent covers the product surface (UI, API, MCP, reports, audit log, user management), not the file system. Said in the AVV text and cloud.md.

The two open low findings and two robustness items from the code-health review, each with a regression test that fails on the code before the fix (tests/sec-review-signup.test.ts in cloud mode, tests/sec-review-followup.test.ts).

ID Location Fix Test
O-1 modules/cloud-signup.ts, services/cloud-pages.ts No account and no password before the address is proven. The form’s password only counts when the verification link is opened in the browser that sent the form (a cookie set on submit – set for known and unknown addresses alike, after the same hashing work, so it is no oracle); in any other browser the link asks for the password and the form’s one is discarded. The link (24 h) is claimed by the first confirmation with a single UPDATE … WHERE verified_at IS NULL; afterwards it only continues in the browser that confirmed. The confirmation – and a Google sign-up, whose address Google proved – closes every other open sign-up for the address (status superseded, password, link and browser binding cleared), so neither an earlier password nor an earlier browser leads into the account. Provisioning refuses a sign-up without verified_at, also when a completed Checkout names it. signup › a password typed into the form by somebody else never becomes the login; … a later verification … ends the earlier sign-ups; … no oracle; never provisions … not verified; Google proves the address too
O-2 services/contacts.ts (upsertContact(…, { publicInput: true })), callers in forms.ts, wfx-triggers.ts (abandoned forms), calendars.ts, teamcal-book.ts, livechat.ts, ai-assistant.ts, voice-ai.ts (booking), webinar-register.ts, stripe.ts, ads-leads.ts Unauthenticated input never changes a stored e-mail address or phone number (an empty one is filled). A phone number only finds a contact when the input has no e-mail address: known number + another address creates a new contact (timeline note, paired by the duplicate assistant) – the dedupe logic already treats a shared number as “probably the same person”, and the booking confirmation then reaches the person who booked. A differing number on a contact found by its e-mail address is noted as an unverified alternative on the timeline. The chat widget shows a visitor only the messages from the visitor’s own start, and the chat bot answers from that chat and the name only (no e-mails or SMS of the contact, no stored numbers, address or deals), so a known address typed into the widget no longer reveals earlier conversations. Checked without change: community join and affiliate sign-up (e-mail only, matched by it), the SEPA mandate page and quote acceptance (they write no contact fields). followup › a form with a known number and another address …; … known address and another number …; the public booking page …; a chat visitor …; nor does the chat bot …; the rule is an option
U-1 modules/public-upload-quota.ts (new), forms.ts, cloud.ts Public form uploads had no disk quota per sub-account (only 12 MB per request and the per-minute brake). Now PGHL_PUBLIC_UPLOADS_MB (default 2048 MB per sub-account) and, in the cloud, the plan’s media storage (form files joined the fair-use meter) – whichever is lower. Over the cap: 413 before anything is written; the sub-account is told by e-mail and bell/push at 90 % and 100 %, once per level until the usage drops below 90 %. followup › disk quota …; signup › public form uploads and the plan’s media storage
D-1 services/werkstatt/ops.ts, modules/accounting.ts, accounting-reports.ts, services/acc-autopost.ts The last UTC “today” spots: the plugin workshop’s sample data (a sample birthday “today” did not match the context’s local today between 00:00 and 02:00 in Berlin), the accounting’s default report period and period year, and the booking date of Stripe refunds now use the sub-account’s time zone (todayYmd). followup › today in the sub-account’s time zone (00:30 on 1 January, pinned clock)

Remaining limits of these fixes: names and other non-identity fields from public input still update a matched contact (as before); two chat visitors on one contact (the real one and somebody who typed the address) both see the messages exchanged after the second one started – the team sees both in one conversation; the upload quota covers public forms (other uploads come from logged-in people and are covered by the cloud’s storage gate).

  • Stripe (/hooks/stripe, /hooks/stripe-agency): constant-time HMAC, 5-minute tolerance, event ids de-duplicated before handling, every handler scopes by the location of the event; Checkout amounts always from the product row (forms with a payment field, quotes, invoices).
  • Quote page /q/<token>: 24 random bytes, the selection is validated against the stored document, amounts are recomputed on the server, acceptance is claimed with a single UPDATE … WHERE status IN ('sent','viewed').
  • Consent: double-opt-in links (HMAC, expiry in the token and the row, a button instead of a GET), preference centre token per contact, redirect only to the sub-account’s own setting.
  • Tracking: /t/c/<id> redirects only to links the server wrote itself, trigger-link signatures cannot be extended (truncated HMAC), the pixel validates nothing it does not store.
  • Hosted course video: signed token bound to video, member and sub-account and the member’s session and a running enrolment, same-origin only, HLS names from a strict pattern, ffmpeg arguments built from server-made paths.
  • /uploads-opt/<w>/<file>: widths from a fixed list, file names through the uploads table, at most two conversions at a time, one cached copy per width and format.
  • Web push endpoints are checked against the push services’ hosts at subscription and at delivery; Slack/SNS/Meta media hosts are checked before any fetch; the importers’ absolute next links still go through safeFetch.
  • Output: a second stored-XSS sweep that also fails on a raw quote or apostrophe next to the payload (attribute breakout) over 5,200 public pages – clean. CSV exports (call analysis, funnel and course statistics, consent proof, payouts, member lists) neutralise formulas.
  • Sales data in the leak harness: deal lines, a sent quote, a meeting note, a sequence enrolment with its events and a consent proof on a foreign contact are seeded as well; none of the 500+ routes hands them to a person limited to their own records (sequence templates are account-wide by design, like workflows – the enrolment is the contact’s data).
  • Tenant export: credential columns and sealed values are stripped by name and by shape; no table with a push private key or provider credential is exported unsealed.