feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+50 -1
View File
@@ -22,13 +22,18 @@
* `POST /sign-out-others` ends every login but this one. These writes take the
* account from the login COOKIE only — a pane token (an agent acting as its
* owner) may not change the owner's login. Each change is audited.
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
* account writes like the profile's. The sidebar shows it beside the name.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const express = require("express");
const { Router } = express;
const { db } = require("../db");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
@@ -162,6 +167,8 @@ const ACCOUNT_STATUS = {
EBADFIELD: 400,
EBADEMAIL: 400,
EBADPASSWORD: 400,
EBADIMAGE: 400,
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
};
@@ -237,6 +244,48 @@ router.post(
})
);
const avatars = require("../lib/avatars");
router.get("/avatar", (req, res) => {
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
const file = avatars.avatarFile(user.id);
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
router.put(
"/avatar",
sameOriginGuard,
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
accountWrite(async (req, user) => {
avatars.setAvatar(user.id, req.body);
return { out: accountView(user.id), action: "own avatar set" };
})
);
router.delete(
"/avatar",
sameOriginGuard,
accountWrite(async (_req, user) => {
avatars.removeAvatar(user.id);
return { out: accountView(user.id), action: "own avatar removed" };
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
return next(e);
});
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;