> ## Documentation Index
> Fetch the complete documentation index at: https://docs.factrove.app/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Describe what the running code does. Do not invent hosted pricing, launch dates, or security guarantees. Share and viewer links are a configured base URL plus an artifact path. Show localhost and a placeholder origin such as https://library.example.com.

# Concepts

> Artifacts, HTML delivery, access, organization membership, and how links are built.

## Artifact

An artifact is one uploaded HTML document. The API stores metadata in PostgreSQL and the bytes in object storage. The current HTML object key and its content revision are stored on the artifact row together with the size. The API generates the key. Clients never send it, and HTTP responses do not include it. The public filename stays `{artifactId}.html`. New writes use `{artifactId}/{contentId}.html`. Older rows keep `{artifactId}.html`.

Replacing content keeps the same id, access, and share token. Another replace of the same revision is `409 CONTENT_CONFLICT`. A rare `500` after the object is written can still mean the new bytes are live; GET the artifact before retrying. Factrove currently exposes only the current content. Replacement provides no version-history or undo interface and normally removes the previous object.

The name is the label in the library, the viewer header, and a public share. It is stored on the artifact. It is not the object key, and it is not the public filename. The public filename stays `{artifactId}.html`. A missing name falls back to that filename, then to the id. Replacing content keeps the name unless the request sends a new one. A name is one line of 1 to 120 characters. There is no request that clears it. When an upload omits the name and the file name without `.html` or `.htm` is empty, longer than 120 characters, or not one line, the stored name is null and the upload still succeeds.

Each artifact has:

* a UUID
* an optional human-readable name
* a creator
* an optional organization
* `private` or `org_shared` access
* filename, size, and timestamps
* a viewer URL and a content URL
* whether a public share is active, and whether that share has a password

## Prepare an artifact

Uploads are one non-empty UTF-8 `.html` or `.htm` file of at most 10,485,760 bytes. The API rejects an empty file, a file that is not valid UTF-8 (`400 INVALID_REQUEST`), a larger file (`413 FILE_TOO_LARGE`), and any other extension (`415 UNSUPPORTED_FILE_TYPE`).

The viewer iframe uses `sandbox="allow-scripts"`. The HTML response sends this content security policy:

* `sandbox allow-scripts`
* `default-src 'none'`
* `script-src 'unsafe-inline'`
* `style-src 'unsafe-inline'`
* `img-src data: blob:`
* `font-src data: blob:`
* `connect-src 'none'`
* `form-action 'none'`
* `object-src 'none'`
* `frame-src 'none'`
* `child-src 'none'`
* `worker-src 'none'`
* `base-uri 'none'`
* `frame-ancestors` set to `PUBLIC_BASE_URL`, plus `WEB_ORIGIN` when that origin is different

A self-contained file with inline scripts, inline styles, and images or fonts embedded as `data:` or `blob:` URLs can render. A CDN library, a linked stylesheet, a relative asset, an API request, a form, and a nested frame do not work under this policy. `frame-ancestors` keeps an arbitrary third-party site from framing the artifact. These are the delivery rules the viewer applies. They are not a broader security guarantee.

## Markdown conversion

`get --format markdown` and `GET /api/v1/artifacts/{id}/content?format=markdown` convert the stored HTML on the way out. Markdown is not saved. The converter parses the stored HTML. It does not run JavaScript, so text that a script would insert is absent. It drops `script`, `style`, `noscript`, `iframe`, `object`, `embed`, `template`, `link`, `meta`, `title`, `base`, `svg`, and `math`. Treat the result as best-effort text. It does not preserve layout, interactive behavior, or dynamically generated content.

Limits, from the converter and its worker pool:

| Limit | Value | Failure |
| - | - | - |
| Input | 10,485,760 bytes | `413 CONTENT_TOO_LARGE` |
| Output | 16 MiB | `422 CONTENT_TOO_COMPLEX` |
| Time | 5 seconds | `422 CONTENT_TOO_COMPLEX` |
| Nesting | deeper than 100 levels is flattened to visible text | none |
| Memory | worker old-generation cap 256 MiB | `422 CONTENT_TOO_COMPLEX` |
| Other complexity | parser or render failure inside those bounds | `422 CONTENT_TOO_COMPLEX` |
| Pool busy | see below | `503 CONVERSION_UNAVAILABLE` |

`503 CONVERSION_UNAVAILABLE` does not set `Retry-After`. `MARKDOWN_CONVERSION_CONCURRENCY` is how many conversions run at once (1 to 4, default 2). `MARKDOWN_CONVERSION_QUEUE` is how many may wait (0 to 8, default 2). A blank value, or a value that does not start with digits, uses the default. `parseInt` keeps a leading integer, so `1.5` is 1 and `0.5` is 0. An integer outside the range is clamped, which turns concurrency `0.5` into 1 and leaves queue `0.5` at 0. A full queue returns `503 CONVERSION_UNAVAILABLE`.

## Access

* **private**: readable only by the creator
* **org\_shared**: readable by the creator and by members of the artifact's organization

