Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

enclave

Self-hostable artifact generation and hosting. Describe what you want, a model writes a multi-file HTML bundle, and the result is hosted with an audience you choose and can take back.

Early software. This is v1 — the first release, tagged from a repo that has never been run anywhere but its author's machine and CI. The privacy model is covered by tests (see Testing) but nothing here has production mileage. Read SECURITY.md before you host anything you care about, and expect to read the code when something surprises you.

The four privacy levels

Every artifact starts at the first level. Anyone with the link is additive — a link is a capability you hand out, not a switch you flip — while the other three are the artifact's own visibility and are mutually exclusive.

LevelWho can read itHow it is revoked
Only meThe owner. Nobody else, including administrators.It is the default.
OrganizationEvery active account on this instance, read-only. The owner stays the sole editor.Set the artifact back to Only me.
Anyone with the linkWhoever holds a share link. No account, no sign-in.Revoke the link. Each link is separate, pinned to one version, and can carry an expiry.
PublicEveryone. The artifact's own /a/{id} URL opens with no account, no sign-in, and no link, always on the current version. This is the one level search engines are allowed to index — the page carries robots: noindex at every other level, and only public artifacts appear in /sitemap.xml.Set the artifact back to Only me or Organization. The next request is refused, and the page leaves the index when the crawler next comes round.

Revocation is not eventually-consistent theatre. The entry document is proxied through the app on every request, so revoking is immediate for the document; assets are served by presigned URLs with a 60-second lifetime, so a link already in someone's hands stops working within a minute. Administrators can manage users, quotas and the audit log, and cannot read a private artifact — that is enforced in the one read gate every path goes through, not by convention.

Quick start

Postgres and object storage run in containers; the app runs on your machine. Five commands:

git clone https://github.com/datj9/enclave.git &&cd enclave
cp .env.example .env
docker compose --profile minio up -d postgres minio
pnpm install && pnpm db:migrate && pnpm build
pnpm start # then open http://localhost:3000/setup

/setup creates the single administrator account and then stops existing. Everything works from there except generating from a prompt, which needs a model provider API key — add ANTHROPIC_API_KEY or OPENAI_API_KEY to .env and restart. You can push bundles through POST /api/v1/artifacts without any key at all.

To check the whole path end to end, including the origin isolation and the share-then-revoke journey, run the demo:

bash scripts/fresh-clone-demo.sh

It drives all ten steps against a throwaway database and fails loudly on the first broken one. Without a provider key it reports the generation step as skipped and continues.

For a real deployment — wildcard DNS, wildcard TLS, bucket CORS, every environment variable, and the backup story — read docs/self-hosting.md. Do not put this on the internet from the quick start above.

Publishing from the command line

Everything except generating from a prompt is reachable from a terminal. The client is on npm:

npm install -g enclave-artifacts # the command is `enclave`
enclave login --host enclave.example.com
enclave push ./dist --title "Kanban board"

login prints where to mint a token and reads it without echoing. The token needs all three scopes — artifacts:read, artifacts:write, shares:write — and is stored per host in ~/.config/enclave/credentials.json at mode 0600; the CLI refuses to read it if that mode has loosened. ENCLAVE_TOKEN overrides the file, which is what CI wants.

push writes .enclave.json beside the directory it published, recording which artifact that directory maps to and which version it last pushed. Commit it. It holds no secret, and it is what lets a second machine or a CI job push a new version of the same artifact instead of a duplicate. --new forces a fresh artifact when you do want one.

A second push appends a version to that artifact and prints ✓ updated 3f2a91c4 v2 — the id and the URL do not change, so a link you already shared starts serving the new content. title and visibility stay where they are; change them with rename and privacy, not with a push.

When the server holds a newer version than .enclave.json records, because someone pushed from another machine, the push is refused before anything uploads:

$ enclave push ./dist
✗ server is at v5, you last pushed v2
refusing to overwrite a newer version
re-run with --force to publish anyway

--force drops that guard. A share link pinned to a version keeps serving that version regardless.

.enclave.json lives inside the directory you push, so a build that wipes ./dist takes the state file with it and the next push would create a duplicate. In CI, name the artifact instead — nothing then has to survive the build:

enclave push ./dist --artifact 3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c

With no state file there is nothing to compare against, so that push appends unconditionally. A full uuid costs no lookup and needs only artifacts:write; a prefix is resolved against your artifacts and also needs artifacts:read.

A bundle is validated as a unit, so a single disallowed file would reject the whole upload. Rather than let that happen, push drops what the server would refuse and names everything it skipped:

$ enclave push ./dist
skipped 3 files:
app.js.map unsupported (.map)
favicon.ico unsupported (.ico)
fonts/Inter.ttf unsupported (.ttf)
✓ 2 files, 40 KB
✓ created 3f2a91c4 v1
→ https://enclave.example.com/a/3f2a91c4-2f1e-4a0b-9d43-5c9d0f0a1b2c
private — only you can open that link
share it: enclave share create 3f2a91c4 --expires 7d
or open to the instance: enclave privacy 3f2a91c4 org

