Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 58f50a5240 release 2026.08.29.01 — sharp becomes infrastructure
every other deploy this month was code. this one changes what a machine must have,
so the plan has two verification steps no previous plan needed and one host gets a
command none of the others do.

SCHEMA UNCHANGED, and the plan says so with the command that proves it rather than
the claim on its own:

  git diff --name-only deploy/2026.08.28.01..HEAD | grep -iE 'schema|/db/|databases'

returns nothing. no db:push step exists and none should be improvised. the last
time a plan of mine asserted this without checking, `dav_app_passwords` had moved
to a plugin and a push would have dropped it on six hosts.

BUN INSTALL IS REQUIRED, not optional — package.json and bun.lock both moved. and
it gets a SEPARATE verification step, because sharp does not compile, it downloads
a prebuilt libvips for this exact platform and arch: the install can succeed and
the module still be unusable, and the symptom is not an error anywhere. it is a
file browser drawing generic icons over a folder of photographs.

FFMPEG IS CHECKED because it was never declared. it has been a hard dependency of
the file browser since long before thumbnails — subtitles, audio extraction, audio
tracks — and worked because every machine was set up by hand. surveyed all seven
hosts today: present everywhere except macbruno, which is the one per-host command
in the plan.

fofos already runs this code and was the test host before the tag. it found a real
bug that only a deploy could: `.ts` was in the video extension list, so every
TypeScript file in every repository drew a play badge and spawned an ffmpeg.

known and NOT fixed here: one test fails, ModelSelector's default selection. it is
in chat code this release does not touch, the file is unchanged across every commit
in this range, and it last moved in fe7fe49c. tagging over somebody else's red test
rather than editing their work mid-release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:38:49 +01:00
pastilhasandClaude Opus 5 43a4463978 the platform skill generator emits an identifier, not a label
c24859c fixed every skill's `name` to be lowercase-hyphenated and equal to its
directory, with the human name moving to `title`. it fixed the FILES. this
generator writes one of those files and was missed.

so the file on disk was correct and running `bun run skills:platform` put the old
shape back — which made `docs:check` fail on a line count that could not be fixed
by regenerating, the worst possible place for a drift check to point. found while
cutting a release, because the procedure says leave docs:check green and it was
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:35:29 +01:00
pastilhasandClaude Opus 5 de425d9d68 .ts is TypeScript here, not a transport stream
caught by deploying: fofos reported `test-setup.ts` as a video. `.ts` is a real
video extension — MPEG transport stream — and it was in the list for that reason,
but on any machine Officer runs on it is TypeScript approximately always. every
source file in every repository was drawing a play badge and spawning an ffmpeg
that could only fail.

the two ways of being wrong are not the same size. excluded, a genuine transport
stream shows a file icon; included, browsing a source tree runs a decoder per file
and negative-caches the lot. nobody keeps broadcast captures in a folder they
browse from here.

m2ts and mts added while in there — the container people actually have, from a
camera rather than a tuner, and neither collides with anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:34:09 +01:00
pastilhasandClaude Opus 5 22a4c65776 sharp is a declared dependency, and setup checks both it and ffmpeg
sharp does the images now; ffmpeg keeps the video. each does the job it is for —
sharp is libvips in-process, no process to spawn and no pipe, which is an order of
magnitude less work for the common case of a folder of photographs. a video has no
frame until something decodes one, and that is ffmpeg's and nobody else's.

added through the procedure bunfig.toml describes, since there is no
--no-frozen-lockfile flag in bun 1.3.14: package.json edited, frozenLockfile
commented out for ONE command, lockfile diff read, setting put back, and a frozen
install confirmed clean afterwards.

WHAT THE LOCKFILE DIFF ACTUALLY SAID, because the friction is only worth anything
if somebody reads it. everything is inside sharp's tree: sharp 0.33.5 -> 0.35.4 at
top level, @img/colour added, and the per-platform libvips binaries moved 1.2.4 ->
1.3.3. two SHARED packages moved with it — semver 7.7.4 -> 7.8.5 and
@emnapi/runtime 1.8.1 -> 1.11.3 — both forced by sharp's own ranges, and both
previous consumers (next, editorconfig) kept pinned copies of what they had. next
keeps its own sharp at 0.33.5 entirely. nothing outside that moved.

^0.35.3 to match what av-club declares. 0.x caret pins the minor, so ^0.33 and
^0.35 do not overlap — the plugin intersection would have resolved two native
libvips trees onto one machine for no reason.

THE IMAGE LIST GREW: libvips reads HEIC and SVG and ffmpeg does not, and neither is
exotic — HEIC is what every recent iPhone writes, and a folder of them drawing
generic file icons was the most visible gap this had.

`failOn: 'none'` and `limitInputPixels: false` are both deliberate. libvips rejects
a file with any warning in it, and a truncated JPEG — an interrupted copy, a phone
that died mid-write — is exactly that; refusing it would make this browser stricter
than the viewer somebody opens the file with. the pixel ceiling is a DoS guard for
an upload endpoint, and these are the owner's own files already on the owner's own
disk: a panorama is not an attack.

SETUP, both halves:

ffmpeg was never in the package list at all, on any of the four package managers,
despite this router shelling out to it for subtitles, audio extraction and audio
tracks long before thumbnails existed. it worked because every machine set up by
hand already had it. now declared.

sharp gets a LOAD CHECK beside node-pty's build check, and reported separately from
it. same failure shape — an install that succeeds while the native module does not
— but the opposite cause: node-pty compiles and can fail to, sharp downloads a
libvips binary for this exact platform and arch and can fail to resolve one. a
combined "native modules" verdict would send somebody to rebuild the wrong thing.
loading it is the only test worth running; a directory proves nothing, and
`versions.vips` proves libvips is bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:31:18 +01:00
pastilhasandClaude Opus 5 0d310ab7b0 the file browser draws pictures, in both views
images and videos get a thumbnail in grid and list. one <Thumbnail>, two sizes —
two implementations would have drifted the first time one learned something the
other did not, and the fallback is the part that would have drifted.

FFMPEG FOR BOTH, and no new dependency. a video needs a frame extracted, which
only ffmpeg does; an image only needs resizing, which sharp does faster and with
better format coverage. sharp is nevertheless not used: it sits in node_modules as
somebody else's transitive dependency and is absent from package.json, so importing
it is an undeclared dependency — the thing the frozen lockfile exists to prevent —
and declaring it is a deliberate --no-frozen-lockfile that belongs to whoever owns
the dependency list. ffmpeg is already a hard requirement of this router, so this
adds nothing to what the machine must have. the cost is HEIC and a few exotics,
which fall back to the icon.

THE CACHE KEY CONTAINS MTIME AND SIZE. a path alone would be wrong: replace a photo
under the same name and every screen keeps the old picture with nothing to clear it
but somebody who knows the cache exists. keyed on content, a changed file is a
different key — the old entry is orphaned rather than stale, and the response can be
`immutable` honestly rather than as a bet.

FAILURES ARE REMEMBERED. the browser retries a broken <img> every time it re-enters
the viewport, so without this one corrupt video runs ffmpeg on every scroll, forever.
in memory, so a restart gives it another chance after somebody installs a codec.

`ls` DECIDES what can be thumbnailed, not the client. the browser's own
NATIVE_VIDEO_EXTS is deliberately only what a <video> tag can play, which is a
different question from what ffmpeg can take a frame out of — every .mkv would have
gone without a picture for that reason alone. the entry has just been stat'd, so the
answer is a lookup on its name.

FOUND WHILE TESTING, and the reason there is a test folder: ffmpeg EXITS 0 HAVING
WRITTEN A ZERO-BYTE FILE when the seek lands past the end of the input. a 0.4s clip
gave exit 0 and 0 bytes with `-ss 1`, and exit 0 and a real JPEG without it. the
retry keyed on the exit code and so never fired, reporting every short video as
having no thumbnail. it keys on the output file now — there is one question worth
asking an image encoder and it is whether there is an image.

four ffmpeg at a time. the grid is virtualised so only what is on screen is asked
for, but scrolling fast through a folder of video queues far more than is visible,
and unbounded decoding turned a 1.9GB box into a swap storm once already.

size is snapped to 64px steps so a panel resizing by a pixel cannot mint a hundred
entries of one image. verified: 240 and 256 share a cache entry, 200 gets its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:25:11 +01:00
pastilhasandClaude Opus 5 16fa869260 the dock rounds its tiles; now it clips them too
reported as a plugin whose dock tile had square corners while every other one was
rounded, and the expectation was right: the rounding IS dock infrastructure and
has been there all along. it was simply never reaching the artwork.

the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile
insets its glyph to h-7, so the corners are empty and nothing looked wrong for as
long as every plugin used one. `bare` fills the box edge to edge — so the first
plugin to ship a logo on an OPAQUE background drew its own square corners over a
rounded box, and became the one tile in the row that had not been told the shape.

`overflow-hidden`, in the dock and in spotlight, which had the identical bug a few
pixels away. it fixes every bare plugin rather than this one, and it is a no-op for
badge tiles, which is why it belongs on the box and not on the image.

the plugins list drew the same artwork with no rounding at all while the grid
beside it used `rounded-xl`. rounded to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:12:31 +01:00
pastilhasandClaude Opus 5 3d936309a5 the list refreshes before the message, and the tab follows the result
reported as "the list does not update without a refresh". the counts were in fact
correct, which is the more interesting half: fofos-admin was ALREADY INSTALLED on
that host, so the fetch put it in Installed while the confirmation said "click it
to install" and pointed at an empty Available tab. right numbers, wrong story.

three fixes, and only one of them is the refresh.

AWAIT THE REFETCH. `invalidateQueries` is fire-and-forget, so the confirmation
appeared while the grid was still rendering the previous answer and the plugin
turned up a moment later. that reads as "nothing happened" and sends somebody
looking for a refresh button — which is exactly what happened.

SAY WHICH THING HAPPENED. a directory and an install row are separate facts:
deleting a plugin's source leaves the row, so a re-fetch can land on something the
platform still considers installed. the route now reports it, and the message is
either "added — click it to install" or "added — already installed".

FOLLOW IT TO ITS TAB. if it came back installed, the overview switches to
Installed rather than leaving somebody staring at the tab it is not in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:01:48 +01:00
pastilhasandClaude Opus 5 9a70089d6b a Marketplace section, and the git field lives where its result appears
a new row above the tags, and where the store now opens. the tags say what a
plugin is FOR; this says what state the machine is in, which is the question
somebody opening a store asks first — narrowing by kind is what you do after.

