feat: file upload/download and pinned sessions, text size, git identity and clone

Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:21:55 +07:00
parent 0cb5e41faf
commit cb19d7e5d2
63 changed files with 3833 additions and 209 deletions
+65 -4
View File
@@ -5,7 +5,8 @@
* for the built-in Swagger and ReDoc surfaces, plus the read-only
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
* Workspace page opens a directory with.
* Workspace page opens a directory with, and the Files tab's reads (tree,
* quick-open, download — also in a folder that is not a git repo) and upload.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -688,7 +689,7 @@ const paths = {
tags: ["Lanes"],
summary: "Tracked paths matching a substring (quick-open)",
description:
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.",
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, no same-origin guard.",
operationId: "listLanePaths",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -705,7 +706,7 @@ const paths = {
tags: ["Lanes"],
summary: "One directory level of tracked files",
description:
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.",
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). Read-only, no same-origin guard.",
operationId: "listLaneTree",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -723,7 +724,7 @@ const paths = {
tags: ["Lanes"],
summary: "Every level of the tracked tree in one read",
description:
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.",
operationId: "listLaneTreeAll",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
@@ -732,6 +733,66 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/download": {
get: {
tags: ["Lanes"],
summary: "Download one working file of the workspace",
description:
"The Files tab's Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member's file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''…`) with `Content-Security-Policy: default-src 'none'; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard's origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.",
operationId: "downloadLaneFile",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
],
responses: {
200: { description: "The file's bytes, as an attachment." },
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
403: {
description:
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/upload": {
post: {
tags: ["Lanes"],
summary: "Upload one new file into a folder of the workspace",
description:
"The Files tab's Upload. The body is the file's raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder (\"\" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member's home dot folder), at most 25 MB. Under S3 the file is written as the folder's member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.",
operationId: "uploadLaneFile",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
{
name: "X-File-Name",
in: "header",
required: true,
schema: { type: "string" },
description: "The new file's name, URI-encoded: one plain segment of at most 255 bytes.",
},
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
],
requestBody: {
required: true,
content: {
"application/octet-stream": { schema: { type: "string", format: "binary" } },
},
},
responses: {
201: { description: "{path} — the workspace-relative path written." },
400: { description: "EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT." },
403: { description: "ESECRET, EFOREIGN, or EBADORIGIN (cross-origin)." },
404: { description: "Lane not found." },
409: { description: "EEXISTS — a file of that name is already there." },
413: { description: "ETOOBIG — over 25 MB." },
},
},
},
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
@@ -170,6 +170,14 @@ const paths = {
"Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
example: "2026-10-05T23:59:59.999Z",
},
{
name: "pinned_first",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"`1` = pinned sessions first (by `pinned_at`, oldest pin first), then the normal order. Applied before `limit`/`offset`, so a pinned old session stays on the first page. Omitted = order unchanged.",
example: "1",
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
@@ -300,7 +308,7 @@ const paths = {
tags: ["Sessions"],
summary: "Update session",
description:
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
"Partially updates a session by `id`. `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned` are accepted (`archived`/`pinned` must be booleans, else 400 `INVALID_INPUT`; they set or clear `archived_at`/`pinned_at`, and a pin-only PATCH leaves `updated_at` unchanged); any other field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "updateSession",
parameters: [
{