--dry-run shows that split without uploading; .enclaveignore (gitignore syntax) drops more. The client's copy of these rules is a convenience, not the gate — the server enforces them regardless.

The rest of the surface:

enclave version [--json] (also -v, -V, --version)
enclave logout [--host <host>]
enclave push <dir> [--title <t>] [--visibility private|org|public]
[--artifact <id>] [--new] [--force] [--dry-run] [--json]
enclave list [--limit <n>] [--cursor <c>] [--json]
enclave show <id> [--json]
enclave rename <id> <title>
enclave privacy <id> private|org|public
enclave rm <id>
enclave restore <id>
enclave share create <id> [--version <versionId>] [--expires <7d|2026-08-10T23:59:00+07:00>] [--json]
enclave share list <id> [--json]
enclave share revoke <shareId>

<id> accepts a full artifact uuid or any unambiguous prefix of eight characters or more. The host resolves from --host, then ENCLAVE_HOST, and for push also from .enclave.json.

--expires takes a duration (7d, 12h, 2w); a date (2026-08-10) or a date-time (2026-08-10T14:30), both resolved in this machine's local timezone; or an ISO-8601 instant with an explicit zone (2026-08-10T23:59:00+07:00, 2026-08-10T16:59:00Z), taken exactly as given — including fractional seconds of any length and lowercase z. A bare date means local end of that day (23:59:59.999 local), not UTC midnight. Anything else is refused. The resolved instant, in both frames (UTC and local date+time), is printed to stderr before the share link is created.

Every command that returns something takes --json, which puts the raw API object on stdout and nothing else — diagnostics and errors always go to stderr, so | jq is safe on every path. Exit 1 means the command ran and the answer was no (not found, refused, unreachable, token rejected); exit 2 means the invocation was malformed and the command never ran.

Flags are scoped to the command that declares them, as listed above. A flag a command does not take exits 2 rather than being silently discarded — enclave rm <id> --dry-run refuses instead of deleting.

There is no enclave token create, deliberately: the server refuses to let an API token mint another token, so a leaked token cannot outlive its own revocation. Mint tokens in the browser. Generating from a prompt, and administering users, invites and the audit log, are browser-only.

Full client documentation, including .enclaveignore and every flag, is in packages/cli/README.md.

What v1 does

  • Generate a multi-file bundle from one prompt, streamed as it arrives. Anthropic or any OpenAI-compatible endpoint. Instance key by default, or bring your own per account.
  • Host each artifact on its own origin ({id}.artifacts.<domain>) inside a sandboxed iframe with a strict Content-Security-Policy, so one artifact cannot reach another's storage or the app's session.
  • Version append-only. A share link pins one version and keeps showing it after you publish newer ones.
  • Share with revocable capability links: 32 bytes of entropy, stored only as a hash, optional expiry, per-link revocation.
  • Push bundles from the enclave CLI, or from anything that speaks HTTP: POST /api/v1/artifacts with a scoped API token (artifacts:read, artifacts:write, shares:write).
  • Audit every privacy change, share creation and revocation, and every non-private view, including anonymous ones, with a viewer for administrators. Prompts are never written to the audit log.
  • Limit abuse with a per-user hourly rate limit and a daily generation quota, both configurable, with a larger quota for accounts using their own provider key.
  • Delete softly: a 30-day trash window that kills every share link immediately, then a purge job that removes the database rows and the storage objects while the audit trail survives.
  • Invite rather than accept open signups, unless you set ALLOW_OPEN_REGISTRATION=true. Email and password (argon2id), or OIDC.

What v1 does not do

Named explicitly so you can stop looking: multi-turn chat, iterating on an existing artifact, diffing versions, forking or remixing, multiple organizations in one deployment, collaborative editing, per-artifact editor permissions, comments, an embed-on-other-sites mode, templates, full-text search, webhooks, usage billing, and SCIM provisioning. None of these are present.

Stack

Next.js (App Router) · Postgres via Drizzle · any S3-compatible object storage · Docker Compose. Vitest for unit and integration tests, Playwright for browser journeys.

Testing

pnpm test# 745 unit + integration tests
pnpm test:e2e # 95 Playwright specs across 9 journeys
pnpm test:coverage # 80% floor repo-wide; 100% branches on the bundle parser and the read gate

Integration tests skip themselves when Postgres or object storage is unreachable, so pnpm test passes on a machine with nothing started. Start the compose services to actually run them.

Docs

FileWhat is in it
docs/self-hosting.mdThe operator's guide: DNS, TLS, CORS, every env var, storage backends, cron jobs, backup
packages/cli/README.mdThe enclave CLI: install, login, push, shares, .enclaveignore, exit codes
SECURITY.mdHow to report a vulnerability, and exactly how artifact isolation works
CONTRIBUTING.mdTest commands, coverage floor, TDD order, the binding design references
design.mdThe locked design system — colour, type, space, depth
docs/motion.mdThe in-app motion standard: durations, curves, what animates and what does not

License

Apache-2.0. Copyright 2026 Dat Nguyen.

About

Self-hostable artifact hosting with three privacy levels: only you, your organization, or a revocable share link.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages