# Masking - Field Redaction

Hide sensitive data from unauthorized users while showing it to those with permission.

## Detection (the compiler asks, you decide)

Quickback scans every table for columns whose names match its sensitive-column patterns — `email`, `ssn`, `apiKey`, `stripeSecret`, `refreshToken`, and the rest of the table below.

It does **not** decide what to do about them. It **refuses to compile** until you do:

```
customers: 2 columns match Quickback's sensitive-column patterns but carry no masking decision.

Quickback will not guess whether a value is confidential — you decide, per column:
  - email   { type: 'email', show: { or: 'owner', roles: ['admin'] } }
  - apiKey  { type: 'redact', show: { or: 'owner', roles: ['admin'] } }

Add a `masking` block to "customers". Keep the suggested rule to mask the
column, or set `false` to record "reviewed — not sensitive here" (the value is then
returned in full and stays filterable / sortable / searchable):

  masking: {
    email: { type: 'email', show: { or: 'owner', roles: ['admin'] } },  // mask it
    // email: false,  // …or this instead: reviewed, not sensitive here
    apiKey: { type: 'redact', show: { or: 'owner', roles: ['admin'] } },  // mask it
    // apiKey: false,  // …or this instead: reviewed, not sensitive here
  },

Every detected column needs one or the other — an omitted key is not a decision.
```

Paste the block, delete the wrong line from each pair, and you're done:

```typescript
masking: {
  // Yes — mask it. Redacted in responses, and only `admin` may filter/sort/search it.
  apiKey: { type: 'redact', show: { roles: ['admin'] } },

  // No — reviewed, not sensitive here. Returned in full, freely queryable.
  email: false,
}
```

### Why a hard error and not a default

Whether `customers.email` is PII to hide or the display label of a contact list is a product decision, and the compiler has no way to know which. Both possible defaults are wrong somewhere:

- **Auto-mask silently** and you ship a broken list view, or you *think* a value is protected because a warning said so.
- **Ignore it silently** and a `stripeSecret` column leaks in every response.

So the compiler asks. A false positive costs you one line (`cc: false`) — which is also a durable, reviewable record that somebody looked.

It also removes a bug class outright. When detection can never quietly supply a rule, the set of masked columns is the set you declared, so no two surfaces can disagree about it. That exact disagreement was a real defect: through v0.58.3 the response path applied declared-only masking while realtime and live views applied detected-plus-declared, so an auto-detected secret was redacted over WebSocket and returned in **plaintext** on `GET`.

### What a decision controls

| Surface | `{ type, show }` rule | `false` |
| :--- | :--- | :--- |
| `GET /:id`, list, named views, `POST`, `PATCH`, `PUT`, batch | Masked per role | Returned in full |
| Realtime frames + live-view deltas | Masked per-subscriber-role | Sent in full |
| `?filter=` / `?sort=` | Only roles in the [query gate](#query-gate-filter--sort--search) | Unrestricted |
| `?search=` | Dropped from the default searchable set | Searchable |
| FK display label / `embeddings` / action egress scan | Blocked — see [Egress protection](#egress-protection) | Allowed |

### Sensitive Keywords & Default Masks

| Pattern | Default Mask | Description |
| :--- | :--- | :--- |
| `email` | `email` | p***@e******.com |
| `phone`, `mobile`, `fax` | `phone` | ******4567 |
| `ssn`, `socialsecurity`, `nationalid` | `ssn` | *****6789 |
| `creditcard`, `cc`, `cardnumber`, `cvv` | `creditCard` | ************1234 |
| `iban` | `redact` | [REDACTED] |
| `password`, `secret`, `token`, `apikey`, `privatekey` | `redact` | [REDACTED] |
| `accesstoken`, `refreshtoken`, `clientsecret`, `signingsecret`, `bearer` | `redact` | [REDACTED] |
| `stripe`, `webhook` | `redact` | [REDACTED] |

> **Smart Pattern Matching**
>
> Detection also matches suffixes like `workEmail`, `homePhone`, `apiSecret`, `stripeApiKey`, `webhookSecret`, `customerStripe`, or `orderWebhook`. Every match needs a decision — including the aggressive ones. `cc: false` on an accounting table's carbon-copy column is a one-line answer.


The rule shown in the error is a *suggestion*, not a default that applies on its own — nothing is masked until you write it down.

#### Tables without an owner column

The suggested predicate is `show: { or: 'owner', roles: ['admin'] }` — admins always see the value, the row's owner sees their own. If you keep it on a table with no owner column at all (no `userId` / `ownerId` and no `q.scope('owner')`), the compiler drops the `owner` clause and warns, so the rule is still safe but transparent:

```bash
[Warning] Auto-masking on "people.email" requested owner OR-show, but "people"
  has no "ownerId" column. Falling back to roles-only (roles: ["admin"]).
  Declare `masking: { email: { show: { roles: [...] } } }` explicitly to
  silence this and pick a real predicate.
```

Drop the `or: 'owner'` clause and pick a real predicate on an ownerless table.

### Choosing the rule

If you want to show a sensitive field to everyone, or use a different rule, that's just what you write in the `resource.masking` block:

{/* doc-compile: skip — one-key excerpt: the point is the `masking` block that overrides the auto-detected defaults. The same `candidates` table is declared whole in "Basic Usage" immediately below, so repeating the columns here would only bury the override. */}
```typescript
export default defineTable(candidates, {
  masking: {
    // Masked, but visible to everyone (a rule with a wide-open audience)
    email: { type: 'email', show: { roles: ['everyone'] } },

    // Recruiter and above only
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },

    // Not masked at all, and not query-restricted either
    linkedinUrl: false,
  }
});
```

Your rule replaces the suggestion **outright** — the suggested rule is never merged into yours. That also means the rule is where you set the [query gate](#query-gate-filter--sort--search): it defaults to `show.roles`, so a list UI that sorts by `email` for every signed-in member needs the roles spelled out.

```typescript
masking: {
  email: {
    type: 'email',
    show: { roles: ['admin'] },       // still redacted in the response body…
    query: { roles: ['member', 'admin'] }, // …but members may sort/filter by it
  },
}
```

## Basic Usage

```typescript
// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  guards: { createable: ["name", "email", "phone", "resumeUrl"], updatable: ["name", "phone"] },
  masking: {
    email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
  },
  read: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
  create: { /* ... */ },
  update: { /* ... */ },
  delete: { /* ... */ },
});
```

## Built-in Mask Types

| Type | Example Input | Masked Output |
|------|---------------|---------------|
| `'email'` | `john@yourdomain.com` | `j***@y*********.com` |
| `'phone'` | `555-123-4567` | `******4567` |
| `'ssn'` | `123-45-6789` | `*****6789` |
| `'creditCard'` | `4111111111111111` | `************1111` |
| `'name'` | `John Smith` | `J*** S****` |
| `'redact'` | `anything` | `[REDACTED]` |
| `'custom'` | (your logic) | (your output) |

## Configuration

```typescript
masking: {
  // Basic masking - everyone sees masked value
  phone: { type: 'phone' },

  // Show unmasked to specific roles
  email: {
    type: 'email',
    show: { roles: ['hiring-manager', 'recruiter'] }
  },

  // Show unmasked to owner (createdBy === ctx.userId)
  resumeUrl: {
    type: 'redact',
    show: { or: 'owner' }
  },

  // Custom mask function
  externalProfileUrl: {
    type: 'custom',
    mask: (value) => value.slice(0, 4) + '...' + value.slice(-4),
    show: { roles: ['recruiter'] }
  },
}
```

## Show Conditions

```typescript
show: 'never' | {
  roles?: string[];    // Unmasked if user has any of these roles
  or?: 'owner';        // Unmasked if user is the record owner
}
```

The `'owner'` condition compares against the owner column declared via `q.scope('owner')` (or auto-detected from a `userId` / `ownerId` column). If no owner scope is present, it falls back to `createdBy`.

### `show: 'never'` — masked for everyone, deliberately

```typescript
masking: {
  emailNormalized: { type: 'email', show: 'never' },
}
```

`show: { roles: [] }` has the same effect, but it doesn't say the same thing. An empty role list reads as an oversight, and it is also what authors fall back to when the audience they wanted is **inexpressible** — masking gates on org and scoped roles, so a table gated `access: { userRole: ['sysadmin'] }` cannot name its own audience in a mask. Two different intentions, one spelling, and no way for the next reader to tell them apart.

Write `'never'` when nobody is the intent. Any other string is a compile error rather than a guess — `show: 'admin'` is a plausible typo, and masking-for-everyone is the wrong thing to assume from it.

> This does **not** add a `userRole` axis to masking. Gating a mask on Better Auth user roles (`sysadmin`, `appmanager`) is still refused at compile time. `'never'` only lets the deliberate case say so out loud; a mask that wants the same audience as a `userRole`-gated table still has to go through a gated view.


### Relationship-aware field projection

For relationship-specific field visibility, use a named view gated by the
relationship role. The relationship resolver runs once at the access layer and
the view projects only the fields that caller may receive.

Quickback deliberately does not run per-row database lookups from
`masking.<field>.show`: doing so would turn list masking into an N+1 query
pipeline. A relationship-backed `show.roles` entry is therefore a clear
compile-time refusal, never a silently ignored condition or a broader grant.

### Named gates in masking

[Named gates](/define/authz-roles#named-gates-authzrules) (`authz.rules`) follow the same discipline in `show:`:

- A **who-only** gate expands like a named role (its flat role list; the owner-gated flat-role constraint above applies).
- A gate carrying a **`record:`** predicate is a **compile error** — masking cannot evaluate record conditions, so the record dimension would be silently dropped.
- A gate carrying **firewall lanes** is a **compile error** — masking has no firewall array to satisfy, so the gate's row lanes would silently not apply. Reference a who-only rule (or inline the roles) in `show:`, and stamp the firewall gate on the resource's `firewall:` instead.

**Supported patterns**, depending on intent:

- **Gate on BA roles only**: keep masking simple and put the relationship check at the access layer instead — `read.views.<v>.access: { roles: ['guest'] }` returns a curated field projection that excludes the sensitive column entirely for non-staff callers, without involving masking.
- **Move the field into a view that's gated by the relationship-role at access**: same pattern — column-level security via field projection rather than per-row masking.

The schema slot remains reserved for a future batched async-mask pipeline, so
adding that execution mode will not require changing the surrounding masking
shape.

## Query Gate (filter / sort / search)

Masking has a second concern beyond hiding values in the response: a non-admin caller could enumerate masked PII via the query string — e.g. `?filter=email:like:%@target.com` — and infer presence even when the response payload is redacted. The `query` allowlist closes this:

```typescript
masking: {
  email: {
    type: 'email',
    show: { roles: ['admin'] },
    // Optional — defaults to show.roles when omitted
    query: { roles: ['admin'] },
  },
}
```

`query` is a single role gate that covers `?filter`, `?sort`, and `?search` uniformly. When omitted, it inherits from `show.roles` — "if you can see it, you can query it" — which is the right shape for almost all PII.

A view can opt back in for a higher-privileged role pair by listing the column in its own `query.{filterable,searchable,sortable}` allowlist. The compiler validates at build time that the view's access roles intersect the masking `query.roles` (or `show.roles` when `query` is omitted), so the opt-in can never reach a caller who'd see the value masked.

A column you answered with `false` is not gated at all — it stays freely filterable, sortable and searchable, which is the point of saying "not sensitive here".

> **The build-time check sees every masked column**
>
> Because the [decision gate](#detection-the-compiler-asks-you-decide) guarantees every detected column is in your `masking` block, this view check covers all of them. There is no category of "masked but invisible to the validator" — that gap existed only while detection could silently supply rules the validator never saw.


```typescript
masking: {
  email: { type: 'email', show: { roles: ['admin'] } },
  // query: defaults to show.roles → ['admin']
},
read: {
  views: {
    pipeline: {
      fields: ['id', 'name'],
      access: { roles: ['member', 'admin'] },
      query: {
        searchable: ['name'],   // email NOT listed → not searchable on this view
      },
    },
    full: {
      fields: ['id', 'name', 'email'],
      access: { roles: ['admin'] },
      query: {
        filterable: ['email'],  // ✓ admin satisfies email's query gate
        searchable: ['name', 'email'],
      },
    },
  },
},
```

`query` is the unified replacement for the legacy per-capability masking flags
(`filterable`, `searchable`, `sortable`). If an older definition still uses
one, the compiler stops with the exact migration to the single role gate.

## Egress protection

Masking redacts a column on the **read paths** (`GET /:id`, list, views, realtime) per role. It does **not** automatically follow the value if you copy it into a *derived* artifact that bypasses those read paths — a different record, an html/text string, or an outbound payload. Quickback guards those egress points so a masked token/PII can't quietly ship in the clear:

- **Compile-time block (declarative).** A masked column is rejected at build time if it appears in `embeddings.fields` / `embeddings.metadata` (the value would be embedded and stored beside the vector, and returned in search hits) or is used as the table's `displayColumn` (it would render as the FK label inside every referencing record).
- **Compile-time scan (actions).** A record-based action that copies `record.<maskedColumn>` into another record's column, an html/text template that gets stored, or a payload object fails compilation. Plain reads, comparisons, logging, and tagged templates (``sql`…${record.col}` ``) are fine — only the storage/payload sinks are flagged.
- **Realtime → webhook.** Rows pushed to a realtime webhook sink are masked (an external sink carries no role, so masked columns are redacted), matching what WebSocket subscribers see.

> **Egress protection follows your decision**
>
> These checks read your `masking` block, which the [decision gate](#detection-the-compiler-asks-you-decide) guarantees covers every detected column. A column answered with a rule is egress-protected; a column answered `false` is not — you reviewed it and said it isn't confidential, so copying it into an embedding or an outbound payload is allowed.


### Revealing a value on purpose: `unmask()`

When an action legitimately needs the raw value (e.g. calling an external API with a token), route it through `unmask()` — the sanctioned, audited reveal. It returns the raw value and records a best-effort `masked_reveal` entry in the audit database when one is configured.

```typescript
import { unmask } from '../../../lib/masking-reveal';

// in an action's execute():
const token = await unmask(c, record.apiToken, 'apiToken');
await fetch(externalUrl, { headers: { Authorization: `Bearer ${token}` } });
```

### Opting a column out: `egress: 'allow'`

If a column is masked for display tidiness rather than secrecy, opt it out of both the compile-time block and the action scan:

```typescript
masking: {
  shortCode: { type: 'redact', show: { roles: ['admin'] }, egress: 'allow' },
}
```

`egress` defaults to `'block'` (fail-closed). The action scan is an
accident-prevention layer: it catches direct `record.col` → sink flows without
pretending to be a whole-program information-flow proof. Aliased values,
whole-row spreads, and values re-fetched through `db.select()` remain ordinary
application code. Pair masking with the [encryption
pillar](/define/encryption) whenever the field needs a
cryptographic boundary at rest or from the server itself.

## Complete Example

```typescript
// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required().filterable('like').searchable(),
    email:          q.text().required().filterable('eq').searchable(),
    phone:          q.text().optional().filterable('eq'),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  guards: {
    createable: ['name', 'email', 'phone', 'resumeUrl'],
    updatable:  ['name', 'phone'],
  },
  masking: {
    email:     { type: 'email',  show: { roles: ['admin+'] } },
    phone:     { type: 'phone',  show: { roles: ['admin+'] } },
    resumeUrl: {
      type: 'custom',
      mask: () => '[RESUME LINK HIDDEN]',
      show: { roles: ['admin+'] },
    },
  },
  read: {
    // "+" role expansion requires auth.roleHierarchy in quickback.config.ts
    access: { roles: ['member+'] },
    views: {
      pipeline: {
        fields: ['id', 'name'],
        access: { roles: ['member+'] },
        query: { searchable: ['name'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'phone', 'resumeUrl'],
        access: { roles: ['admin+'] },
        query: {
          filterable: ['email', 'phone'],
          searchable: ['name', 'email'],
        },
      },
    },
  },
  create: { access: { roles: ['admin+'] } },
  update: { access: { roles: ['admin+'] } },
  delete: { access: { roles: ['owner'] }, mode: 'soft' },
});
```