`org_shared` upload without a current organization is `400 ORGANIZATION_REQUIRED`.

The owner of a private artifact can share it with the current organization later. In the viewer, and on that artifact's library row, **Share with** the organization name asks for confirmation, then calls `PATCH /api/v1/artifacts/{id}` with `{ "accessType": "org_shared", "organizationId": "<current organization id>" }`. The id is the organization named in that confirmation. If it is no longer the current membership when the share is saved, the API returns `409 ORGANIZATION_CHANGED` and the page asks again. A session that was revoked or expired before that save reads the session returns `401 UNAUTHENTICATED` and leaves the artifact private. The conditional update is the linearization point. A membership change or revocation that commits after that snapshot can land before the share commits and does not cancel the share. Members of that organization can then view the artifact. The same control is hidden for anyone who is not the creator, for an artifact that is already organization-shared, and when the signed-in user has no current organization. The API does not offer a way back to private. A public link is a separate step and is left unchanged.

The library has two views:

* **mine**: artifacts the caller created
* **org**: `org_shared` artifacts in the caller's current organization, including other people's work

A private artifact can still record an organization. That does not put it in the organization list.

## Organization membership

User and Organization come from Better Auth. Runtime sign-in is email and password. Organization HTTP routes stay unmounted. Membership is read from the `member` table.

Signup creates a user and no membership. `pnpm db:seed` upserts the fixture user `local-user` and the organization `local-org` and does not insert a member row. `local-user` has no password. `pnpm db:claim-local-artifacts` adds memberships only for organizations already referenced by artifacts it reassigns from `local-user`.

An administrator provisions membership by inserting a row. With exactly one membership, that organization is current for an API key and for a session that has no active organization. The columns match the `member` table:

```sql theme={null}
insert into member (id, "organizationId", "userId", role, "createdAt")
values ('member-example', 'local-org', '<user-id>', 'member', now());
```

Look up `<user-id>` with `select id from "user" where email = 'you@example.com'`. The organization id must already exist. `local-org` is the seeded organization.

The current organization is the session's active organization when the user is a member of it. Otherwise it is the user's only membership. With more than one membership and no active organization, it is null. The runtime session schema includes `activeOrganizationId` with `input: false`, so a stored value is returned with the session and callers cannot set it on sign-in. Organization HTTP routes stay unmounted. An id is ignored unless that user has a membership for it. An API key has no active organization, so it uses the sole-membership rule. `factrove upload --access org_shared` needs that current organization. The example fails with `400 ORGANIZATION_REQUIRED` until the membership exists.

## Identity

Product routes accept a session cookie or `Authorization: Bearer <api-key>`. They do not accept `x-api-key` as a credential. Create keys in the web app, or with the [session recipe](https://docs.factrove.app/api-reference/overview.md#create-an-api-key-from-a-session). The secret is shown once. The plugin stores a hash.

The CLI sends the bearer key. Managing keys requires a browser session and rejects `Authorization`.

## Links

`PUBLIC_BASE_URL` is the origin for viewer URLs and public share links. When it is unset, those links use `WEB_ORIGIN`.

| Kind | Shape |
| - | - |
| Private viewer | `{PUBLIC_BASE_URL}/artifacts/{id}` |
| Public share | `{PUBLIC_BASE_URL}/share/{id}/{token}` |
| Authenticated content | `{API_PUBLIC_ORIGIN}/api/v1/artifacts/{id}/content` |
| Public content | `{API_PUBLIC_ORIGIN}/api/v1/public/artifacts/{id}/{token}/content` |

Local examples use `http://localhost:3000` and `http://localhost:3001`. A self-hosted install uses whatever origin you configure, such as `https://library.example.com`.

The share token is not the stored column. The API stores ciphertext and a hash. Rotating the key material that encrypts tokens makes existing public URLs return 404.

## Current limits

The HTTP API has no artifact-delete route. Replacement rejects `accessType` and keeps the access chosen at create. `PATCH /api/v1/artifacts/{id}` is the only access change, and it only goes from `private` to `org_shared` in the organization the caller confirmed, which must be the current membership. A different organization at that conditional update is `409 ORGANIZATION_CHANGED` and does not write. A session that was revoked or expired before the update reads it is `401 UNAUTHENTICATED` and does not write. A membership change or revocation that commits after that snapshot can land before the share commits and does not cancel the share. Repeating it for an artifact that is already `org_shared` is a 200 that does not move the artifact or change its public share, whichever organization id the body names and whether or not the caller has a current organization. The CLI has no delete command and no flag that changes access on an existing artifact.

Stopping the API and web processes leaves PostgreSQL and object storage in place. `pnpm infra:down` stops local PostgreSQL and Garage and keeps their volumes. `pnpm infra:reset` deletes those local volumes after confirmation. Objects in a remote bucket stay. Saved CLI credentials under `~/.factrove` stay until those files are deleted. Details are in [Self-hosting](https://docs.factrove.app/guides/self-hosting.md#removing-an-install).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.