---
title: "Permissions — Svelte"
description: "A document can be opened with less than full power. Every plugin tells you what the user may do, so your UI only offers what works."
framework: "Svelte"
source: "https://www.embedpdf.com/docs/headless/svelte/concepts/permissions"
---

# Permissions

A document can be opened with less than full power: a link that lets people read and comment, a
signing session that only fills in fields, a reviewer who may not download. The engine refuses
everything else. This page is about building a UI that **shows only what works**, instead of buttons
that fail.

## Every verb has a check

Every call that can be refused has a check next to it, named `can` and the verb. It answers one
question: would this work right now, for this user?

```ts
annotation.canCreate(); // may they draw new annotations?
annotation.canUpdate(ref); // may they change this one?
selection.canCopy(); // may selected text leave the document?
form.canFill(ref); // may they fill in this field?
documents.canDownload(); // may they download the file?
```

A check already includes everything its call looks at: the user's permissions, the flags on the
document (a locked annotation, a read-only field) and what's selected. You never combine them
yourself, and you never read permission names: which permission a call needs is the plugin's
business, not yours.

> **The rule:** show a button when its check is true. Read the check in your markup, like any
> other state: the answer changes when the selection changes or the user gets new permissions.

**`annotation-actions.svelte`**

```svelte
<script lang="ts">
  import { useAnnotation, type AnnotationRef } from '@embedpdf/svelte/annotation';
  import DrawingTools from './DrawingTools.svelte';

  let { annotationRef }: { annotationRef: AnnotationRef } = $props();

  const annotation = useAnnotation();
</script>

{#if annotation.canCreate()}
  <DrawingTools />
{/if}
<button disabled={!annotation.canDelete(annotationRef)}>Delete</button>
```

Each control below shows only when its check says yes. Pick another role, and the document opens
again with its permissions:

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { interactionPlugin } from '@embedpdf/svelte/interaction';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { SelectionLayer, selectionPlugin } from '@embedpdf/svelte/selection';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import Controls from './Controls.svelte';
  import ReopenOnRoleChange from './ReopenOnRoleChange.svelte';

  import '../checks.css';

  const engine = localEngine();
  const plugins = [
    stagePlugin(),
    renderPlugin(),
    interactionPlugin(),
    selectionPlugin(),
    searchPlugin(),
  ];

  const welcome = async (): Promise<OpenInput> => {
    const response = await fetch('/pdfs/welcome.pdf');
    return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
  };

  // What each role may do, as permissions.
  const roles = {
    viewer: ['doc.open', 'doc.render', 'doc.text.select'],
    reviewer: ['doc.open', 'doc.render', 'doc.text.select', 'doc.text.copy', 'doc.text.search'],
    owner: [
      'doc.open',
      'doc.render',
      'doc.text.select',
      'doc.text.copy',
      'doc.text.search',
      'doc.download',
    ],
  };
  type Role = keyof typeof roles;
</script>

<script lang="ts">
  let role = $state<Role>('reviewer');
</script>

<Viewer
  {engine}
  {plugins}
  scope={roles[role]}
  initialDocuments={[{ source: welcome, name: 'welcome.pdf' }]}
>
  <div class="toolbar">
    <label class="label">
      Role
      <select class="select" bind:value={role}>
        <option value="viewer">viewer: read and select</option>
        <option value="reviewer">reviewer: also search and copy</option>
        <option value="owner">owner: also download</option>
      </select>
    </label>
  </div>
  <ReopenOnRoleChange {role} {welcome} />
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Opening…</p>
    {/snippet}
    <Controls />
    <Stage class="stage">
      <RenderLayer />
      <SearchLayer />
      <SelectionLayer />
    </Stage>
  </DocumentGate>
</Viewer>
```

**`checks.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The role, then only the controls its checks allow */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink);
}

.select,
.field {
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 10px;
}

