Security

What we access, where it lives, and what happens on cancel.

Tideline is small enough that we can name the actual seams — the integration list, the relational store behind DATABASE_URL, the admin allow-list, and the contact-form row that drives deletion — instead of waving at a SOC2 badge we don't have.

01 — Per integration

What we actually read from each connection.

Every integration below is wired into the home-page stack strip and listed again on /channels. The column to the right of each row names the data shape it sends to the agent — nothing more, nothing less.

02 — Storage

One relational store, scalar values only.

Postgres behind DATABASE_URL. One source of truth — every tenant-bearing row carries a scalar workspaceId, every required scalar carries a @default(...) so a deploy stays idempotent against a live DB.

Shape

Scalar cents and text only — no images, no videos, no media files of any kind.

Tenant tag

Every row carries workspaceId: String (defaulted for now — section 4 explains why).

Inbox for messages

ContactMessage (name + email + message + createdAt, written by the public POST /api/contact) lands in the company inbox at POLSIA_COMPANY_EMAIL through the Polsia email proxy — never a third-party SaaS.

03 — Who can see it

One allow-list today. A real session check later.

No SSO, no OAuth, no SCIM, no MFA — those are not wired. Today the gate is an explicit CSV of admin emails read from the env.

The requireAdmin() helper in src/lib/admin.ts:41-66 reads the ADMIN_EMAILSenv CSV and produces a 403 in production unless the caller's email is on the list. In non-production it permits any request so the admin list is reachable locally. When better-auth installs, requireAdmin will swap to auth.api.getSession(...) + a role check; the helper already exposes the hook.

No third-party staff read your data. The founder is the only human who ever touches the database.

04 — Account deletion

Request it once. Every row is removed.

Honest about today's posture: until better-auth installs, every row is shared across visitors — the tenant isolation is enforced at the application boundary by the single workspaceId defaulting to "default".

A deletion request today is a ContactMessage row written by POST /api/contact: name + email + message + createdAt. A human reads it, removes the tenant-scoped rows (VoiceProfile, dashboard tiles, rules, guardrails, voice-profile samples), and replies on the same thread.

Once better-auth installs, the same intent becomes an in-app action gated by the allow-list from §3 — the schema swap turns tenantKey into userId and cascade-deletes the rows the user owns.

05 — Voice-training data

The literal samples you uploaded, no embeddings, no residue.

Honest about the schema: VoiceProfile stores the plain text of the 3..5 replies you submit during /onboard. There is no Embedding table — the copy below cites the field by name.

prisma/schema/voice-profile.prisma:9-22 defines VoiceProfile { samples String[] } — the literal 3..5 prior replies uploaded during /onboard, plus tone, an optional name?, the workspaceId, and the tenantKey scalar. There is no @relation anywhere — no Embedding / VectorStore row lands on the database, full stop.

On deletion the row and its scalar workspaceId are removed. Nothing else references VoiceProfile (the schema header comment states this explicitly), so the row cannot leave an orphan behind.

FAQ

Five trust questions, answered.

Each row below points back to one of the five sections above.

Still have a question?

The help center answers the wiring questions.

The help center walks through connection setup, the digest format, and the way the agent reacts to drift per channel. Pricing scales with the stores you connect and the reply volume you handle.