THREE TABS, because there are genuinely three states and the old grid had two.
`Installed` is what runs. the other two are both "not installed" and are not the
same thing:

  Official   the catalogue — somewhere else, a download away.
  Available  already ON THIS MACHINE, not installed. where a plugin fetched from
             a git URL lands, and where one deleted-but-not-purged sits.

collapsing those hid the entire point of fetching from a repository: you add
something and it is indistinguishable from twenty things you have not downloaded.

it opens on Installed rather than the catalogue. a store that opens on what it
can sell you is selling; this is mostly where somebody checks on something they
already have.

THE GIT FIELD MOVED INTO THE AVAILABLE TAB, which is the tab its result appears
in — and only shows there. floating above a filtered grid it was an action whose
outcome landed somewhere off screen, which is how the first version installed a
plugin with no feedback at all. same mistake, one layer up.

the tag grid is untouched and still what a filter shows. two views because they
answer different questions: "what have I got" wants installed-versus-not, "what
is there for my calendar" wants everything of that kind whatever its state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:56:27 +01:00
pastilhasandClaude Opus 5 e2d03394e1 the marketplace nav, in the house section style
taken from fofos-admin/web/NavPanel.tsx, which is the pattern from here on.
three changes, each doing a job rather than being a preference:

SEPARATION. `border-b border-border` on every row, replacing gapped pills. a
gapped list leaves the boundary between one item and the next to guesswork; a
ruled edge says where a row ends.

THE LABEL IS NO LONGER MUTED. it was `text-duck-dark/70` — every row whispering,
with nothing for the eye to land on. the muting moves down to the second line,
where a count genuinely is secondary.

THE COUNT MOVES OUT OF THE RIGHT-HAND COLUMN. it was a number at the far edge at
60% opacity, which is where the eye goes last. as the row's second line — "3
plugins" — it is read WITH the label rather than hunted for, and it gives every
row the same two-line shape the fofos panel has.

active is `bg-muted`, a filled row, rather than a tinted label. colour on the text
was carrying both "this is a link" and "this one is selected", which is why
nothing looked selected.

the footer moved to the same border and the same `muted-foreground` token rather
than the duck palette, so it sits under the rows instead of looking like another
kind of thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:50:37 +01:00
pastilhasandClaude Opus 5 cac58ea044 the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid,
the progress log is in the detail panel — so a URL install ran to completion with
nothing on screen and the plugin simply appeared, already installed. no feedback,
and no moment to change your mind about running somebody's code.

cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON
DISK, so a fetched plugin shows up with `installed: false` — Available, exactly
like a catalogue entry — and the existing card, its Install button and its
streamed progress do the rest. one install path, and it is the one people have
already used.

so `install-from-url` and its streaming twin are gone, replaced by
`fetch-from-url`, which clones and stops. the streaming route existed only to
carry progress into a view that could not render it.

the outcome is REPORTED, since that was the complaint: "fofos-admin added — click
it to install", naming what the URL resolved to. the name is derived rather than
typed — appName IS the directory, deciding the route, the table prefix and the
process name — so being told what it became is the only way to know which card is
yours. it stays on screen until the next attempt rather than being a toast that
is gone before you look up.

discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS,
discovery checks it parses. a directory failing that still renders in the grid as
broken, which is honest, but saying so at fetch time reaches the person while
they are still looking at the field they pasted into.

verified: fetch 200 with the derived name, the plugin listed installed=false, a
second fetch refused with "already on disk" rather than clobbering it, and the
icon present in the clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:44:43 +01:00
pastilhasandClaude Opus 5 5c6c5b31f8 "Install from Git repository", in the marketplace
a button beside the plugin count that becomes a URL field. everything after it is
the machinery that was already there: same installPlugin, same step and log
frames, same progress panel, same result.

STREAMED, because that is what "works exactly like it already does" means here.
an install writes an ecosystem entry, starts a process and rebuilds the router;
answering once at the end is a spinner that stops, with no way to tell "started
the sidecar" from "could not". the frame reader in usePlugins was split out
rather than copied — it never cared which URL it read from, and two copies of an
incomplete-frame parser is two places to get it subtly wrong.

A TWIN ROUTE, not a case inside the verb map. every other verb names an
INSTALLED plugin in its path; this one names something the machine has never
seen, and its subject arrives in the body. folding it in would mean a route whose
`:appName` is ignored for exactly one value of `:verb`.

NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never
declared in a manifest — so it decides the route segment, the table prefix and
the pm2 process name. a box for it would invite a name that disagrees with the
address. it is derived server-side and reported as the FIRST streamed step, which
is the moment it can still be cancelled.

collapsed until asked for: a permanent URL box at the top of a catalogue reads as
the primary way in, and it is the exception — for somebody who already has an
address.

verified end to end against fofos/fofos-admin: 30 frames, every step from clone
to mount, `assets: published to /plugins/fofos-admin/` now that it carries an
icon, and the icon served at 256x256 with alpha intact. purged afterwards —
officer-dev is not that website's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:27:16 +01:00
pastilhasandClaude Opus 5 d273dc6a14 install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and
give it to a friend" had no route. the first real case is a plugin that must
never be listed: fofos/fofos-admin is one website's admin panel, useful to
exactly one person.

MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the
URL, refused to overwrite an existing directory, checked for a manifest and
removed a failed clone. exactly one line tied it to the marketplace — where the
string came from. so this is a source parameter threaded through, and a route.

THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is
never declared in a manifest — the id cannot disagree with where the code sits.
that decision is inherited here rather than remade: the repo name becomes the
directory, the route segment, the table prefix and the sidecar suffix. so it is
checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone
under an unusable name reads as a broken plugin rather than an absent one.

the URL is validated identically whether it came from the catalogue or from a
person, because the catalogue is not a trust boundary — it is just where a string
came from, and the same string reaches the same argv. what differs is who may
ask: this route is owner-only, and the owner is choosing what code runs rather
than choosing from a list somebody curated. validation is not review, and nothing
here reads what it cloned.

caught by testing: the source reached installPlugin and stopped there — the
fetchPlugin call still had the old two arguments, so a URL install fell back to
the catalogue and failed with "no catalogue entry". the guard tests are what
surfaced it, because the error named the wrong cause.

verified end to end against fofos-admin — cloned, schema applied, mounted, dock
pinned — then purged, since officer-dev is not that website's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:12:54 +01:00
pastilhasandClaude Opus 5 4e0a065f0e a host can drop a core sidecar it does not use
`OFFICER_DISABLED_SIDECARS` in .env, comma separated, filtered out of the
generated ecosystem file.

core processes are a fixed array because the platform's own features depend on
them. that is still true — what is not true is that every host runs every
feature. fofos is a 1.9 GB machine serving a website, and officer-opencode sat on
it costing 456 MB for a harness nobody there uses.

it froze hard twice today, and the tell is what is MISSING from the journal: no
OOM kill, no panic, no shutdown — the log simply stops mid-session and resumes at
the manual reboot. that is memory exhaustion with no headroom left to kill
anything cleanly, on a box with 1.9 GB and a 2 GB swapfile that was already 440
MB into swap one minute after boot.

.env rather than a database setting, and that is the load-bearing part: this file
is regenerated during a plugin install, so a host that dropped a process needs it
to stay dropped across one. per-host is also exactly the scope of the decision.

not validated. naming `officer` or `officer-pty` will break that host, and which
processes are safe to drop depends on what the host is for — which this script
cannot know and should not guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:02:52 +01:00
pastilhasandClaude Opus 5 f4685eeafe one plugins directory, because the second one was symlinks
`officer-dev/plugins/` is gone. it was created to hold every repo in the org side
by side, and by today it held 22 symlinks into platform/plugins, one duplicate
clone of a plugin that is installed here, and a README describing a state that
had already been fixed.

it could not have been more than that. a plugin cannot live outside the platform:
bun resolves a symlink to its real path, so from there `@@/sidecar/create-proxy`
and every relative import fail. tested both, plus `--preserve-symlinks`, which
bun 1.3.14 accepts and ignores.

WHICH MATTERS BEYOND TIDYING, because marketplace-sync leaned on the distinction.
its comment said the second directory was "where a release is actually made", so
publishing from it would advertise released versions rather than installed ones.
that was never true — symlinks meant both paths read the same working copy. the
bug it describes (21 plugins tagged, the store still showing 1.0.0) was never
addressed by the thing built to address it.

the honest fix is in the comment now: each plugin directory IS a git repo with
its own tags, so the released manifest can be read out of the tag —
`git show <tag>:manifest.ts` — without a second checkout. not written yet, and
said plainly rather than left implied.

finances is deleted, at the owner's request: not installed, no pm2 entry, no
ecosystem entry, no plugin_installs row, no tables. both clones were clean and
pushed. the gitea repo is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:23:59 +01:00
pastilhasandClaude Opus 5 87d1740caf deploy/2026.08.28.01 — seven hosts, one process each, and a probe that had to wait
First release under the new watcher loop. 7 of 7 target hosts to 9c8af9d4;
officer-dev skipped as the host running the rollout.

No db:push and no plugins:update, per the plan — so officer was the only
process that moved anywhere. Route probe returned 401 on all seven; three
answered 000 first because pm2 returns before the server binds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 22:09:59 +01:00
pastilhasandClaude Opus 5 9c8af9d424 deploy/2026.08.28.01 — sign in as a user
two commits, one feature, and a plan that leaves things out for stated reasons.

NO SCHEMA, NO DEPENDENCY, NO PLUGIN CHANGE — checked with git rather than
asserted, because asserting it is exactly what sank 2026.08.27.01.

db:push is deliberately not a step. yesterday's report established that it prints
"Changes applied" when it applies nothing, so its output is not evidence, and on
this fleet it re-applies a default on command_hq_sessions.reference_paths every
time plus churns pk_music_now_playing on two hosts. running it here would be
noise against a database this release does not touch.

so the rollout is a merge and one restart. the verification step is an
unauthenticated POST to the new route: 401 means mounted, 404 means the bundle
did not move — a check that works without a session, on an owner-only feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:03:44 +01:00
pastilhasandClaude Opus 5 fb0cc880ac sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for
me". reproducing that with a password reset means locking somebody out of their
own account in order to look at it, which is worse than the bug.

THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and
reaches every handler, because user-middleware puts the whole payload on the
context. without it an impersonated action is indistinguishable from that
person's own — in a log, in a transcript, in anything keyed on a user id — and a
support tool that can forge activity as somebody else is a different and much
worse thing than a support tool.

ONE HOUR, not thirty days. a session lasts a month because a person uses their
own account daily; impersonation is a task you finish. an hour is long enough to
reproduce a problem and short enough that a forgotten tab stops being a live
credential for somebody else's account by lunchtime.

IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any
token carrying `impersonatedBy`, so impersonating another Super Admin does NOT
hand user management back — which would have made this a way to launder owner
actions through somebody else's id. stated twice, in the gate and in the handler,
because one file away is too far for this one.

THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an
owner session — a far more dangerous thing to own than this whole feature. the
cost is that clearing browser storage strands you as the other person until the
hour expires, and signing in again fixes that.

the banner is loud and sits ABOVE the header, outside everything a fullscreen
panel can hide. the failure it prevents is not a hole, it is forgetting: reading
a mailbox or deleting something while believing it is yours. it asks the server
who it is rather than decoding the token, so it cannot keep showing a name after
the hour has run out.

verified end to end against a throwaway account, since this host has one user:
impersonate 200 with the target's id and impersonatedBy naming the owner, 60
minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining
403, user management while impersonating 403; one [SECURITY] line per start. the
probe account was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 21:51:20 +01:00
pastilhasandClaude Opus 5 02ad4d3bdf deploy/2026.08.27.02 — seven hosts, and a push that lies about applying changes
Every target host is at the tag. The one destructive step ran on six of them and
was guarded by a count each time; alpha kept its table and its 8 rows.