.field {
  appearance: none;
  flex: 1 1 140px;
  min-width: 0;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 12px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.select:focus-visible,
.field:focus-visible,
.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.note {
  min-height: 18px;
  margin: 0 0 14px;
  font-size: 12px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 380px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`Controls.svelte`**

```svelte
<!-- Each control shows only when its check says yes. -->
<script lang="ts">
  import { saveFile, useDocuments, usePageList } from '@embedpdf/svelte/runtime';
  import { useSearch } from '@embedpdf/svelte/search';
  import { copySelection, useSelection, useSelectionState } from '@embedpdf/svelte/selection';

  const documents = useDocuments();
  const search = useSearch();
  const selection = useSelection();
  const hasSelection = useSelectionState((state) => state.hasSelection);
  const pages = usePageList();
  const cover = $derived(pages.current[0]?.ref);
  // A check read once into a derived value: an effect that reads it runs again only when the
  // answer changes, not on every change in the viewer.
  const canSearch = $derived(search.canSearch());
  let text = $state('PDF');
  let copied = $state('');

  // On load: a search, and some text selected, so every check has something to act on.
  $effect(() => {
    if (canSearch) void search.search({ text });
  });
  $effect(() => {
    if (cover) selection.select({ page: cover, start: 28, count: 30 });
  });

  async function copy() {
    await copySelection(selection).catch(() => {}); // the browser may refuse the clipboard
    copied = await selection.readText();
  }

  async function download() {
    saveFile(await documents.download(), 'welcome.pdf');
  }
</script>

<div class="toolbar">
  {#if canSearch}
    <input class="field" type="search" aria-label="Search" bind:value={text} />
  {/if}
  {#if selection.canCopy()}
    <button type="button" class="button" disabled={!hasSelection.current} onclick={copy}>
      Copy
    </button>
  {/if}
  {#if documents.canDownload()}
    <button type="button" class="button" onclick={download}>Download</button>
  {/if}
</div>
<p class="note">{copied ? `Copied: “${copied}”` : ' '}</p>
```

**`ReopenOnRoleChange.svelte`**

```svelte
<!-- A document keeps the permissions it opened with, so a new role opens it again. -->
<script lang="ts">
  import { untrack } from 'svelte';
  import { useDocument, useDocuments, type OpenSource } from '@embedpdf/svelte/runtime';

  let { role, welcome }: { role: string; welcome: OpenSource } = $props();

  const documents = useDocuments();
  const document = useDocument();
  // The role the open document was opened with.
  let opened = untrack(() => role);

  $effect(() => {
    const id = document.id;
    if (role === opened || !id) return;
    opened = role;
    void documents.close(id).then(() => documents.open(welcome, { name: 'welcome.pdf' }));
  });
</script>
```

## One answer per annotation

Checks that take a `ref` answer for that one annotation, field or comment. In a shared document, a
user may hold `annotations:update:self`: change their own annotations, and only look at the rest.
You don't handle that yourself. Someone else's annotation answers `canUpdate(ref) === false`, and
shows the way a locked one does: it can be selected and commented on, but has no handles. Forms work
the same: a field the user may not fill shows as read-only.

## Calling anyway is safe

Checks are there to keep your UI honest; the engine doesn't rely on them. A refused call rejects,
the same way in every plugin:

**`permission-denied.svelte`**

```svelte
<script lang="ts">
  import { isPluginError } from '@embedpdf/svelte/runtime';
  import { toFieldRef, useForm } from '@embedpdf/svelte/form';

  const form = useForm();

  async function setTotal() {
    try {
      await form.setValue(toFieldRef('total'), { value: '120.00' });
    } catch (error) {
      if (isPluginError(error, 'permission-denied')) {
        console.log(error.permission); // what was missing, such as 'doc.forms.fill'
      }
    }
  }
</script>

<button onclick={setTotal}>Set the total</button>
```

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import DownloadAnyway from './DownloadAnyway.svelte';

  import '../refused.css';

  const engine = localEngine();
  const plugins = [stagePlugin(), renderPlugin()];

  const welcome = async (): Promise<OpenInput> => {
    const response = await fetch('/pdfs/welcome.pdf');
    return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
  };

  // Read and select, but no downloading.
  const scope = ['doc.open', 'doc.render', 'doc.text.select'];
</script>

<Viewer {engine} {plugins} {scope} initialDocuments={[{ source: welcome }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Opening…</p>
    {/snippet}
    <DownloadAnyway />
    <Stage class="stage">
      <RenderLayer />
    </Stage>
  </DocumentGate>
</Viewer>
```

**`refused.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --refused: #b42318;
  --refused-tint: #fef3f2;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The call, and what came back */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.result {
  flex: 1 1 220px;
  min-width: 0;
  font-size: 13px;
  color: var(--refused);
  background: var(--refused-tint);
  border-radius: 9px;
  padding: 8px 12px;
}

.result:empty {
  visibility: hidden;
}

.result code {
  font-weight: 600;
}

/* The pages */
.stage {
  height: 380px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`DownloadAnyway.svelte`**

```svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { isPluginError, useDocuments } from '@embedpdf/svelte/runtime';

  type Result =
    | { kind: 'downloaded'; bytes: number }
    | { kind: 'refused'; code: string; permission: string | null };

  const documents = useDocuments();
  let result = $state<Result | null>(null);

  async function download() {
    try {
      const bytes = await documents.download();
      result = { kind: 'downloaded', bytes: bytes.byteLength };
    } catch (error) {
      if (isPluginError(error, 'permission-denied')) {
        result = { kind: 'refused', code: error.code, permission: error.permission };
      }
    }
  }

  // On load, the call the button makes, so the refusal shows at once.
  onMount(() => {
    void download();
  });
</script>

<div class="toolbar">
  <button type="button" class="button" onclick={download}>Download anyway</button>
  <output class="result">
    {#if result?.kind === 'downloaded'}
      Downloaded {result.bytes} bytes.
    {:else if result?.kind === 'refused'}
      Refused: <code>{result.code}</code>, missing <code>{result.permission}</code>
    {/if}
  </output>
</div>
```

The local and the cloud engine refuse the same way, so this one check covers both.

What people do with the mouse fails quietly instead: without permission to create, drawing with a
tool paints nothing and saves nothing.

## Where the limits come from

A document gets its permissions when it opens, together with who the user is.

On the local engine you give both. Once on the viewer, for every document it opens, and per document
when one needs something else:

**`viewer-permissions.svelte`**

```svelte
<script lang="ts">
  import { Viewer } from '@embedpdf/svelte/runtime';
  import { archive, engine, plugins, report } from './pdf';
</script>

<Viewer
  {engine}
  {plugins}
  identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
  scope={[
    'doc.open',
    'doc.render',
    'doc.text.select',
    'doc.annotate.read',
    'annotations:create:self',
    'annotations:update:self',
    'annotations:delete:self',
  ]}
  initialDocuments={[
    { source: report },
    { source: archive, scope: ['doc.open', 'doc.render'] }, // this one is read-only
  ]}
/>
```

- **A document's own `scope` replaces the viewer's.** It's the whole list for that document, not an
  addition, so it means exactly what it says. The same goes for `identity`.
- **`identity` is who the user is.** Permissions that say "their own" (`:self`) or "their group"
  (`:group`) are checked against it, so they need one: opening without it fails.
- **A document keeps what it opened with.** Change the viewer's `scope` or `identity`, and the
  documents you open after that get the new values.

Code that opens documents itself passes the same options to
`documents.open(source, { scope, identity })`.

Leave `scope` out and the user may do what the file allows, as in Acrobat: everything, unless the
PDF has restrictions of its own, such as no printing. That's `pdf.permissions` plus what no PDF
flag governs: downloading, importing annotations and form data, running and submitting a form,
certifying the document, and putting annotations and fields in any group. Pass the scope your users will really get, so the
checks show you the UI they'll see.

> **Limits that hold on the server**
>
> On the local engine the checks run in the browser, and the browser already has the file. With
> CloudPDF, the server checks every call: a user who may not download never receives the file, and
> one who may not copy gets where the text is, not the text itself.
>
> [See how CloudPDF checks permissions](https://www.cloudpdf.com/docs/headless/svelte/concepts/permissions)

## Every check

Each plugin's page has a Permissions section with the permission behind each check.

| Plugin                                                  | Checks                                                                                                                                   |
| :------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------- |
| [Documents](../documents/saving#permissions)            | `canDownload()` `canPrint()`                                                                                                             |
| [Rendering](../viewing/render#permissions)              | `canRender()`                                                                                                                            |
| [Page editing](../documents/pages#permissions)          | `canEdit()`, one answer for every page change; `canExtract()` for copying pages out                                                      |
| [Metadata](../documents/metadata#permissions)           | `canUpdate()`                                                                                                                            |
| [Selection](../text/selection#permissions)              | `canSelect()` `canCopy()`                                                                                                                |
| [Search](../text/search#permissions)                    | `canSearch()`, and `canSearch({ snippets: true })` for the words around each match                                                       |
| [Annotations](../annotations/with-code#permissions)     | `canRead()` `canCreate()` `canUpdate(ref)` `canDelete(ref)`; `selection.canGroup()` `selection.canUngroup()`                             |
| [Comments](../annotations/comments#permissions)         | `canReply(ref)` `canSetText(ref)` `canSetStatus(ref)` `canSetMarked(ref)` `canDelete(ref)` `canDeleteThread(ref)`                        |
| [Stamps](../annotations/stamps#permissions)             | `canPlace()` `canCreateFromAnnotations()`                                                                                                |
| [Measurements](../annotations/measurements#permissions) | `canMeasure(page)` `canCalibrate()`                                                                                                      |
| [Redaction](../annotations/redaction#permissions)       | `canMark()` `canUnmark(ref)` `canUpdateLabel(ref)` `canApply()`                                                                          |
| [Forms](../forms#permissions)                           | `canRead()` `canFill(ref)`; [building forms](../forms/building#permissions): `canDesign()` `canRestoreAttribution()` `canWriteScripts()` |
| [Signatures](../forms/signatures#permissions)           | `canSign(field)` `canCertify()` `canFill(field)` `canReadRevision()`                                                                     |

A [command](../ui/commands)'s `enabled` is where these checks go, so every toolbar button and
shortcut follows them.
