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
privateororg_sharedaccess- 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-scriptsdefault-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-ancestorsset toPUBLIC_BASE_URL, plusWEB_ORIGINwhen that origin is different
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:
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_sharedartifacts in the caller’s current organization, including other people’s work
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 themember 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:
<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 orAuthorization: Bearer <api-key>. They do not accept x-api-key as a credential. Create keys in the web app, or with the session recipe. 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.
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 rejectsaccessType 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.