Skip to main content
Glama
README.md
# portego

A single TypeScript application. [Hono](https://hono.dev) serves the API, and
[Vite](https://vite.dev) builds the React client. Bun runs both.

![The artifact gallery, with folders, tags, and status filters](docs/images/dashboard.png)

## Connect an agent

Two MCP servers exist. The remote one at `/mcp` on the deployment lists, reads,
and uploads artifacts. The local `portego-upload` server adds styled artifact
creation and validation, and uploads through the same deployment.

### Claude Code

Install the plugin from the marketplace in this repository. It carries the
local server, asks for the address of the deployment, and adds the
`/portego-upload:create-artifact`, `/portego-upload:share-html`, and
`/portego-upload:share-markdown` skills:

```
/plugin marketplace add laguna-engineering/portego
/plugin install portego-upload@portego
```

The plugin also reads artifacts and their comments. To use the deployment's
other tools, such as versions, status, folders, and entries, add the remote
server for every project:

```sh
claude mcp add --scope user --transport http portego https://share.acme.example/mcp
```

Sign-in opens a browser. Over SSH the OAuth callback needs a forwarded port;
[docs/mcp.md](docs/mcp.md#connecting-a-client) shows the commands.

### Pi

Pi connects to the remote server with a pre-registered client id. Host the
client metadata document as described in [docs/mcp.md](docs/mcp.md), then add
the server to `~/.config/mcp/mcp.json`:

```json
{
  "mcpServers": {
    "portego": {
      "url": "https://share.acme.example/mcp",
      "auth": "oauth",
      "oauth": {
        "clientId": "https://share.acme.example/mcp-clients/pi.json",
        "redirectUri": "http://localhost:19876/callback",
        "scope": "artifacts:read artifacts:write offline_access"
      }
    }
  }
}
```

The first tool call opens the browser for sign-in. After the server is
redeployed, run `/mcp reconnect portego` in Pi so it refreshes its cached tool
list.

Any other MCP client starts the local server with `npx -y portego-upload` and
passes `PORTEGO_ORIGIN` in the server entry's `env`.

## Requirements

- [Bun](https://bun.com) 1.4 or later
- Node.js 24 (see `.nvmrc`) if you run editor tooling outside Bun

## Setup

```sh
bun install
cp .env.example .env
```

Fill in `.env`. `SESSION_SECRET` can stay empty in development, but the
admission policy cannot: the server refuses to start when no one could be
admitted. See [docs/authentication.md](docs/authentication.md).

## Development

```sh
bun run dev
```

This applies the database schema, so a new clone needs no separate step, and a
pull that adds a migration needs none either. Then it starts two processes,
plus the local issuer described below when `.env` points at it:

- Hono on `http://127.0.0.1:3000`
- Vite on `http://127.0.0.1:5173`

Open the Vite URL. Vite forwards `/api`, `/auth`, and `/healthz` to Hono, so
client code uses the same paths in development and in production.

### Signing in without a real provider

A local OpenID Connect issuer signs any visitor in as one fixed person. Point
`.env` at it:

```sh
AUTH_PROVIDERS=oidc
OIDC_ISSUER_URL=http://127.0.0.1:9876
OIDC_CLIENT_ID=test-client-id
OIDC_CLIENT_SECRET=test-client-secret
AUTH_ALLOW_ALL_AUTHENTICATED=true
AUTH_ALLOWED_EMAIL_DOMAINS=
```

`.env.example` sets `AUTH_ALLOWED_EMAIL_DOMAINS`. Clear it: the server does not
start when both admission policies are set.

`bun run dev` sees the loopback issuer URL and starts the issuer before the
server, which reads the discovery document at startup and skips the provider
when it cannot. For `bun run dev:server` or `bun run migrate` on their own,
run `bun run dev:issuer` first. `DEV_ISSUER_EMAIL` and `DEV_ISSUER_NAME`
change the person it signs in.

## Production

```sh
bun run build
bun run start
```

`build` writes the client to `dist/client` and the bundled server to
`dist/server`. `start` runs the bundle with `NODE_ENV=production`, serves the
built client, and answers the API on the same port.

Startup fails with a message naming the variable when a required production
value is absent.

[docs/deployment.md](docs/deployment.md) covers a single-host deployment: nginx, the
systemd units, certificates, and the deploy and rollback commands.
[docs/docker.md](docs/docker.md) covers the container image and a Compose
example with Caddy. [docs/branding.md](docs/branding.md) covers replacing the
name, logos, and colors.

## Commands

| Command              | Purpose                                     |
| -------------------- | ------------------------------------------- |
| `bun run dev`        | Start the API and the client dev server     |
| `bun run build`      | Build the client and the server for release |
| `bun run migrate`    | Apply the database schema                   |
| `bun run reconcile`  | Compare artifact metadata with files on disk |
| `bun run start`      | Run the production build                    |
| `bun run typecheck`  | Type-check the application and upload tool  |
| `bun run typecheck:tools` | Type-check the upload tool only         |
| `bun run lint`       | Lint and check formatting                   |
| `bun run lint:fix`   | Apply the safe lint and format fixes        |
| `bun run test`       | Run the server and component tests          |
| `bun run test:server`| Run the server tests                        |
| `bun run test:web`   | Run the component tests, with a DOM         |
| `bun run test:e2e`   | Run the browser tests                       |
| `bun run test:ci`    | Run every suite the way CI does             |

## Environment variables

| Name             | Default        | Notes                                     |
| ---------------- | -------------- | ----------------------------------------- |
| `NODE_ENV`       | `development`  | `development`, `test`, or `production`     |
| `APP_NAME`       | `Portego`      | Shown on the sign-in page and by the OAuth consent screen. |
| `HOST`           | `127.0.0.1`    | Loopback by default. A reverse proxy is the public listener. |
| `PORT`           | `3000`         |                                           |
| `CLIENT_DIST`    | `dist/client`  | Built client directory. Must be a relative path. |
| `SESSION_SECRET` | none           | At least 32 characters. Required when `NODE_ENV=production`. |
| `APP_URL`        | `http://localhost:5173` | Public origin. OAuth callback URLs are built from it. Required when `NODE_ENV=production`. |
| `CONTENT_URL`    | `http://127.0.0.1:5173` | Origin serving artifact previews. Must be a different host from `APP_URL`. Required when `NODE_ENV=production`. |
| `DATA_DIR`       | `data`         | SQLite and artifact files. Production uses `/var/lib/portego`. |
| `ARTIFACT_MAX_BYTES` | `5242880`  | Largest upload accepted, in bytes (5 MiB).  |
| `ARTIFACT_MAX_IMAGES` | `20`      | Image files one upload may carry. Each image is at most 10 MiB. |
| `ARTIFACT_IMAGES_MAX_BYTES` | `52428800` | Combined size of one upload's images, in bytes (50 MiB). |
| `PRIVATE_ARTIFACTS` | `true`     | `false` turns private artifacts off. The server does not start while private artifacts exist. |
| `AUTH_PROVIDERS` | none           | Enabled provider ids, comma separated. Known ids: `google`, `oidc`. |
| `AUTH_ALLOWED_EMAIL_DOMAINS` | none | Domains admitted after authentication. |
| `AUTH_ALLOW_ALL_AUTHENTICATED` | `false` | Admit every authenticated identity. |
| `GOOGLE_CLIENT_ID` | none         | Required when `google` is enabled.        |
| `GOOGLE_CLIENT_SECRET` | none     | Required when `google` is enabled.        |
| `GOOGLE_HOSTED_DOMAIN` | none     | Workspace domain, enforced against the id token `hd` claim. |
| `OIDC_ISSUER_URL` | none          | Issuer or discovery URL. Required when `oidc` is enabled. |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | none | Required when `oidc` is enabled. |
| `OIDC_SCOPES`    | `openid profile email` | Scopes the OIDC provider is asked for. |
| `OIDC_LABEL`     | `Single sign-on` | What the sign-in page calls the OIDC provider. |
| `MCP_ALLOW_DYNAMIC_CLIENT_REGISTRATION` | `false` | Let any caller register an OAuth client. |

## Layout

```
src/server/         Hono app, environment parsing, entry point
src/server/auth/    Identity providers, admission policy, session middleware
src/server/artifacts/ Artifact service, HTTP routes, upload validation
src/server/markdown/ HTML to Markdown conversion and its cache
src/server/mcp/    MCP tools, endpoint, and token principal mapping
src/server/preview/ Preview tokens, isolated content responses
src/server/storage/ Artifact metadata, file storage, migrations
src/web/            React client, views, and Vite entry point
plugins/            Claude Code plugin skills and metadata
tools/portego-upload/ Local creation, validation, and upload tool
deploy/             systemd units and the nginx server configuration
docs/               Design and operation notes
scripts/            Deployment and test entry points
```

## Authentication

Sign-in uses [Better Auth](https://www.better-auth.com). A deployment chooses
which identity providers to enable and, separately, which authenticated
identities to admit. A deployment can enable Google and admit verified
Workspace accounts from one domain, or enable a generic OpenID Connect
provider instead, or as well.

[docs/authentication.md](docs/authentication.md) covers the configuration, the
Google setup and callback URLs, the cookie and CSRF behavior, and how to add a
provider without changing application code.

## Storage

Artifact metadata is in SQLite and the uploaded HTML is on disk, both under
`DATA_DIR`. Uploads are written to a temporary file and linked into place, so a
failed upload leaves neither a partial file nor a row pointing at nothing.
[docs/storage.md](docs/storage.md) covers the data model, the write sequence,
migrations, and the reconciliation report.

## Checks required to merge

Every pull request and every push to `main` runs
[.github/workflows/ci.yml](.github/workflows/ci.yml). Every job has to pass:

**Lint, types, tests, build**

1. `bun install --frozen-lockfile`, so CI installs what the lockfile says
2. `bun run lint` (Biome: lint and formatting)
3. `bun run typecheck`
4. `bun run build`
5. `bun run test:ci`, which runs the server, component, and browser suites

**Dependency audit**

`bun audit --audit-level=high` fails the build on a high or critical advisory.
Everything below that is reported and left to a person to judge. Clearing an
advisory means upgrading, or recording why it does not apply with an explicit
`--ignore` and a comment in the workflow.

**Secrets and deployment details**

[gitleaks](https://github.com/gitleaks/gitleaks) scans the file contents of
every commit, the commit messages, and the pull request title and description
with the rules in [.gitleaks.toml](.gitleaks.toml). Besides credentials, the
rules reject IP addresses outside the documentation ranges, hosting provider
names, and links to agent sessions. Run the same rules before each commit:

```sh
brew install gitleaks   # or another package manager
git config core.hooksPath scripts/git-hooks
```

`bun run test:ci` fails when a test fails **and when a test is skipped**. A
suite that quietly stops running a test is the same problem as a suite that
never had it.

The browser tests need Chromium once:

```sh
bun x playwright install chromium
```

They run the application in the test process, on a real port, serving the built
client. Sign-in uses the same offline provider the server tests use, so nothing
in CI reaches Google, and no test can touch a deployment: every suite builds its
own temporary directory and database. [docs/testing.md](docs/testing.md)
describes the suites and the hostile-artifact cases.

## Web client

Two views. The gallery lists artifacts as cards and keeps its search term in
the URL, so a link reproduces what the sender was looking at. Search sits in
the masthead (a field in the gallery, a button on an artifact; `/` opens it)
and finds words in titles, descriptions, artifact text, comments, and folder
and tag names. Enter without picking a result filters the gallery. The artifact page
fills the window under the masthead with an isolated preview, and carries the
metadata, versions, comments, a source download, and a copy-link action. A link
to it unfurls in chat apps with the artifact's title and the Portego logo. Uploading is a dialog that takes a dropped or chosen file, confirms its
name and size, and reports what the server refused when it refuses.

Component tests need a DOM, so `bun run test:web` registers happy-dom first.
`bun test` on its own runs the server tests only.

## Artifact API

`GET`, `POST` under `/api/artifacts` covers listing, upload, metadata, and
source download, and every route there needs a session. `GET`, `POST`, `PATCH`,
and `DELETE` under `/api/folders` and `/api/tags` manage shared organization. `POST /api/uploads`
takes a signed upload ticket instead, for an MCP client sending a file. The
rules live in a transport-independent service that the MCP tools call
directly.
[docs/api.md](docs/api.md) lists the endpoints, the shared error codes, and the
upload limits.

## Folders and tags

Folders form a shared nested tree. An artifact has zero or one folder and any
number of shared tags. They organize artifacts only: the normal gallery remains
a global view across folders and tags, with its existing open and solved filters.
[docs/organization.md](docs/organization.md) defines the model, API behavior,
and the boundary prepared for a later permission system.

## Status, archiving, and comments

An artifact is open or solved, and can be archived separately, so a solved
artifact can also be archived. The gallery filters on both, archived artifacts
stay out of it unless asked for, and their links keep working. Comments are
append-only: they cannot be edited, and only their author can remove one. Every
change records who made it. See
[docs/collaboration.md](docs/collaboration.md).

## Reading an artifact as text

An artifact can be read as Markdown through the API, the artifact page, and MCP.
Markdown uploads return the Markdown supplied with their version, and an HTML
upload can bring its own Markdown for the same purpose. Without one, the HTML
is parsed and never executed, so an artifact that draws itself with JavaScript
reports that it has no static content instead of inventing some.
[docs/markdown.md](docs/markdown.md) covers what converts, how URLs are
handled, and the caching rule.

## MCP

Claude and ChatGPT reach the same artifacts through a remote MCP server at
`/mcp`, over Streamable HTTP. This application is also the OAuth authorization
server: tokens are bound to the exact MCP URL, and the tools call the same
artifact service the web routes call. Other clients reach it too, including one
that needs a pre-registered client id rather than dynamic registration.
[docs/mcp.md](docs/mcp.md) covers discovery, scopes, client registration,
hosting a client metadata document, the tools, how a client sends a file
without putting it in a tool argument, and how to connect each client. The
local plugin can also create and validate an artifact in a configurable style;
[docs/artifact-styles.md](docs/artifact-styles.md) defines the style format and
resolution order. [docs/custom-plugin.md](docs/custom-plugin.md) covers building
the plugin and npm package under a company's own name.

## Uploaded HTML

Artifacts are untrusted code that has to render. They are served from a second
hostname, over a short-lived signed URL, into an iframe whose document has an
opaque origin, no cookies, and no network. The application origin never renders
an uploaded document. [docs/security.md](docs/security.md) describes the model,
what each restriction stops, and the known limitations.

## Health check

`GET /healthz` returns `{"status":"ok","uptime":<seconds>}`. It reports no
configuration, secret, or database detail, because the reverse proxy exposes
it publicly.

## License

[Apache License 2.0](LICENSE).