feat: optional self-hosted UUID server (SQLite + shared viewer)#16
feat: optional self-hosted UUID server (SQLite + shared viewer)#16
Conversation
- Add Node selfhosted server (CRUD API, sliding 24h TTL, static + /{uuid} shell)
- Reuse viewer: fetch payload, decode with optional fragment-length bypass
- Gate UUID client behavior on NEXT_PUBLIC_SELFHOSTED_SERVER=1 for static safety
- Document deployment, add selfhosted-agent-render skill, tests for store and path
Co-authored-by: Aanish Bhirud <baanish@users.noreply.github.com>
📝 WalkthroughWalkthroughThis PR introduces an optional self-hosted Node + SQLite deployment mode alongside the existing static fragment-based architecture. The changes add a separate server under Changes
Sequence DiagramsequenceDiagram
participant Client as Client (Browser)
participant Router as Route Handler
participant API as Server API
participant DB as SQLite DB
participant Static as Static Files
Client->>Router: GET /artifact-uuid
activate Router
Router->>Router: Extract UUID from pathname
Router->>API: GET /api/artifacts/{uuid}
deactivate Router
activate API
API->>DB: SELECT artifact WHERE id=uuid
DB-->>API: artifact row (if exists & not expired)
alt Artifact expired
API->>DB: DELETE artifact
DB-->>API: deleted
API-->>Client: 404 {error: "expired"}
else Artifact valid
API->>DB: UPDATE expires_at (sliding TTL)
DB-->>API: updated
API-->>Client: 200 {payload, expiresAt}
end
deactivate API
activate Client
Client->>Client: Decode payload<br/>(enforceFragmentLengthLimit=false)
Client->>Static: GET /index.html (cached)
Static-->>Client: HTML shell
Client->>Client: Render viewer with stored payload
deactivate Client
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-authored-by: Aanish Bhirud <baanish@users.noreply.github.com>
Deploying agent-render with
|
| Latest commit: |
38d1cc5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://33df3592.agent-render.pages.dev |
| Branch Preview URL: | https://cursor-agent-render-self-hos.agent-render.pages.dev |
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (24 files)
The PR adds an optional self-hosted deployment mode with SQLite storage, properly documented as an opt-in feature that doesn't affect the default static fragment product. Reviewed by minimax-m2.5-20260211 · 1,134,843 tokens |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 36c476cff1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| server.listen(port, () => { | ||
| const suffix = basePath ? `${basePath}/` : "/"; | ||
| console.log(`Self-hosted agent-render at http://127.0.0.1:${port}${suffix}`); |
There was a problem hiding this comment.
Bind the self-hosted server to loopback by default
server.listen(port) omits the host, and Node will accept connections on ::/0.0.0.0 when the hostname is missing. In practice, anyone who runs npm run selfhosted:start on a machine with a routable interface exposes the UUID artifact API to the network, even though the startup log advertises 127.0.0.1 and the docs describe same-machine usage. Defaulting to loopback (or requiring an explicit HOST) would avoid accidental public exposure of stored payloads.
Useful? React with 👍 / 👎.
| function toStaticFilePath(requestPath) { | ||
| const normalizedPath = requestPath === "/" ? "/index.html" : requestPath; | ||
| const tentativePath = path.join(staticRoot, normalizedPath); | ||
| return normalizedPath.endsWith("/") ? path.join(tentativePath, "index.html") : tentativePath; |
There was a problem hiding this comment.
Reject path traversal before serving static files
The static-file fallback joins the raw request path straight onto staticRoot without checking for .. segments, so a client that preserves the path verbatim (for example, curl --path-as-is /../../data/artifacts.sqlite) can escape the export directory. Because the resulting path is then passed through existsSync and createReadStream, a reachable self-hosted instance can disclose arbitrary files from the host filesystem, including the SQLite database itself.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
selfhosted/docker-compose.yml (1)
1-17: Consider adding restart policy and healthcheck for production resilience.The compose file is functionally correct. For production deployments, consider adding a restart policy and healthcheck to improve reliability.
♻️ Optional production hardening
services: agent-render-selfhosted: build: context: .. dockerfile: selfhosted/Dockerfile args: NEXT_PUBLIC_SELFHOSTED_SERVER: "1" ports: - "3000:3000" environment: PORT: "3000" DATABASE_PATH: /data/artifacts.sqlite volumes: - agent-render-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 volumes: agent-render-data:🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@selfhosted/docker-compose.yml` around lines 1 - 17, Add a restart policy and a healthcheck to the agent-render-selfhosted service to improve production resilience: under the service definition for agent-render-selfhosted add a restart (e.g., "unless-stopped" or "always") and a healthcheck block that exercises the running app (e.g., HTTP GET to localhost:3000/ or a lightweight curl command) with sensible interval, timeout and retries so Docker can detect and restart unhealthy containers; ensure the healthcheck references the container port and leaves the existing volumes (agent-render-data) and environment intact.tests/artifact-path.test.ts (1)
6-19: Consider additional edge case coverage.The current tests cover the happy path well. Consider adding tests for:
- Invalid UUID format (e.g.,
/not-a-uuid/) → should returnnull- Nested paths beyond the UUID (e.g.,
/app/${sample}/extra) → clarify expected behavior- Empty string
basePathvsundefined💡 Suggested additional test cases
it("returns null for invalid UUID format", () => { expect(getArtifactIdFromPathname("/not-a-uuid/")).toBeNull(); expect(getArtifactIdFromPathname("/123/")).toBeNull(); }); it("handles empty basePath same as undefined", () => { expect(getArtifactIdFromPathname(`/${sample}/`, "")).toBe(sample); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/artifact-path.test.ts` around lines 6 - 19, Add unit tests for edge cases to getArtifactIdFromPathname: verify invalid UUID segments return null (e.g., expect getArtifactIdFromPathname("/not-a-uuid/") and getArtifactIdFromPathname("/123/") toBeNull), assert nested extra segments after the UUID return null (e.g., expect getArtifactIdFromPathname(`/app/${sample}/extra`, "/app") toBeNull) to enforce exact-segment matching, and add a test that an empty string basePath is treated like undefined (e.g., expect getArtifactIdFromPathname(`/${sample}/`, "") toBe(sample)); reference the existing test suite around getArtifactIdFromPathname to add these cases.selfhosted/server.mjs (1)
79-93: Consider adding request timeout handling.The
readBodyfunction limits body size to 12MB but doesn't have a timeout for slow clients. A malicious or slow client could hold the connection open indefinitely by sending data very slowly.♻️ Optional: Add request timeout
function readBody(request) { return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("request timeout")); + }, 30000); + const chunks = []; request.on("data", (chunk) => { chunks.push(chunk); if (chunks.reduce((acc, c) => acc + c.length, 0) > 12_000_000) { + clearTimeout(timeout); reject(new Error("body too large")); } }); request.on("end", () => { + clearTimeout(timeout); resolve(Buffer.concat(chunks).toString("utf8")); }); - request.on("error", reject); + request.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@selfhosted/server.mjs` around lines 79 - 93, The readBody function lacks a timeout, allowing slow or malicious clients to keep the connection open; add timeout handling by starting a timer when readBody begins (or use request.setTimeout) that rejects with a descriptive Error (e.g., "request timeout") if no completion before the deadline, and ensure you clear the timer and remove/cleanup listeners on "end", "error", and on timeout; update the Promise handlers in readBody to call request.destroy() or similar after rejecting to free resources and avoid memory leaks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@selfhosted/artifact-db.mjs`:
- Around line 55-59: The touchView prepared statement (touchView) is incorrectly
updating updated_at on reads; modify the touchView SQL so it only updates
last_viewed_at and expires_at (leave updated_at untouched) when refreshing TTL
on a GET, and ensure any legitimate updates to updated_at remain confined to the
write/update path (e.g., the function that handles PUT/modify of artifacts).
In `@selfhosted/Dockerfile`:
- Around line 1-24: Add a non-root user and switch to it after installing
dependencies/building the app: create a user/group (e.g., appuser), chown the
application directories and the mounted VOLUME path so the user can write to
/data and STATIC_ROOT, then set USER to that non-root user before the final CMD
(which currently runs node selfhosted/server.mjs); perform these steps after RUN
npm ci and RUN npm run build so build steps run as root but runtime runs
unprivileged, and ensure any runtime environment variables (e.g., DATABASE_PATH,
STATIC_ROOT) remain accessible to the new user.
---
Nitpick comments:
In `@selfhosted/docker-compose.yml`:
- Around line 1-17: Add a restart policy and a healthcheck to the
agent-render-selfhosted service to improve production resilience: under the
service definition for agent-render-selfhosted add a restart (e.g.,
"unless-stopped" or "always") and a healthcheck block that exercises the running
app (e.g., HTTP GET to localhost:3000/ or a lightweight curl command) with
sensible interval, timeout and retries so Docker can detect and restart
unhealthy containers; ensure the healthcheck references the container port and
leaves the existing volumes (agent-render-data) and environment intact.
In `@selfhosted/server.mjs`:
- Around line 79-93: The readBody function lacks a timeout, allowing slow or
malicious clients to keep the connection open; add timeout handling by starting
a timer when readBody begins (or use request.setTimeout) that rejects with a
descriptive Error (e.g., "request timeout") if no completion before the
deadline, and ensure you clear the timer and remove/cleanup listeners on "end",
"error", and on timeout; update the Promise handlers in readBody to call
request.destroy() or similar after rejecting to free resources and avoid memory
leaks.
In `@tests/artifact-path.test.ts`:
- Around line 6-19: Add unit tests for edge cases to getArtifactIdFromPathname:
verify invalid UUID segments return null (e.g., expect
getArtifactIdFromPathname("/not-a-uuid/") and getArtifactIdFromPathname("/123/")
toBeNull), assert nested extra segments after the UUID return null (e.g., expect
getArtifactIdFromPathname(`/app/${sample}/extra`, "/app") toBeNull) to enforce
exact-segment matching, and add a test that an empty string basePath is treated
like undefined (e.g., expect getArtifactIdFromPathname(`/${sample}/`, "")
toBe(sample)); reference the existing test suite around
getArtifactIdFromPathname to add these cases.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 277fd8c9-8e28-4127-a9ab-8b434e37587f
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (24)
.gitignoreAGENTS.mdREADME.mddocs/architecture.mddocs/dependency-notes.mddocs/deployment.mddocs/payload-format.mddocs/testing.mdpackage.jsonselfhosted/Dockerfileselfhosted/README.mdselfhosted/artifact-db.mjsselfhosted/cleanup.mjsselfhosted/docker-compose.ymlselfhosted/server.mjsskills/agent-render-linking/SKILL.mdskills/selfhosted-agent-render/SKILL.mdsrc/components/viewer-shell.tsxsrc/components/viewer/fragment-details-disclosure.tsxsrc/lib/payload/fragment.tssrc/lib/selfhosted/artifact-path.tstests/artifact-path.test.tstests/fragment.test.tstests/selfhosted-artifact-db.test.ts
| const touchView = db.prepare(` | ||
| UPDATE artifacts | ||
| SET last_viewed_at = @now, expires_at = @expires_at, updated_at = @now | ||
| WHERE id = @id | ||
| `); |
There was a problem hiding this comment.
updated_at is modified on GET, which may not be intended.
The touchView statement sets updated_at = @now`` when refreshing the TTL on a read operation. Typically, updated_at should only change when the payload is modified (PUT), while `last_viewed_at` tracks read access. This could confuse API consumers who expect `updatedAt` to reflect actual content changes.
🔧 Proposed fix to preserve `updated_at` on view
const touchView = db.prepare(`
UPDATE artifacts
- SET last_viewed_at = `@now`, expires_at = `@expires_at`, updated_at = `@now`
+ SET last_viewed_at = `@now`, expires_at = `@expires_at`
WHERE id = `@id`
`);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@selfhosted/artifact-db.mjs` around lines 55 - 59, The touchView prepared
statement (touchView) is incorrectly updating updated_at on reads; modify the
touchView SQL so it only updates last_viewed_at and expires_at (leave updated_at
untouched) when refreshing TTL on a GET, and ensure any legitimate updates to
updated_at remain confined to the write/update path (e.g., the function that
handles PUT/modify of artifacts).
| FROM node:22-bookworm-slim | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| COPY package.json package-lock.json* ./ | ||
| RUN npm ci | ||
|
|
||
| ARG NEXT_PUBLIC_BASE_PATH= | ||
| ARG NEXT_PUBLIC_SELFHOSTED_SERVER=1 | ||
| ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH | ||
| ENV NEXT_PUBLIC_SELFHOSTED_SERVER=$NEXT_PUBLIC_SELFHOSTED_SERVER | ||
|
|
||
| COPY . . | ||
| RUN npm run build | ||
|
|
||
| ENV NODE_ENV=production | ||
| ENV PORT=3000 | ||
| ENV DATABASE_PATH=/data/artifacts.sqlite | ||
| ENV STATIC_ROOT=/app/out | ||
|
|
||
| VOLUME ["/data"] | ||
| EXPOSE 3000 | ||
|
|
||
| CMD ["node", "selfhosted/server.mjs"] |
There was a problem hiding this comment.
Add a non-root user for container security.
The container runs as root by default, which increases the attack surface if the application is compromised. Consider adding a non-root user after installing dependencies and before the CMD.
🛡️ Proposed fix to run as non-root user
ENV STATIC_ROOT=/app/out
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 --ingroup nodejs appuser && \
+ chown -R appuser:nodejs /app
+
VOLUME ["/data"]
EXPOSE 3000
+USER appuser
+
CMD ["node", "selfhosted/server.mjs"]Note: Ensure the /data volume is writable by the non-root user at runtime (e.g., via chown in an entrypoint or by mounting with appropriate permissions).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| FROM node:22-bookworm-slim | |
| WORKDIR /app | |
| COPY package.json package-lock.json* ./ | |
| RUN npm ci | |
| ARG NEXT_PUBLIC_BASE_PATH= | |
| ARG NEXT_PUBLIC_SELFHOSTED_SERVER=1 | |
| ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH | |
| ENV NEXT_PUBLIC_SELFHOSTED_SERVER=$NEXT_PUBLIC_SELFHOSTED_SERVER | |
| COPY . . | |
| RUN npm run build | |
| ENV NODE_ENV=production | |
| ENV PORT=3000 | |
| ENV DATABASE_PATH=/data/artifacts.sqlite | |
| ENV STATIC_ROOT=/app/out | |
| VOLUME ["/data"] | |
| EXPOSE 3000 | |
| CMD ["node", "selfhosted/server.mjs"] | |
| FROM node:22-bookworm-slim | |
| WORKDIR /app | |
| COPY package.json package-lock.json* ./ | |
| RUN npm ci | |
| ARG NEXT_PUBLIC_BASE_PATH= | |
| ARG NEXT_PUBLIC_SELFHOSTED_SERVER=1 | |
| ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH | |
| ENV NEXT_PUBLIC_SELFHOSTED_SERVER=$NEXT_PUBLIC_SELFHOSTED_SERVER | |
| COPY . . | |
| RUN npm run build | |
| ENV NODE_ENV=production | |
| ENV PORT=3000 | |
| ENV DATABASE_PATH=/data/artifacts.sqlite | |
| ENV STATIC_ROOT=/app/out | |
| RUN addgroup --system --gid 1001 nodejs && \ | |
| adduser --system --uid 1001 --ingroup nodejs appuser && \ | |
| chown -R appuser:nodejs /app | |
| VOLUME ["/data"] | |
| EXPOSE 3000 | |
| USER appuser | |
| CMD ["node", "selfhosted/server.mjs"] |
🧰 Tools
🪛 Trivy (0.69.3)
[error] 1-1: Image user should not be 'root'
Specify at least 1 USER command in Dockerfile with non-root user as argument
Rule: DS-0002
(IaC/Dockerfile)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@selfhosted/Dockerfile` around lines 1 - 24, Add a non-root user and switch to
it after installing dependencies/building the app: create a user/group (e.g.,
appuser), chown the application directories and the mounted VOLUME path so the
user can write to /data and STATIC_ROOT, then set USER to that non-root user
before the final CMD (which currently runs node selfhosted/server.mjs); perform
these steps after RUN npm ci and RUN npm run build so build steps run as root
but runtime runs unprivileged, and ensure any runtime environment variables
(e.g., DATABASE_PATH, STATIC_ROOT) remain accessible to the new user.
Summary
Adds an optional deployment mode: a small Node server that stores the canonical
agent-render=v1...payload string in SQLite under UUID v4 keys, with a 24h sliding TTL on successfulGET /api/artifacts/:id. The same static-export viewer bundle decodes and renders artifacts when built withNEXT_PUBLIC_SELFHOSTED_SERVER=1.The default fragment-based static product is unchanged when that env var is unset (including CI / Playwright).
Key changes
selfhosted/—server.mjs(staticout/, CRUD API,GET /{uuid}→index.html),artifact-db.mjs(WAL SQLite, TTL refresh, lazy expiry on read),cleanup.mjs, Docker assets.src/lib/payload/fragment.ts—DecodeFragmentOptions/enforceFragmentLengthLimitso stored wires can exceedMAX_FRAGMENT_LENGTHwhile keeping decoded size limits.src/components/viewer-shell.tsx— UUID path detection + API fetch + localactiveArtifactIdupdates without rewriting the full payload to the hash; loading/error panels;data-transportattribute.src/lib/selfhosted/artifact-path.ts— UUID segment parsing (works with NextusePathname()+ optional base path).src/components/viewer/fragment-details-disclosure.tsx—transportMode/ expiry label for stored payloads.README.md,AGENTS.md,docs/deployment.md,docs/architecture.md,docs/payload-format.md,docs/testing.md,docs/dependency-notes.md,skills/agent-render-linking/SKILL.md, newskills/selfhosted-agent-render/SKILL.md.tests/selfhosted-artifact-db.test.ts,tests/artifact-path.test.ts, fragment test for stored decode; dependencybetter-sqlite3.Build / run (self-hosted)
API
POST /api/artifacts{ "payload": "agent-render=v1...." }GET|PUT|DELETE /api/artifacts/:id(GET extends TTL)NEXT_PUBLIC_BASE_PATHis honored for static assets and API routes, consistent with the static preview server.Testing
npm run check— passnpm run test:e2e— pass (one run showed a single flaky JSON test under parallel load;npx playwright test tests/e2e/viewer.spec.ts:146 --repeat-each=3— pass)Summary by CodeRabbit
Release Notes
New Features
Documentation