The finding is that `bun db:push` prints "Changes applied" unconditionally — it
said so on an unchanged database, twice in a row. `--verbose` shows the truth:
what it re-applies everywhere is pre-existing composite-key drift, and nothing
touches dav_app_passwords on any host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 20:38:15 +01:00
40 changed files with 1863 additions and 121 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**107 open, 32 closed**, across 34 themes.
**106 open, 33 closed**, across 34 themes.
| Theme | Open | Closed |
| --- | --- | --- |
@@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
+98 -35
View File
@@ -122,6 +122,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
@@ -365,7 +366,7 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -443,31 +444,43 @@
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
@@ -475,11 +488,13 @@
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
@@ -2135,7 +2150,7 @@
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -2147,7 +2162,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -2419,6 +2434,20 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-linuxmusl-arm64/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-linuxmusl-x64/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2519,6 +2548,8 @@
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -2557,6 +2588,8 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
@@ -2597,35 +2630,23 @@
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
"sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
"sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
"sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
"sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
"sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
"sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
"sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -2727,6 +2748,46 @@
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],
@@ -2899,6 +2960,8 @@
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
+96
View File
@@ -0,0 +1,96 @@
# 2026.08.27.02 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
was dirty in tracked files, none diverged, none needed a skip.
## Per host
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
| --- | --- | --- | --- | --- | --- |
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
process was left `errored` or `stopped` anywhere.
## The schema, verified by query per host
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
not inferred from an exit code. Final sweep:
```
alpha caldav=yes dav_app_passwords=present rows=8
fofos caldav=no dav_app_passwords=GONE
macbookpro caldav=no dav_app_passwords=GONE
macbruno caldav=no dav_app_passwords=GONE
production-pertento-bridge caldav=no dav_app_passwords=GONE
edge-pertento-bridge caldav=no dav_app_passwords=GONE
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
```
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
are intact.
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
under the plan's own guard rather than under an assumption.
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
`officer-marketplace`, confirmed zero of seven.
## `bun db:push` prints `Changes applied` even when it changes nothing
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
unchanged database. It is unconditional output, not evidence.
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
real content was:
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
`pk_music_now_playing` as the same two-column key
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
said it would.
Not fixed here. A rollout is the wrong place for a schema-code change.
## Plugins
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
deadpool 2/2, macbookpro none installed.
## What was not done
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
owner restarts it deliberately.
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
knowing.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
touch it and it was left alone.
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
## Worth a look, per the plan's own risk note
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
router's mount order changed (device registration is worth one check).
+71
View File
@@ -0,0 +1,71 @@
# 2026.08.28.01
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
Two commits since `2026.08.27.02`, one of which is that release's own report.
## No schema change. No dependency change. No plugin change.
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
plan got wrong, so it is stated here as a check that was run rather than a belief.
## `bun db:push` is deliberately NOT a step
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
mis-diff in `src/databases/CLAUDE.md`.
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
2. `pm2 restart officer`
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
boot.
## Verify, per host
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
401 or 403 means mounted, 404 means the bundle did not move:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
## Risk
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
impersonated — that last one is what stops it chaining into owner powers.
Nothing to roll back but the merge: no data is written and no schema is touched.
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.28.01 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
was already at the tagged commit.
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
needed a skip.
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
that ran the previous nine; what was unproven was the loop around it.
## Per host
| Host | HEAD before | HEAD after | Restarted | Route probe |
| --- | --- | --- | --- | --- |
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
`stopped` on any host.
## The verification, per host
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
not from habit:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
answered.
## No schema change — checked, not assumed
The plan claimed no schema change, no dependency change and no plugin change. That claim was
**re-derived before the first host was touched**, not taken on trust:
```
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
```
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
nothing under `plugins/`.
So there is no schema verification section in this report, because there was nothing to verify. That is
the honest answer rather than an omission.
## Steps the plan deliberately did NOT include, and which were NOT run
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
a schema, so running it would have been churn.
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
nothing in this release needs it.
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
## Version agreement
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
## Say what was not done
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
previous release's process against the new code on disk.
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
reason to skip a host, and a fast-forward could never have touched it.
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
rollout — checked, not assumed. The restart named `officer` alone.
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
exists anywhere.
+122
View File
@@ -0,0 +1,122 @@
# 2026.08.29.01 — sharp becomes infrastructure
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
a dependency with a native binary and a system package, so `bun install` is a required step rather than
an optional one, and there are two things to verify per host that no previous plan asked for.
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
one host needs a command none of the others do.
## What is in it
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
the video. `package.json` and `bun.lock` both changed.
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
not provisioned by the new script, so this release checks rather than assumes.
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
- Dock and Spotlight clip bare plugin artwork to the tile shape.
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
## Schema: UNCHANGED. Do not run `db:push`.
Verified, not assumed:
```bash
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
```
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
plugin moved, say so in the report — do not push a schema to cover it.
## Every host — the commands
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
checkout from PM2's `pm_cwd`.
### 1. Install — REQUIRED, and it is the point of this release
```bash
bun install
```
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
around 14 packages — sharp and its platform-specific libvips binaries.
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
human.
### 2. Prove sharp actually loads — the check that matters
```bash
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
```
Expect `libvips 8.18.6`.
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
folder of photographs, which nobody reads as "a native module is missing".
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
repair and it is a human's to run, not this rollout's.
### 3. ffmpeg must be present
```bash
command -v ffmpeg && ffmpeg -version | head -1
```
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
2026-08-29: present on every host **except `macbruno`** — see below.
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
improvises.
### 4. Restart
```bash
pm2 restart officer
```
**`officer` only.** Nothing else in this release touches a sidecar. Never
`pm2 restart ecosystem.config.cjs`.
### 5. Verify it came back
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
```
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
`production-pertento-bridge`, 9000 elsewhere).
## Per host
| Host | Extra command | Note |
| --- | --- | --- |
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
| `alpha` | — | x86_64, ffmpeg present. |
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
| `production-pertento-bridge` | — | x86_64, port **9010**. |
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
was found, and `officer` back online with a moved `restart_time`.
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.27.02",
"version": "2026.08.29.01",
"private": true,
"type": "module",
"workspaces": [
@@ -160,6 +160,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
+26 -1
View File
@@ -60,9 +60,34 @@ function coreProcesses(): Pm2App[] {
const [name, script, args] = line.split('|');
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
});
})
.filter((app) => !DISABLED.has(app.name));
}
/*
Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`.
A core process is core because the platform's own features depend on it — which is why the list is a
fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a
1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness
nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory
exhaustion looks like when there is not enough headroom left to kill something cleanly.
Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that
has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is
per-host, which is exactly the scope of the decision.
Naming something the platform genuinely needs will break that host — `officer` itself, or
`officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop
depends on what the host is for, and this script cannot know that.
*/
const DISABLED = new Set(
(process.env.OFFICER_DISABLED_SIDECARS ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
async function pluginEntries(): Promise<Pm2App[]> {
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
+11 -1
View File
@@ -60,7 +60,17 @@ export async function renderPlatformSkill(): Promise<string> {
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
/*
`name` is an IDENTIFIER, not a label: lowercase-hyphenated and equal to the directory, per both
harnesses' rules. The human name is `title`.
This generator emitted `name: Officer platform` and was missed when c24859c fixed every skill by
hand — so the file on disk was right and running this script put the old shape back. `docs:check`
then failed on a line count that nobody could fix by regenerating, which is the worst place for a
drift check to point.
*/
out('name: platform');
out('title: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
+14 -7
View File
@@ -69,14 +69,21 @@ const git = (dir: string, args: string[]): string | null => {
/*
Which plugins directory to publish FROM.
There are two, and they are not the same thing. `platform/plugins/` holds what this machine has
INSTALLED — updated by `plugins:update`, which is a git pull, and therefore always at whatever version
was last installed. `officer-dev/plugins/` holds every repo in the org, cloned for working on, and is
where a release is actually made.
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
Publishing from the install directory means the catalogue advertises the version this machine happens
to be running rather than the version that was released. That is exactly what happened on 2026-08-27:
21 plugins were tagged and the store went on showing 1.0.0.
Publishing from it means the catalogue advertises the version this machine happens to be running
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
were tagged and the store went on showing 1.0.0.
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
working copy and published the same version. It was removed on 2026-08-28.
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
real git repo with its own tags, so the released manifest can be read out of the tag itself —
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
written yet, and until it is, this script advertises what is installed.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
+8 -3
View File
@@ -31,9 +31,14 @@ import { join } from 'node:path';
/*
Which plugins directory to check.
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
`platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant
to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but
symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its
real path, so `@@/…` and every relative import fail from there. Tested, including
`--preserve-symlinks`, which Bun accepts and ignores.
`PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only
reason this is not a constant.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
const HOST = 'git@gitea.officer.dev';
+11 -4
View File
@@ -82,6 +82,13 @@ LAST_SKIPPED=()
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
# out to it, so on a machine without it those features fail at the moment somebody
# opens a folder — which reads as Officer being broken rather than as a package
# being absent. It was missing from this list until 2026-08-29 and the features
# worked anyway, because every machine that had been set up by hand already had it.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
@@ -109,24 +116,24 @@ pkgs_core() {
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban unattended-upgrades
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler fail2ban
inotify-tools poppler ffmpeg fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban
inotify-tools poppler-utils ffmpeg fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
#
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
;;
esac
}
+20 -2
View File
@@ -405,6 +405,7 @@ if ! skip; then
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo " sharp: $(sharp_loads && echo "loads (libvips $(sharp_version))" || echo 'does not load')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
@@ -412,12 +413,29 @@ if ! skip; then
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
echo ""
echo " sharp is the opposite problem: it does not compile, it downloads a"
echo " libvips binary for this exact platform and architecture. So the"
echo " install can succeed and the module still not load, and the way that"
echo " shows up is a file browser drawing generic icons over a folder of"
echo " photographs. Loading it is the only check worth running."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
if deps_installed && node_pty_built && sharp_loads; then
ok "already installed; node-pty built and sharp loads"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
# Reported separately from node-pty rather than as one "native modules"
# verdict: they fail for different reasons and want different fixes, and a
# combined line sends somebody to rebuild the wrong one.
if ! sharp_loads; then
warn "installed, but sharp does not load"
echo " Image thumbnails in the file browser will silently fall back to"
echo " file icons. Usually the platform-specific binary did not resolve:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: sharp does not load")
fi
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
+23
View File
@@ -95,4 +95,27 @@ deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
# would not start.
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
# sharp is libvips, and it arrives as a PREBUILT BINARY chosen by platform and
# architecture — @img/sharp-linux-x64 and its libvips, or the arm64 pair, or the
# musl ones. bun picks them from the lockfile's optional dependencies.
#
# Which means the install can succeed and the module can still be unusable: a
# lockfile written on x64 carries the x64 binaries, and on a machine where the
# right optional package did not resolve, `import('sharp')` throws at the first
# call rather than at install time. Same failure shape as node-pty above, and the
# same reason to check it here — the symptom otherwise is a file browser drawing
# generic icons over a folder of photographs, which nobody reads as "a native
# module is missing".
#
# Loading it is the only test worth running. A directory being present proves
# nothing; sharp's own `versions.vips` proves libvips is there and bound.
sharp_loads() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => { if (!m.default.versions.vips) process.exit(1) }).catch(() => process.exit(1))\"" >/dev/null 2>&1
}
# What libvips sharp found, for the report. Empty when it does not load at all.
sharp_version() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => console.log(m.default.versions.vips)).catch(() => {})\"" 2>/dev/null | tr -d '\r'
}
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
<ImpersonationBanner />
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
@@ -109,10 +109,16 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
style={{
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
// does not paint it.
//
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
// its glyph, so the corners were never reached and nothing here looked wrong for as
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
// opaque background kept its own square corners and sat in the dock as the one tile
// that had not been told the shape.
background: item.tile === 'bare' ? 'transparent' : item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
@@ -143,7 +143,10 @@ const Row = ({
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
// both, a few pixels apart.
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
@@ -1,7 +1,17 @@
import { startImpersonation } from 'officerdev';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import {
Copy,
Crown,
KeyRound,
Loader2,
RotateCcw,
SquareTerminal as TerminalIcon,
Trash2,
UserCog,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -128,6 +138,25 @@ export const UsersSection = () => {
}
};
/**
* Sign in as this person, for an hour.
*
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
* new password and a deletion, which are neither.
*/
const impersonate = async (user: ManagedUser) => {
setPendingId(user.id);
try {
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
// Swaps the token and reloads — nothing after this line runs.
startImpersonation(token);
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -263,6 +292,22 @@ export const UsersSection = () => {
</Button>
)}
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
offers it would be the one row where it cannot work. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Sign in as ${user.email}`}
title="Sign in as this user for one hour"
onClick={() => void impersonate(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
+61 -1
View File
@@ -9,6 +9,7 @@ import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
@@ -210,11 +211,24 @@ router.get('/ls', async (ctx) => {
if (!isInside(rootDir, fullPath)) return null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory();
return {
name,
type: s.isDirectory() ? 'directory' : 'file',
type: isDirectory ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
/*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire.
The client had no way to know without its own copy, and the copies would have drifted — the
browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a <video> tag can play, which
is a different question from what ffmpeg can take a frame out of, and every .mkv in the
folder would have gone without a picture for that reason alone.
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
*/
thumb: isDirectory ? null : thumbnailKind(name),
};
}),
);
@@ -333,6 +347,52 @@ router.post('/rename', async (ctx) => {
// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
/**
* A small JPEG of an image or a video, for the browser's grid and list.
*
* ── Why not just point <img> at `/raw` ──
*
* Because a directory of photographs is then a directory of full-size downloads. Thirty 8MB images to
* draw thirty 80px squares is a quarter of a gigabyte over the wire to show something the size of a
* postage stamp, and it is worse on a phone, which is where it matters. Video cannot be done that way
* at all — there is no frame until something decodes one.
*
* ── Authenticated by `?token=`, like `/raw` ──
*
* An `<img>` cannot send an Authorization header. `user-middleware` already accepts the token in the
* query for exactly this reason, so this route needs no special handling — and it stays behind the same
* gate as everything else here rather than becoming a public URL that happens to be hard to guess.
*
* ── `immutable`, honestly ──
*
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*/
router.get('/thumb', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const kind = thumbnailKind(relPath);
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
if (!kind) return ctx.text('no thumbnail for this file type', 415);
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('size')));
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
// the file, and logging it as a server error would bury the ones that are.
if (!result.ok) return ctx.text(result.reason, 422);
return new Response(Bun.file(result.path), {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
});
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
+258
View File
@@ -0,0 +1,258 @@
import { createHash } from 'node:crypto';
import { mkdir, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import sharp from 'sharp';
/**
* Thumbnails for the file browser — one small JPEG per file, made once and cached.
*
* ── sharp for images, ffmpeg for video ──
*
* Each does the job it is actually for. sharp is libvips in-process: no process to spawn, no pipe, and
* an order of magnitude less work for the common case, which is a folder of photographs. A video has no
* frame until something decodes one, and that is ffmpeg's job and nobody else's.
*
* sharp was deliberately NOT used when this was first written, because it sat in `node_modules` only as
* somebody else's transitive dependency and importing it would have been an undeclared dependency —
* the thing the frozen lockfile exists to prevent. It is now a declared one, added through the
* procedure `bunfig.toml` describes, so this note is history rather than a caveat.
*
* Both are infrastructure and both are checked by the setup script, because a machine missing either
* fails at the point somebody opens a folder rather than at the point it was built.
*
* ── The cache key contains mtime and size, and that is what makes it safe ──
*
* A path alone would be wrong: replace a photo with a different one under the same name and every
* screen keeps showing the old thumbnail, with nothing to clear it but a person who knows the cache
* exists. With mtime and size in the hash, a changed file simply has a different key — the old entry
* is orphaned rather than stale, and the response can be `immutable` because that URL's answer genuinely
* never changes.
*
* ── Failures are remembered ──
*
* A corrupt file, a codec ffmpeg does not have, a zero-byte download: each would otherwise spawn a
* process on every render, forever, for a file that will never produce a thumbnail. The browser retries
* a broken <img> whenever it re-enters the viewport, so "no cache on failure" means a directory with one
* bad video runs ffmpeg every time somebody scrolls past it.
*/
/**
* What the client is allowed to ask about. Anything else is answered 415 rather than attempted.
*
* The image list grew when sharp arrived: libvips reads HEIC and SVG, which ffmpeg does not, and those
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
* file icons was the most visible gap this had.
*/
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
/*
`ts` is NOT here, and that is the one entry worth explaining.
It is a real video extension — MPEG transport stream — and it was in this list until a deploy to fofos
reported `test-setup.ts` as a video. On any machine Officer runs on, `.ts` is TypeScript approximately
always, so including it meant every source file in every repository drew a play badge and spawned an
ffmpeg that could only fail.
The two ways of being wrong are not the same size. Excluded, a genuine transport stream shows a file
icon; included, browsing a source tree runs a decoder per file. Nobody stores broadcast captures in a
folder they browse from here.
*/
const VIDEO_EXT = new Set([
'mp4',
'mov',
'm4v',
'webm',
'mkv',
'avi',
'wmv',
'flv',
'mpg',
'mpeg',
'3gp',
'm2ts',
'mts',
]);
export const thumbnailKind = (name: string): 'image' | 'video' | null => {
const ext = extname(name).slice(1).toLowerCase();
if (IMAGE_EXT.has(ext)) return 'image';
if (VIDEO_EXT.has(ext)) return 'video';
return null;
};
/** Bigger than the grid tile draws, so a retina screen is not resampling upwards. */
const MAX_SIZE = 512;
const DEFAULT_SIZE = 256;
export const clampSize = (raw: string | undefined): number => {
const asked = Number(raw);
if (!Number.isFinite(asked)) return DEFAULT_SIZE;
// Snapped to a step, so a panel resizing by a pixel cannot mint a hundred cache entries of one image.
return Math.min(MAX_SIZE, Math.max(64, Math.round(asked / 64) * 64));
};
/*
How many thumbnail jobs may run at once.
The grid is virtualised, so only what is on screen is requested — but scrolling fast through a folder
queues far more than is visible, and each item either spawns a decoder or turns libvips loose on a
40-megapixel photograph. Four keeps a browse responsive on a small box; unbounded turned a 1.9GB
machine into a swap storm once already, which is the whole reason there is a number here.
It covers BOTH paths deliberately, even though sharp is in-process and ffmpeg is not. The limit is
about the machine, not about the mechanism: libvips uses its own thread pool per image, so a hundred
concurrent resizes is no gentler than a hundred processes.
*/
const MAX_CONCURRENT = 4;
let running = 0;
const waiting: Array<() => void> = [];
const acquire = async (): Promise<void> => {
if (running < MAX_CONCURRENT) {
running += 1;
return;
}
await new Promise<void>((resolve) => waiting.push(resolve));
running += 1;
};
const release = () => {
running -= 1;
waiting.shift()?.();
};
/**
* Paths that will never produce a thumbnail, by cache key — so a failure is not retried on every scroll.
*
* In memory rather than on disk: it is cheap to rebuild, and a restart is the natural moment to give a
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
* long-lived process browsing a lot of broken media should not grow without limit.
*/
const failed = new Set<string>();
const FAILED_LIMIT = 5_000;
const rememberFailure = (key: string) => {
if (failed.size >= FAILED_LIMIT) failed.clear();
failed.add(key);
};
/** A frame from a second in, so a video that opens on black does not thumbnail as a black square. */
const SEEK_SECONDS = '1';
/**
* Run ffmpeg and report whether a FRAME CAME OUT — which is not the same as whether it succeeded.
*
* ffmpeg exits 0 having written a zero-byte file when the seek lands past the end of the input.
* Observed on a 0.4s clip: `-ss 1` gave exit 0 and 0 bytes, and the same command without the seek gave
* exit 0 and a real JPEG. So an exit code cannot decide whether to retry, and the earlier version of
* this that trusted one reported every short video as having no thumbnail.
*
* The output file is the test, here and at the end. There is only one question worth asking of an
* image encoder and it is whether there is an image.
*/
const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> => {
const proc = Bun.spawn(['ffmpeg', ...args], { stdout: 'ignore', stderr: 'ignore' });
// 20s is generous for one frame and short enough that a wedged decode does not hold a slot forever.
const timer = setTimeout(() => proc.kill(), 20_000);
await proc.exited;
clearTimeout(timer);
const written = await stat(output).catch(() => null);
return written !== null && written.size > 0;
};
/**
* One image, resized to fit the box.
*
* `fit: 'inside'` with `withoutEnlargement` matches what the ffmpeg path does — fit without cropping,
* and never scale a 40px icon up to 256, which looks worse than leaving it small.
*
* `failOn: 'none'` is deliberate and is the difference between a browsable folder and a wall of icons.
* libvips defaults to rejecting a file with any warning in it, and a truncated JPEG — the ordinary
* result of an interrupted copy or a phone that ran out of battery mid-write — is exactly that. Every
* viewer on earth will show what there is of such a file, so refusing to thumbnail it would be this
* browser being stricter than the thing somebody opens the file with.
*
* `limitInputPixels: false` for the same reason applied to size: libvips refuses images past a default
* pixel count as a denial-of-service guard, which is right for an upload endpoint and wrong here. These
* are the owner's own files, already on the owner's own disk; a panorama is not an attack.
*
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
* file, and the caller already turns that into a fallback icon and a remembered failure.
*/
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
try {
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
.rotate() // Honour the EXIF orientation, or every photo taken sideways thumbnails sideways.
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80, mozjpeg: true })
.toFile(output);
return true;
} catch {
return false;
}
};
export type ThumbnailResult = { ok: true; path: string } | { ok: false; reason: string };
/**
* Produce (or find) the thumbnail for one file, and return where it is on disk.
*
* `userDataDir` is the caller's own data directory — the cache is per user, because the path being
* thumbnailed is resolved against that user's root and two users' `/photos/a.jpg` are different files.
*/
export async function buildThumbnail(
absPath: string,
userDataDir: string,
kind: 'image' | 'video',
size: number,
): Promise<ThumbnailResult> {
const info = await stat(absPath).catch(() => null);
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
const key = createHash('sha256').update(`${absPath}\0${info.mtimeMs}\0${info.size}\0${size}`).digest('hex');
if (failed.has(key)) return { ok: false, reason: 'no thumbnail for this file' };
/*
Two levels of fan-out on the first byte of the hash. A single flat directory of thumbnails is fine
until it is forty thousand entries, at which point `readdir` on it — which any backup or sync will
do — becomes the slowest thing on the machine.
*/
const cacheDir = join(userDataDir, 'cache', 'thumbs', key.slice(0, 2), key.slice(2, 4));
const cachePath = join(cacheDir, `${key}.jpg`);
const cached = await stat(cachePath).catch(() => null);
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
await mkdir(cacheDir, { recursive: true });
await acquire();
try {
/*
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
keeps it even, which the JPEG encoder requires for chroma subsampling.
*/
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
let ok: boolean;
if (kind === 'image') {
ok = await resizeImage(absPath, cachePath, size);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
// broken file. Take the first frame instead before giving up on it.
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
}
if (!ok) {
rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
return { ok: true, path: cachePath };
} finally {
release();
}
}
+100
View File
@@ -6,6 +6,9 @@ import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
import { findOrphanTables } from '../../plugins/orphans';
import { fetchPlugin } from '../../plugins/fetch';
import { discoverPlugins } from '../../plugins/discover';
import { listPluginInstalls } from 'officerdb';
import { iconUrl } from '../../app-store/assets';
import {
installPlugin,
@@ -224,6 +227,103 @@ pluginsRouter.post('/update-all', async (ctx) => {
});
});
/**
* `https://host/owner/repo` or `…/repo.git` → `repo`.
*
* Derived rather than asked for, so the name and the address cannot disagree. `appName` IS the directory
* a plugin sits in — never declared in a manifest — so this string becomes the route segment, the table
* prefix and the sidecar suffix. Checked before anything is cloned: a half-clone under an unusable name
* reads as a BROKEN plugin rather than an absent one, which is much harder to get out of.
*/
function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } {
const derived = url
.replace(/\.git$/, '')
.replace(/\/+$/, '')
.split('/')
.pop();
const appName = override?.trim() || derived;
if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) {
return {
error:
`"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` +
'it becomes the directory, the route and the table prefix.',
};
}
return { appName };
}
/*
POST /plugins/fetch-from-url — put a plugin on the machine, and stop there.
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
had no route. The first real case is one that must never be listed: fofos-admin is a single website's
admin panel.
── Why this FETCHES and does not install ──
It was an install first, and that was wrong in a way only using it showed: the button lives on the
grid, the progress log lives in the detail panel, so a URL install ran to completion with nothing on
screen and the plugin simply appeared, installed. Streaming into a view that cannot render it is worse
than not streaming.
Cloning alone is enough to make a plugin real — `/api/plugins` lists what is ON DISK with
`installed: false`, so it shows up as Available like any catalogue entry. From there the existing card,
the existing Install button and the existing streamed progress do the rest. One flow, not two.
── The name still comes from the URL ──
`appName` IS the directory, so the repository name decides the route segment, the table prefix and the
process name. Checked before cloning: a half-clone under an unusable name reads as a BROKEN plugin
rather than an absent one.
Owner-only like everything else here, and worth saying plainly — this puts somebody's code on the
machine. It does not run it, which is exactly why installing stays a separate, deliberate click.
*/
pluginsRouter.post('/fetch-from-url', async (ctx) => {
type FromUrl = { url?: string; appName?: string };
const body = await ctx.req.json<FromUrl>().catch(() => ({}) as FromUrl);
const url = body.url?.trim();
if (!url) throw errors.BAD_REQUEST('url is required');
const named = pluginNameFromUrl(url, body.appName);
if ('error' in named) throw errors.BAD_REQUEST(named.error);
const { appName } = named;
const fetched = await fetchPlugin(appName, undefined, url);
if (!fetched.ok) throw errors.BAD_REQUEST(fetched.error ?? `could not fetch ${appName}`);
/*
Re-read from disk rather than trusting the clone.
`fetchPlugin` checks that `manifest.ts` EXISTS; discovery checks that it PARSES and says what a
plugin needs to say. A directory that fails the second test still renders in the grid — as broken,
which is the honest state — but saying so here means the person hears it while they are still
looking at the field they pasted into.
*/
const { plugins, broken } = await discoverPlugins();
const found = plugins.find((entry) => entry.appName === appName);
const bad = broken.find((entry) => entry.appName === appName);
if (!found) {
return ctx.json(
{ ok: false, appName, error: bad?.error ?? `cloned "${appName}", but it does not read as a plugin` },
400,
);
}
/*
Whether it is ALREADY installed, so the UI can say the true thing and show the right tab.
A directory and an install row are separate facts — deleting a plugin's source leaves the row, so a
re-fetch can land on something the platform still considers installed. Reported here because the
caller has just been told "added" and needs to know where to look; "click it to install" pointing at
an empty tab is worse than no message.
*/
const installs = await listPluginInstalls();
const installed = installs.some((entry) => entry.appName === appName && entry.enabled);
return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName, installed });
});
pluginsRouter.post('/:appName/install', async (ctx) => {
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
return ctx.json(result, result.ok ? 200 : 400);
+70
View File
@@ -0,0 +1,70 @@
import type { Handler } from 'hono';
import { getUserById } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { clientIp } from '../auth/client-ip';
/**
* Sign in AS another account, from user management.
*
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
* password reset means locking the person out of their own account to look at it, which is worse than
* the bug.
*
* ── The token says whose it is, and who is really holding it ──
*
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
* forge activity as somebody else is a different and much worse thing.
*
* ── One hour, not thirty days ──
*
* A session token lasts a month because a person uses their own account daily. Impersonation is a task
* you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops
* being a live credential for somebody else's account by lunchtime.
*
* ── What it cannot do ──
*
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
* owner actions through somebody else's id.
*
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
* deleted or non-Active user, exactly as it does for an ordinary session.
*
* ── What it deliberately does NOT do ──
*
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
* be stranded as somebody else.
*/
export const impersonateUserHandler: Handler = async (ctx) => {
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
const targetId = Number(ctx.req.param('id'));
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
const target = await getUserById(targetId);
if (!target) throw errors.NOT_FOUND('No such user');
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
const { id, email, name, username } = target;
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
console.warn(
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
);
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
};
+14
View File
@@ -9,6 +9,7 @@ import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
import { permissionAdminRouter } from './permissions-routes';
import { impersonateUserHandler } from './impersonate';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -23,6 +24,17 @@ usersRouter.put('/', updateUserHandler);
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
/*
An impersonated session is not the owner, whoever it is wearing.
`isSuperAdmin` reads the id on the token, so impersonating another Super Admin would otherwise hand
user management straight back — and every action taken there would be recorded against the person
impersonated rather than the person doing it. This one line is what stops impersonation being a way
to launder owner actions through somebody else's account, and it is also what stops it chaining.
*/
if ((ctx.get('user') as { impersonatedBy?: unknown }).impersonatedBy) {
throw errors.FORBIDDEN('An impersonated session cannot manage users');
}
return next();
};
@@ -36,6 +48,8 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Sign in AS somebody, for an hour, on a token that says who is really holding it. See impersonate.ts.
usersRouter.post('/:id/impersonate', ownerGate, impersonateUserHandler);
// Which permissions each role holds. Owner-gated inside its own router.
usersRouter.route('/', permissionAdminRouter);
+18 -6
View File
@@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git');
* verb with different questions — what happens to uncommitted changes, does the version go backwards —
* and it does not exist yet.
*/
export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<FetchResult> {
export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise<FetchResult> {
const dir = join(PLUGINS_DIR, appName);
if (existsSync(dir)) {
@@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
return { ok: false, dir, error: 'git is not installed on this machine' };
}
const entry = await catalogueEntry(appName);
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
/*
A URL the caller supplied, or the catalogue's.
The catalogue is not the only way a plugin should be able to arrive. "Build one and give it to a
friend" had no route at all, and the first real case is a plugin that must NOT be listed: fofos-admin
is one website's admin panel and belongs in nobody's registry.
The URL is checked identically either way, below. It has to be — the catalogue is not a trust
boundary here, it is just where a string came from, and the same string reaches the same command
line. What DOES differ is who is asking: an install from a URL is somebody typing an address, so the
route that accepts one is owner-only.
*/
const url = source?.trim() || (await catalogueEntry(appName))?.source;
if (!url) return { ok: false, dir, error: `no catalogue entry for "${appName}", and no source URL given` };
// The URL comes from the catalogue and reaches a command line, so it is checked rather than trusted.
// Deliberately narrow: https only, no credentials in the URL, no shell metacharacters. A private
@@ -63,8 +75,8 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
// refused a completely valid, credential-free, https clone URL with a message ("https URLs only, no
// credentials") that describes neither actual reason. Row fixed for consistency with its siblings, but
// the regex should never have depended on it.
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(entry.source)) {
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` };
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(url)) {
return { ok: false, dir, error: `refusing to clone from "${url}" — https URLs only, no credentials` };
}
const result = await runLogged(
@@ -74,7 +86,7 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
// `--progress` instead of `--quiet`: git only reports progress when it believes it has a terminal, and
// this is the step where a slow link looks identical to a hang. `runLogged` collapses the per-percent
// rewrites, so the counter is readable rather than a flood.
['git', 'clone', '--depth', '1', '--progress', entry.source, dir],
['git', 'clone', '--depth', '1', '--progress', url, dir],
{
// No terminal to answer a credential prompt: without this, a repository that turned private hangs
// the install forever rather than failing it. Same reasoning as `sudo -n` in os-deps.ts.
+9 -1
View File
@@ -145,6 +145,14 @@ export async function installPlugin(
onStep?: OnStep,
forUserId?: number,
onLog?: OnLog,
/**
* Clone from HERE instead of from the catalogue.
*
* For a plugin that is not in any registry and should not be — one website's admin panel, something
* built for one person. Ignored when the plugin is already on disk, because then there is nothing to
* fetch and a URL would be describing a decision already made.
*/
source?: string,
): Promise<PluginActionResult> {
const steps: string[] = [];
@@ -160,7 +168,7 @@ export async function installPlugin(
// as it was.
let plugin = await findPlugin(appName);
if (!plugin) {
const fetched = await fetchPlugin(appName, onLog);
const fetched = await fetchPlugin(appName, onLog, source);
if (!fetched.ok) {
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
}
@@ -53,6 +53,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
visibleEntries: entries,
viewMode,
currentPath,
rootOverride,
selected,
clipboard,
handleOpen,
@@ -236,6 +237,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
key={entry.name}
entry={entry}
viewMode={viewMode}
path={entryPath(entry.name)}
root={rootOverride}
selected={selected.has(entry.name)}
anySelected={anySelected}
selectedCount={selected.size}
@@ -25,6 +25,7 @@ import {
PinOff,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { Thumbnail } from './Thumbnail';
import {
DropdownMenu,
DropdownMenuContent,
@@ -56,6 +57,10 @@ import { useFormat } from 'i18n';
export type FileItemProps = {
entry: DirEntry;
viewMode: 'grid' | 'list';
/** Root-relative path of this entry, for the thumbnail URL. */
path: string;
/** The browser's root, when it is not `home`. */
root?: string;
selected: boolean;
anySelected: boolean;
selectedCount: number;
@@ -587,6 +592,8 @@ export const FileItem = ({
onTogglePin,
isPinned,
canPaste,
path,
root,
}: FileItemProps) => {
const { t } = useTranslation();
const { date } = useFormat();
@@ -605,6 +612,26 @@ export const FileItem = ({
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
);
/*
The picture, where there is one, wrapping the icon it replaces.
`entry.thumb` comes from the server — see the note on it in useFilesAPI. Both views ask for a size
a little larger than they draw so a retina screen is not resampling upwards, and both fall back to
exactly the icon above when there is no picture to be had.
Rounded in the list and square in the grid: the list row is a line of text with a small image in it,
where a soft edge sits better beside a glyph; the grid tile is the picture, and cropping it into a
rounded box would trim content off a photograph for decoration.
*/
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
{node}
</Thumbnail>
);
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
useEffect(() => {
return () => {
if (clickTimer.current) clearTimeout(clickTimer.current);
@@ -708,7 +735,7 @@ export const FileItem = ({
onPointerDown={(ev) => ev.stopPropagation()}
>
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{icon}</span>
<span className="shrink-0">{listVisual}</span>
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRenameInput
@@ -762,7 +789,7 @@ export const FileItem = ({
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div>
{iconLarge}
{gridVisual}
{renaming ? (
<InlineRenameInput
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Play } from 'lucide-react';
import { getHeaders } from 'hooks/useClient';
// Matches , which declares its own for the same reason: one string, and no
// import cycle between the browser and the viewer.
const API_URL = '/api';
/**
* The picture of a file, where there is one — an image, or a frame from a video.
*
* ── One component for both views ──
*
* The grid and the list want the same thing at two sizes, so the only difference between them is a
* `size` and a class. Two implementations would have drifted the first time one of them learned
* something the other did not, and the fallback behaviour below is the part that would have drifted.
*
* ── It falls back to the icon, and the icon is the CHILD ──
*
* `children` is whatever the item would have drawn without a thumbnail. It is rendered until the image
* loads and again forever if it fails, so a file that cannot be thumbnailed — a codec ffmpeg does not
* have, a corrupt download, a member without file access — looks exactly like it did before this
* existed rather than like a hole in the layout.
*
* That is also why the icon is not merely hidden behind the image: an <img> that 404s draws a broken
* glyph in most browsers, and a layout that reserves space for a picture that never comes reads as a
* page still loading.
*
* ── `loading="lazy"`, on top of virtualisation ──
*
* The grid only mounts what is on screen, so most of the work is already avoided. Lazy loading covers
* the rest: the rows just past the fold are mounted by the virtualiser's overscan, and without this
* every one of them would spawn an ffmpeg for something nobody has looked at.
*/
export const Thumbnail = ({
path,
root,
size,
isVideo,
className,
children,
}: {
/** Root-relative, the same path every other file-browser call uses. */
path: string;
root?: string;
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
size: number;
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
isVideo: boolean;
className?: string;
/** The file icon — shown until the image arrives, and kept if it never does. */
children: React.ReactNode;
}) => {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
if (state === 'failed') return <>{children}</>;
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
{/* Underneath rather than instead of. Swapping on load would reflow the row twice per file on a
fast connection, which is visible as a shudder while a folder fills in. */}
{state === 'loading' && <span className="absolute inset-0 flex items-center justify-center">{children}</span>}
<img
src={src}
alt=""
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setState('ready')}
onError={() => setState('failed')}
className={`size-full object-cover transition-opacity duration-150 ${
state === 'ready' ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Only once the frame is actually showing — a badge over a file icon would claim the icon is a
video still, which is the one thing it is not. */}
{state === 'ready' && isVideo && (
<span className="pointer-events-none absolute right-0.5 bottom-0.5 rounded-sm bg-black/60 p-0.5">
<Play className="size-2.5 fill-white text-white" />
</span>
)}
</span>
);
};
@@ -1003,6 +1003,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
user,
// Navigation
homeRoot,
/*
The root this browser is reading, when it is not the caller's home.
Already threaded into `useFilesAPI` and the viewer params above; exposed because anything building
a URL to one of these files — a thumbnail, a download — needs the same answer, and reconstructing
it from `homeRoot` gets the panel-with-an-override case wrong in exactly the way line 440 explains.
*/
rootOverride,
currentPath,
setCurrentPath,
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { UserCog, LogOut } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { isImpersonating, endImpersonation } from './impersonation';
/**
* A bar across the top, for as long as you are somebody else.
*
* Loud on purpose. The failure this prevents is not a security hole, it is a person forgetting — reading
* a mailbox, sending a message or deleting something believing it is theirs. Every action taken here is
* recorded against the account being worn, so the only defence is that you cannot miss it.
*
* It asks the server who it is rather than decoding the token: the token is the thing being tested, and a
* banner that reads its own claims would keep showing a name after the hour ran out.
*/
export const ImpersonationBanner = () => {
const client = useClient();
const active = isImpersonating();
const { data } = useQuery({
queryKey: ['auth', 'me', 'impersonation'],
queryFn: () => client.get<{ email?: string; name?: string }>('/auth/me'),
enabled: active,
staleTime: 60_000,
});
if (!active) return null;
return (
<div className="flex shrink-0 items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-xs font-medium text-black">
<UserCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
You are signed in as <b>{data?.name ?? data?.email ?? 'another account'}</b>. Everything you do is
recorded as theirs.
</span>
<button
onClick={endImpersonation}
className="flex shrink-0 items-center gap-1 rounded bg-black/15 px-2 py-0.5 transition-colors hover:bg-black/25"
>
<LogOut className="h-3 w-3" />
Back to yourself
</button>
</div>
);
};
@@ -0,0 +1,55 @@
/**
* Being somebody else, and getting back.
*
* ── Why the way back is a stashed token and not a server route ──
*
* Returning to yourself is putting back the token you already had. There is no server state to expire,
* leak or get wrong, and no route that mints an owner token — which would be a far more dangerous thing
* to own than this whole feature. The cost is that clearing browser storage strands you as the other
* person until their hour runs out; signing in again fixes it, and that is the right trade against a
* route that hands out owner sessions.
*
* ── Why `localStorage` and not `sessionStorage` ──
*
* The same storage the client reads its token from, so the two cannot disagree, and so a second tab
* opened mid-support sees the same account rather than silently being the owner.
*/
const TOKEN_KEYS = ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN'] as const;
/** Where the owner's own token waits. Distinct from every key the client reads, so it is never picked up. */
const STASH_KEY = 'officer:impersonation:owner-token';
const writeToken = (token: string) => {
for (const key of TOKEN_KEYS) localStorage.setItem(key, token);
(window as { officerBearerToken?: string }).officerBearerToken = token;
};
/** True while this browser is wearing somebody else's session. */
export const isImpersonating = (): boolean => Boolean(localStorage.getItem(STASH_KEY));
/**
* Swap to the target's token, keeping your own.
*
* A full reload rather than a re-render: every query in flight, every socket and every cached page
* belongs to the previous account. Refetching them piecemeal is how a support view ends up showing two
* people's data at once.
*/
export const startImpersonation = (token: string) => {
const mine = localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ?? localStorage.getItem('BEARER_TOKEN');
// Refuse rather than stash nothing: without the owner's token there is no way back, and discovering
// that after the swap is the worst possible moment.
if (!mine) throw new Error('Could not find your own session to return to');
// Never overwrite an existing stash — that would be a second hop overwriting the way home.
if (!localStorage.getItem(STASH_KEY)) localStorage.setItem(STASH_KEY, mine);
writeToken(token);
window.location.assign('/');
};
/** Put your own token back and reload. Safe to call when not impersonating: it does nothing. */
export const endImpersonation = () => {
const mine = localStorage.getItem(STASH_KEY);
if (!mine) return;
localStorage.removeItem(STASH_KEY);
writeToken(mine);
window.location.assign('/');
};
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { GitBranch, X, Loader2 } from 'lucide-react';
/**
* "Install from Git repository" — for a plugin that is in no catalogue and should not be.
*
* The marketplace was the only way a remote plugin could arrive, so "build one and give it to a friend"
* had no affordance. It is also the wrong shape for a plugin built for one person: fofos-admin is one
* website's admin panel, and listing it publicly in order to install it would be a lie about who it is for.
*
* ── It FETCHES. It does not install ──
*
* The first version installed, and using it showed why that was wrong: this button is on the grid, the
* progress log is in the detail panel, so the install ran to completion with nothing on screen and the
* plugin simply appeared, already installed. No feedback, and no moment to change your mind.
*
* Cloning is enough to make a plugin real — the platform lists what is on disk, so it shows up as
* Available like any catalogue entry. You then click the card and install it there, with the progress
* and the result the existing flow already renders. One install path, and it is the one people know.
*
* ── No name field ──
*
* The name is derived from the repository server-side, because `appName` IS the directory a plugin sits
* in and decides its route, its table prefix and its process name. A box for it would invite a name that
* disagrees with the address. What it became is reported back, and named in the confirmation.
*/
export const InstallFromUrl = ({
onFetch,
}: {
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
}) => {
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
if (!open) {
return (
<div className="flex items-center gap-2">
{/* The outcome stays until the next attempt. A toast would be gone before somebody who just
pasted a URL has looked up, and "which card is mine" is the question this answers. */}
{note && <span className={note.ok ? 'text-duck-dark/50' : 'text-destructive'}>{note.text}</span>}
<button
onClick={() => {
setNote(null);
setOpen(true);
}}
className="flex shrink-0 items-center gap-1.5 rounded-md border border-duck-dark/10 px-2.5 py-1 text-xs text-duck-dark/60 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark"
>
<GitBranch className="h-3.5 w-3.5" />
Install from Git repository
</button>
</div>
);
}
const submit = async () => {
const trimmed = url.trim();
if (!trimmed || busy) return;
setBusy(true);
setNote(null);
const result = await onFetch(trimmed);
setBusy(false);
if (result.ok) {
/*
Named, because the name was derived rather than typed — this is where you find out what to look
for. And it says which of the two things happened: a plugin whose source was deleted but whose
install row survived comes back ALREADY installed, and telling somebody to "click it to install"
while it sits in a different tab is worse than saying nothing.
*/
setNote({
ok: true,
text: result.installed
? `${result.appName} added — already installed`
: `${result.appName} added — click it to install`,
});
setOpen(false);
setUrl('');
} else {
setNote({ ok: false, text: result.error ?? 'Could not fetch that repository' });
}
};
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
{busy ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-duck-dark/40" />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0 text-duck-dark/40" />
)}
<input
autoFocus
value={url}
disabled={busy}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
if (e.key === 'Escape' && !busy) setOpen(false);
}}
placeholder="https://gitea.officer.dev/owner/plugin.git"
className="min-w-0 flex-1 rounded-md border border-duck-dark/10 bg-transparent px-2 py-1 font-mono text-xs outline-none focus:border-duck-dark/30 disabled:opacity-50"
/>
{note && !note.ok && <span className="shrink-0 text-destructive">{note.text}</span>}
<button
onClick={() => void submit()}
disabled={!url.trim() || busy}
className="shrink-0 rounded-md bg-duck-dark/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-duck-dark/20 disabled:opacity-40"
>
{busy ? 'Fetching…' : 'Add'}
</button>
<button
onClick={() => setOpen(false)}
disabled={busy}
aria-label="Cancel"
className="shrink-0 rounded p-1 text-duck-dark/40 hover:text-duck-dark disabled:opacity-40"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
};
@@ -0,0 +1,119 @@
import { useState, type ReactNode } from 'react';
import { Check, Download, GitBranch, Store } from 'lucide-react';
import { InstallFromUrl } from './InstallFromUrl';
import type { AvailablePlugin, PluginItem } from './usePlugins';
/**
* The Marketplace section: what this machine has, and what it could have.
*
* ── Three tabs, because there are genuinely three states ──
*
* `Installed` is what is running. The other two are both "not installed" and they are NOT the same
* thing, which is the distinction the old single grid lost:
*
* `Official` the marketplace catalogue — somewhere else, one click and a download away.
* `Available` already ON THIS MACHINE and not installed. A plugin fetched from a git URL lands
* here, and so does one that was deleted without being purged.
*
* Collapsing those two hid the whole point of fetching from a repository: you add something, and it is
* indistinguishable from twenty things you have not downloaded yet.
*
* ── Why the git field lives in Available ──
*
* That is the tab its result appears in. A button that adds something to the list you are looking at is
* legible; the same button floating above a filtered grid was not, which is how the first version
* managed to install a plugin with no feedback at all.
*/
type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean };
type TabKey = 'installed' | 'official' | 'available';
export const MarketplaceOverview = ({
installed,
official,
available,
renderCard,
onFetch,
}: {
installed: Entry[];
official: Entry[];
available: Entry[];
renderCard: (entry: Entry) => ReactNode;
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
}) => {
// Opens on what you HAVE. A store that opens on the catalogue is selling; this is mostly a place
// people come to check on something they already installed.
const [tab, setTab] = useState<TabKey>('installed');
const tabs: { key: TabKey; label: string; icon: typeof Check; items: Entry[] }[] = [
{ key: 'installed', label: 'Installed', icon: Check, items: installed },
{ key: 'official', label: 'Official', icon: Store, items: official },
{ key: 'available', label: 'Available', icon: Download, items: available },
];
const current = tabs.find((t) => t.key === tab) ?? tabs[0]!;
return (
<div className="flex h-full flex-col">
{/* One ruled row, same border as the nav beside it, so the two panels read as one screen. */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3">
{tabs.map(({ key, label, icon: Icon, items }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm transition-colors ${
tab === key ? 'border-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="size-3.5 shrink-0" />
{label}
<span className="text-xs tabular-nums text-muted-foreground">{items.length}</span>
</button>
))}
{/* Only on the tab it affects. Elsewhere it would be an action whose result appears somewhere
you cannot see, which is the mistake this whole section is correcting. */}
{tab === 'available' && (
<div className="ml-auto flex min-w-0 flex-1 justify-end py-1.5 pl-3">
{/* The tab FOLLOWS the result. A plugin whose directory was removed but whose install row
survived comes back already installed, so "click it to install" would be pointing at a
tab it is not in — which is exactly what happened the first time this shipped. */}
<InstallFromUrl
onFetch={async (url) => {
const result = await onFetch(url);
if (result.ok && result.installed) setTab('installed');
return result;
}}
/>
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{current.items.length === 0 ? (
<Empty tab={current.key} />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{current.items.map((entry) => renderCard(entry))}
</div>
)}
</div>
</div>
);
};
/** Says what the tab is FOR, not just that it is empty — an empty tab is otherwise a dead end. */
const Empty = ({ tab }: { tab: TabKey }) => {
const text =
tab === 'installed'
? 'Nothing installed yet. Official has the catalogue.'
: tab === 'official'
? 'The marketplace catalogue is empty or unreachable.'
: 'Nothing waiting to be installed. Add one from a Git repository above.';
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<GitBranch className="size-5 opacity-30" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
);
};
@@ -177,7 +177,7 @@ const ActionLog = ({
/** Artwork if the catalogue has a URL, else the plugin's own lucide glyph. */
const AvailableGlyph = ({ plugin }: { plugin: AvailablePlugin }) => {
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" />;
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />;
const Glyph = resolveIcon(plugin.icon ?? 'Box');
return <Glyph className="h-9 w-9 shrink-0" style={{ color: plugin.color }} />;
};
@@ -431,7 +431,9 @@ export const PluginDetail = () => {
return (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
{plugin.image ? <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" /> : null}
{plugin.image ? (
<img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />
) : null}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
@@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Check, Download } from 'lucide-react';
import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
import { MarketplaceOverview } from './MarketplaceOverview';
/**
* The store: cards in a grid, filtered by the tag on the left.
@@ -80,11 +81,32 @@ const Card = ({ entry, search }: { entry: Entry; search: string }) => {
};
export const PluginGrid = () => {
const { plugins, available, isLoading } = usePlugins();
const { plugins, available, isLoading, fetchFromUrl } = usePlugins();
const [params] = useSearchParams();
const tag = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
/*
No tag means the Marketplace section — the state of this machine, in three tabs.
The tag grid below is unchanged and still what a filter shows. Two views rather than one because
they answer different questions: "what have I got" wants installed-versus-not, and "what is there
for my calendar" wants everything of that kind together regardless of state.
*/
if (!tag) {
const installed = plugins.filter((p) => p.installed);
const onDisk = plugins.filter((p) => !p.installed);
return (
<MarketplaceOverview
installed={installed}
official={available}
available={onDisk}
onFetch={fetchFromUrl}
renderCard={(entry) => <Card key={entry.appName} entry={entry} search={params.toString()} />}
/>
);
}
// Installed first, then the rest. Both are the same thing to a person browsing — a plugin — and the
// ones they already have are the ones they are most likely looking for.
@@ -106,7 +128,9 @@ export const PluginGrid = () => {
return (
<div className="h-full overflow-auto p-4">
<div className="mb-3 text-xs text-duck-dark/40">
{/* No git field here any more: it lives in the Marketplace section's Available tab, which is where
what it adds actually shows up. */}
<div className="mb-3 text-xs text-muted-foreground">
{shown.length} plugin{shown.length === 1 ? '' : 's'}
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
</div>
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react';
import { Puzzle, AlertTriangle, Database, Package, Store } from 'lucide-react';
import { usePlugins, type OrphanTable } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
@@ -32,14 +32,31 @@ const TAG_ICONS: Record<string, string> = {
Fun: 'Gamepad2',
};
const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left';
/*
The house section-nav row, taken from `fofos-admin/web/NavPanel.tsx`.
Three things carry the look, and each is doing a job:
`border-b border-border` — the SEPARATION. Gapped pills read as a floating list where the boundary
between one item and the next is guesswork; a ruled edge says where a row ends.
The label is NOT muted. It was `text-duck-dark/70`, so every row whispered and the eye had nothing to
land on. The muting moves to the line BELOW it, where a count is genuinely secondary.
Active is `bg-muted` — a filled row rather than a tinted label. Colour on the text was carrying both
"this is a link" and "this one is selected", which is why nothing looked selected.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
export const TagNav = () => {
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins();
const [params] = useSearchParams();
const active = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
// Counted over everything the grid can show — installed and available together — so a number here
// always matches what clicking it produces.
@@ -55,6 +72,25 @@ export const TagNav = () => {
);
const untagged = all.filter((p) => !p.tags?.length).length;
// On disk and not installed — a plugin fetched from a git URL lands here, which is why it is counted
// beside the catalogue rather than folded into it.
const notInstalled = plugins.filter((p) => !p.installed).length;
/**
* The Marketplace section — a view, not a filter.
*
* `?tag=` narrows the same grid; this replaces it, so it clears the tag rather than setting one. It is
* also the DEFAULT: arriving with neither parameter lands here, because "what do I have, and what can
* I get" is the question somebody opening a store is asking, and the tag list is how you narrow it
* afterwards.
*/
const overviewLink = () => {
const next = new URLSearchParams(params);
next.delete('tag');
next.delete('selected');
return { search: next.toString() ? `?${next}` : '' };
};
const onOverview = !active;
const link = (tag: string | null) => {
const next = new URLSearchParams(params);
@@ -65,61 +101,70 @@ export const TagNav = () => {
return { search: `?${next}` };
};
/** One row: icon, a label that is actually legible, and what it holds underneath. */
const row = (
key: string,
to: ReturnType<typeof link>,
Icon: typeof Package,
label: string,
n: number,
on: boolean,
) => (
<Link key={key} to={to} className={rowClass(on)}>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{label}</span>
{/* The count moves here from the right-hand column. It was a number floating at the edge at 60%
opacity, which is where the eye goes last; as the row's second line it is read WITH the label
rather than hunted for. */}
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{n} plugin{n === 1 ? '' : 's'}
</span>
</span>
</Link>
);
return (
<nav className="flex h-full flex-col overflow-auto">
<div className="flex flex-col gap-0.5 p-2">
<Link
to={link(null)}
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Package className="size-4 shrink-0" />
<span className="flex-1">All</span>
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
<div className="flex flex-col">
{/* Above the tags and separated from them: it answers a different question. The tags say what a
plugin is FOR; this says what state this machine is in. */}
<Link to={overviewLink()} className={`${rowClass(onOverview)} border-b-2`}>
<Store className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">Marketplace</span>
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{plugins.filter((p) => p.installed).length} installed · {notInstalled + available.length} available
</span>
</span>
</Link>
{ordered.map(([tag, n]) => {
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
return (
<Link
key={tag}
to={link(tag)}
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1 truncate">{tag}</span>
<span className="text-xs tabular-nums opacity-60">{n}</span>
</Link>
);
})}
{row('__all', link(null), Package, 'All', all.length, false)}
{ordered.map(([tag, n]) =>
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
)}
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
and that is worth being able to SEE rather than discovering by its absence. */}
{untagged > 0 && (
<Link
to={link('__untagged')}
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<Puzzle className="size-4 shrink-0" />
<span className="flex-1 truncate">Untagged</span>
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
</Link>
)}
{untagged > 0 && row('__untagged', link('__untagged'), Puzzle, 'Untagged', untagged, active === '__untagged')}
</div>
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
{/* Below the rows, and separated by the same border they use — these are facts about the store
rather than filters you can click, so they must not look like another section. */}
<div className="mt-auto flex flex-col gap-1 border-t border-border p-3 text-xs">
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
{plugins.length > 0 && (
<button
onClick={() => updateAll()}
disabled={updatingAll}
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
className="rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 disabled:opacity-50"
>
{updatingAll ? 'Updating…' : 'Update all'}
</button>
)}
{broken.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
<div className="flex items-start gap-2 px-2 py-1.5 text-busy-ink">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
@@ -127,7 +172,7 @@ export const TagNav = () => {
</div>
)}
{orphans.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
<div className="flex items-start gap-2 px-2 py-1.5 text-muted-foreground">
<Database className="mt-0.5 size-3.5 shrink-0" />
<span>
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins
@@ -204,23 +204,31 @@ export function usePlugins() {
* The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has
* to understand what our own endpoint emits.
*/
const run = useCallback(
async (verb: PluginVerb, appName: string) => {
/**
* The frame reader, which does not care where the stream came from.
*
* Split out when "Install from Git repository" arrived: it posts to a different path with a body, and
* everything after the first byte is identical — same step and log events, same `done`. Two copies of
* this parser would be two places to get an incomplete frame wrong.
*/
const stream = useCallback(
async (path: string, label: PluginVerb, appName: string, body?: unknown) => {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
setSteps([]);
setResult(null);
setRunning(verb);
setRunning(label);
try {
const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, {
const res = await fetch(path, {
method: 'POST',
headers: getHeaders(),
headers: body ? { ...getHeaders(), 'content-type': 'application/json' } : getHeaders(),
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.body) throw new Error(`${verb} failed: no response body`);
if (!res.body) throw new Error(`${label} failed: no response body`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -265,6 +273,47 @@ export function usePlugins() {
[invalidate],
);
const run = useCallback(
(verb: PluginVerb, appName: string) => stream(`/api/plugins/${appName}/${verb}/stream`, verb, appName),
[stream],
);
/**
* Clone a plugin from its repository so it appears as Available. It does NOT install it.
*
* That distinction is the whole design. Installing from here ran to completion with nothing on screen,
* because the progress log lives in the detail panel and this button is on the grid. Fetching puts a
* card in the grid instead, and the card already has an Install button with the streamed progress
* beside it — so there is one install flow rather than two, and the one that exists is the one people
* have already used.
*/
const fetchFromUrl = useCallback(
async (url: string): Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }> => {
const res = await fetch('/api/plugins/fetch-from-url', {
method: 'POST',
headers: { ...getHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ url }),
});
const body = (await res.json().catch(() => ({}))) as {
appName?: string;
installed?: boolean;
error?: string;
};
/*
AWAITED, so the caller resolves after the list has actually been refetched.
`invalidateQueries` is fire-and-forget: without awaiting it, the confirmation appeared while the
grid was still showing the previous answer, and the new plugin turned up a moment later — which
reads as "nothing happened" and sends somebody looking for a refresh button.
*/
await queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
// A clone that failed discovery still left something on disk worth showing, so this runs either way.
invalidate();
return { ok: res.ok, appName: body.appName, installed: body.installed, error: body.error };
},
[invalidate, queryClient],
);
// Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step
// streams is not a readable log, and the useful answer here is the summary: how many moved, and which
// refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine
@@ -301,6 +350,7 @@ export function usePlugins() {
/** Which verb is in flight, or null. */
running,
run,
fetchFromUrl,
updateAll,
updatingAll,
updateAllResult,
@@ -198,6 +198,13 @@ export type DirEntry = {
type: 'file' | 'directory';
size: number;
modifiedAt: number;
/**
* Set by `ls` when `/thumb` can draw this file, and which kind it is.
*
* The server decides, so there is one list of extensions instead of one per side. Absent on an older
* response, which simply means no thumbnail — the file icon, exactly as before.
*/
thumb?: 'image' | 'video' | null;
};
type ListDirResponse = {
+4
View File
@@ -139,3 +139,7 @@ export type { StoreItem as AppStoreItem } from './apps/AppStore';
// docs/localization.md's open question and is not answered by this.
export { panelName } from './locales/panelName';
export { useTranslation } from 'i18n';
// Impersonation: the banner the owner cannot miss, and the token swap behind it. See impersonation.ts.
export { ImpersonationBanner } from './apps/Impersonation/ImpersonationBanner';
export { startImpersonation, endImpersonation, isImpersonating } from './apps/Impersonation/impersonation';
@@ -2,7 +2,7 @@
id: plugin-ecosystem/no-install-from-git-url
title: "No install-from-git-URL"
theme: plugin-ecosystem
status: open
status: done
created: 2026-08-18
date_source: section
order: 3
@@ -11,3 +11,18 @@ order: 3
**No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
"build your own, give it to a friend" path has no affordance. Small, and needed before the
unofficial channel is real.
**Done 2026-08-28.** `POST /api/plugins/install-from-url`, owner-only, taking `{ url }` and optionally
`{ appName }`.
Smaller than this note assumed: `fetchPlugin` already cloned, validated the URL, refused to overwrite an
existing directory, checked for a `manifest.ts` and cleaned up a failed clone. One line tied it to the
catalogue — where the URL came from. So the change is a source parameter threaded through, plus a route.
The name comes from the repository, because `appName` IS the directory name and is never declared in a
manifest. That is checked against `^[a-z0-9]+(-[a-z0-9]+)*$` BEFORE cloning: it becomes the route
segment, the table prefix and the pm2 process name, and a half-clone under an unusable name reads as a
broken plugin rather than an absent one.
The first real user is `fofos/fofos-admin` — one website's admin panel, which should never be in a
registry. Verified end to end against it and then purged, since this is not that website's machine.