Commit Graph
60 Commits
Author SHA1 Message Date
saltbo b7f7fa7ecd feat(admin): implement operations dashboard stats 2026-07-09 23:34:03 -04:00
saltbo 554c231536 feat(admin): redesign dashboard with pro analytics 2026-07-09 23:34:03 -04:00
agent-kanban[bot]andNoah Reed 7ccaba2f8b feat: refine admin audit filtering (#495)
* feat: refine admin audit filtering

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* test: add audit filter spec scenarios

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* chore: refresh openapi client

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

---------

Co-authored-by: Noah Reed <noah-reed@mails.agent-kanban.dev>
2026-07-04 10:21:45 -04:00
saltbo 3695c80c2e feat(downloads): add task event timeline 2026-06-29 21:07:00 -04:00
saltbo 979afd20e7 style(downloader): format client tests 2026-06-29 17:45:34 -04:00
saltbo 3fdc7b4ae0 refactor(downloader): reorganize cmd downloader runtime 2026-06-29 17:42:24 -04:00
saltbo 26b7a1d8a4 fix(downloader): preserve failed upload results for retry 2026-06-29 12:08:25 -04:00
saltbo 2c0cdd8a63 fix(downloader): prevent aria2 session auto-restore 2026-06-27 01:42:32 -04:00
saltbo cf7b1de112 fix(downloader): consolidate polling into heartbeat 2026-06-27 01:16:12 -04:00
saltbo c55f6f460c chore(openapi): update generated storage client 2026-06-24 17:51:19 -04:00
saltbo 3f6751d60c chore(openapi): update generated downloader client 2026-06-24 14:59:54 -04:00
saltbo fb2281f1b3 fix(downloader): clean local data only on task delete 2026-06-24 11:12:31 -04:00
agent-kanban-local[bot]andJordan Park f41ed27bba [codex] separate billing configuration (#479)
* feat(admin): separate billing configuration

Add dedicated storage egress and downloader credit billing contracts, usecases, RPC wrappers, drawers, generated client updates, and coverage.

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

* fix(billing): preserve not found ordering

Check storage and downloader existence before quota_store gating in dedicated billing usecases, and cover enabled missing-resource requests at usecase and route levels.

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

---------

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
2026-06-24 05:57:10 -04:00
agent-kanban-local[bot]andJordan Park 82c5452782 feat(auth): move OAuth provider editor to drawer (#480)
Agent-Profile: https://agent-kanban.dev/agents/57ed5bcf43079e29

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
2026-06-24 05:55:26 -04:00
agent-kanban-local[bot]andJordan Park 7cfbbf77b7 fix: clean downloader terminal artifacts (#477)
* fix: clean downloader terminal artifacts

Agent-Profile: https://agent-kanban.dev/agents/57ed5bcf43079e29

* test: tolerate stale cloud license cleanup

Agent-Profile: https://agent-kanban.dev/agents/57ed5bcf43079e29

* test: retry transient pairing poll failures

Agent-Profile: https://agent-kanban.dev/agents/57ed5bcf43079e29

* fix: make suspended downloader cleanup idempotent

Agent-Profile: https://agent-kanban.dev/agents/57ed5bcf43079e29

---------

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
2026-06-24 03:14:38 -04:00
agent-kanban-local[bot]andJordan Park c7f3d11793 [codex] Add admin storage connection testing (#475)
* feat(storage): add admin connection testing

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

* fix: correct storage CORS guidance

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

---------

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
2026-06-24 00:37:59 -04:00
f4b65e4987 feat: make forcePathStyle configurable per storage (#474)
* feat: make forcePathStyle configurable per storage

Previously hardcoded to true, which breaks S3-compatible backends that require
virtual-hosted-style addressing (e.g. Alibaba Cloud OSS). Now configurable via
admin storage settings with a toggle switch, defaulting to true for backwards
compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: cover storage force path style

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: saltbo <saltbo@foxmail.com>
2026-06-23 20:46:33 -04:00
agent-kanban-local[bot]andAlex Chen 00f48cf355 feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode (#467)
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode

Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).

- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
  a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
  (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
  unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
  bearer header so both the image content-type and Authorization survive hono's
  per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
  takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
  the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy

The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

---------

Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
2026-06-20 00:16:07 -04:00
saltbo 29ec82651f fix(downloader): resume download path instead of panicking when runtime isn't complete
Final piece of the restart→download→upload→seed robustness pass. nextTaskWorkStage
can route a task to uploadExistingResult based on the server checkpoint (upload
progress / runtime phase / download totals), but the engine may not report the
download complete yet — aria2 re-checks on-disk files after a restart (showing
downloaded=0/total=nil transiently), or the download was lost. The old code
panic()'d on that mismatch, which recoverTaskPanic turned into a permanently
failed task (this is what failed Moonfall after it had fully downloaded 7.4GB).

Now uploadExistingResult falls back to downloadThenUpload when the runtime isn't
complete (or has no result). downloadThenUpload re-attaches to the session
download, waits for aria2 to finish re-checking, and uploads on completion — no
data loss, no spurious failure. Test updated to assert resume-not-fail.
2026-06-19 19:38:33 -04:00
saltbo 23ded65e6f fix(downloader): nil-safe isAria2RPCDisconnected to stop a startup panic
isAria2RPCDisconnected called err.Error() unconditionally, so a nil error
panicked (nil pointer deref). findSeed reaches it with a nil error whenever
tellStatus succeeds but the status isn't a seed — which crashed the downloader at
startup while restoring a retained seed from the ledger. Guard nil -> false.
2026-06-19 19:28:18 -04:00
saltbo a803b2d6f0 fix(downloader): don't adopt an assigned task's auto-seed before it's uploaded
Second restart bug behind the same stuck Moonfall: aria2 auto-seeds a torrent
the instant its download finishes (--seed-time), so a completed-but-not-yet-
uploaded download shows up in ListSeeds. reconcileEngineSeeds runs at startup
BEFORE the task loop marks tasks running, so 'running' was empty and it adopted
that seed as a done seed for managed expiry — skipping the upload entirely. The
task then sat at 'downloading' forever and the file would be deleted when the
seed expired.

Skip seeds whose task is still assigned/unfinished (AssignedTasks: assigned/
downloading/interrupted/uploading) — those belong to the task loop, which will
upload then seed them. Only genuinely orphaned seeds (completed tasks) are
adopted. Confirmed on prod: Moonfall finished (7.4GB on disk) with
result_object_id=null, stuck downloading.
2026-06-19 19:12:10 -04:00
saltbo 50514399d5 fix(downloader): re-attach to session download on restart so progress keeps reporting
After a downloader restart, the web UI showed a frozen download with 0 speed
even though aria2 was still downloading. Cause: on restart a task comes back as
'interrupted', but shouldAttachExistingAria2Task only attached for
downloading/uploading — so the worker RE-ADDED the magnet. aria2 had already
reloaded that download from its saved session, so the re-add hit error 12
(infohash already registered) and produced a dead duplicate gid. The worker then
polled the dead duplicate (bytes=0, speed=0) instead of the live download;
mergeTaskProgress's max() kept the stale byte count, so the UI froze. The real
download kept going, orphaned and unreported.

Fixes:
- shouldAttachExistingAria2Task also returns true for 'interrupted', so restart
  attaches to the session-restored download instead of re-adding.
- findTask skips error/removed entries so it never attaches to a dead duplicate.

Confirmed on prod: Moonfall had two aria2 gids — 29ded179 (live, 6.8GB) and
3203aca6 (error-12 duplicate) — and the server runtime was frozen at the
pre-restart snapshot.
2026-06-19 18:49:53 -04:00
saltbo a1ce668693 fix(downloader): broaden aria2 BT peer discovery so stale magnets resolve
Several magnet tasks sat at 0 bytes / 0 connections forever: their embedded
trackers were dead (coppersurfer.tk, leechers-paradise, etc.) and aria2 had no
other way to find peers, so metadata never downloaded — while still holding a
download slot and blocking the queue.

aria2 startArgs set no BT discovery config at all. Add:
- --bt-tracker fed from the maintained XIU2 TrackersListCollection 'best' list,
  fetched live at startup (FetchBtTrackers) with a bundled snapshot fallback, to
  supplement each magnet's own (often rotted) announce list,
- explicit --enable-dht / --enable-peer-exchange,
- --dht-file-path in the state dir so the DHT routing table is warm across
  restarts instead of bootstrapping cold,
- --bt-load-saved-metadata to reuse fetched metadata.

On restart aria2 reloads the saved session and re-announces these magnets with
the live trackers, so the stuck ones can finally resolve.
2026-06-19 18:14:33 -04:00
saltbo 5eac720dd4 fix(downloader): report aria2 per-peer progress; perf(ci): scope server COPY
Two follow-ups:

- aria2 peers now report progress (0..1) derived from the peer's piece bitfield
  (seeder => 1.0), matching qBittorrent. Previously the Peers list showed '—'
  for aria2 because the field was never populated.
- The server builder copied the whole repo via 'COPY . .', so a cmd/-only
  (downloader) change busted the layer and forced a full vite/tsup rebuild. Copy
  only the build inputs + final-stage sources (src, server, shared, public,
  index.html, vite.config.ts, tsconfig.json, migrations, scripts) so
  downloader-only pushes reuse the cached server image. Verified the builder
  stage still builds and produces dist/dist-server/migrations/entrypoint.
2026-06-19 16:19:50 -04:00
saltbo fc6ee38e51 fix(downloader): request uploadLength/uploadSpeed/connections/numSeeders from aria2 tellStatus
aria2's tellStatus only returns the keys it's asked for, but aria2StatusKeys
omitted uploadLength, uploadSpeed, connections and numSeeders — while
aria2Detail reads status.UploadLength/UploadSpeed/Connections/NumSeeders. So the
runtime reported them all as 0: during seeding the Overview showed 0 B/s upload
and 0 B uploaded, even though the Peers list had per-peer upload activity (that
comes from a separate getPeers call). Confirmed on live aria2: the seeding
torrent reported uploadSpeed ~3.26MB/s and uploadLength ~2.5GB when those keys
were requested. Adds the missing keys + a guard test.
2026-06-19 15:10:43 -04:00
saltboandClaude Opus 4.8 e6709f5728 fix(downloads): pre-authorize remote-download credits, gate before download, surface suspend reason
Billing was charge-in-arrears: a credit unit was only charged once the
downloader had already reported downloading into it, and the first unit only
after the first progress report — so a no-credit task still pulled bytes before
being blocked, then suspended with no explanation.

- Pre-authorize one unit ahead of the bytes pulled: targetUnits =
  min(ceil(downloaded/unit) + 1, ceil(total/unit)). The downloader never fetches
  bytes it hasn't paid for, and the cap keeps the lifetime charge at exactly
  ceil(total/unit) — same total as before, only billed earlier.
- Charge the first unit on the transition into 'downloading' (zero bytes), so a
  task that can't afford a unit is suspended at the gate and pulls nothing. The
  worker reads the authoritative status from that transition response and does
  not start downloading when it comes back suspended (progress reports stay pure
  telemetry; control still flows through the poll).
- On suspend, set runtime.message so the UI shows why (insufficient credits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:33:38 -04:00
Jasper VanandClaude Opus 4.8 a020cf74f0 refactor(branding): store logo + favicon as base64 data URIs (drop public bucket) (#466)
Branding's logo/favicon are now encoded as `data:${mime};base64,…` URIs and
stored directly in the `branding_logo_url` / `branding_favicon_url` system
options instead of being uploaded to a `mode='public'` S3 bucket. This removes
branding's dependency on public storage entirely (#456 Part 1).

- uploadBrandingImage encodes the raw file bytes and drops select('public') /
  s3.putObject / s3.getPublicUrl; s3 + storages removed from BrandingDeps.
- Per-field raw-byte caps replace the single 2 MiB limit: logo ≤ 256 KB,
  favicon ≤ 64 KB; over-cap returns 413 naming the field's limit.
- The 503 "no public storage" path is gone: uploads succeed with no public
  storage configured, and the updateBranding route no longer advertises 503
  (operationId unchanged; Go OpenAPI client regenerated).
- GET shape unchanged; legacy absolute-URL values keep rendering as-is (no
  migration, no backfill, old _system/branding objects untouched).

Out of scope (#456 Parts 2-3): avatar/team-logo upload, storages.mode,
StorageRepo.select.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:15:20 -04:00
Jasper VanandClaude Opus 4.8 399609b121 fix(downloader): drive stop-control from the task poll, make reporting pure (#464)
The progress-report path was making control decisions by string-matching error
bodies — and missed the suspended case entirely, so a billing-suspended task
kept downloading while the UI showed Suspended.

Reporting is now pure telemetry: updateTask just syncs progress/status and a
failed report is logged, never acted on. All stop-control (pause/cancel/suspend)
flows through the existing task poll:

- AssignedControlTasks now also queries 'suspended'; cancelRunning cancels the
  running download with errTaskSuspended and leaves the server-owned suspended
  status untouched.
- Removed errBillingPaused, the insufficient_credits string-match, and the
  isControlledTaskUpdateError/resolveControlledTaskUpdate error-string matching.
  pause/cancel already flowed through the poll; the report-path handling was a
  redundant, brittle second mechanism.

Worker-only; no server change (status=suspended is already pollable and keeps
assignedDownloaderId). Tests cover the suspended poll cause and the control
status query.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:01:16 -04:00
Jasper VanandClaude Opus 4.8 5b8495bc3d fix(downloader): report seeding stopped so the dashboard clears it (#463)
When a retained seed was cleaned up (expiry/ratio/cache/missing), the worker
removed it from aria2, the ledger, and local disk — but never told the server
seeding had ended. The task's last runtime report (phase=seeding,
Seeding.Active=true) stuck, so the dashboard showed completed tasks as
seeding forever. reportRetainedSeedsStopped only ran on worker shutdown, not
on per-seed expiry.

- cleanupRetainedSeed now reports the task runtime as completed/not-seeding
  after a seed is cleaned, so the UI clears it. Task status stays completed.
- clearStaleSeedingReports runs once at startup: lists this downloader's
  completed tasks the server still marks seeding (new client SeedingTasks),
  and reports stopped for any the worker is no longer actually seeding (after
  reconciliation), clearing entries left stale by older builds.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:47:17 -04:00
Jasper VanandClaude Opus 4.8 64b0ea3e81 fix(downloader): give seeding its own concurrency budget and recycle orphan seeds (#462)
Two coupled bugs starved aria2 downloads on long-running nodes.

Concurrency: max_concurrent_tasks is the download budget only, but aria2
counts seeding torrents as active downloads, so retained seeds were eating
the shared --max-concurrent-downloads (default 5) and new downloads queued
forever in 'waiting' with no error. Seeding now gets its own budget via a new
Downloader-local config downloader.seed.max_concurrent (default 10); aria2's
--max-concurrent-downloads is set to max_concurrent_tasks + that budget, so
seeds can never consume a download slot. The worker still caps real download
concurrency itself.

Orphan seeds: aria2 was told SeedTime=1000000 (~694 days), so it never
stopped seeding on its own; the worker was the sole authority, and any drift
between aria2's session and the worker's ledger (e.g. across restarts) left
torrents seeding forever, holding slots and disk, never expired. Two fixes:
- aria2 SeedTime is now the configured seed_duration (+ seed-ratio), so aria2
  stops seeding on its own even if the worker loses track.
- The worker reconciles on startup and periodically: any torrent the engine
  is still seeding but the worker no longer tracks is adopted into the ledger
  with an expiry (skipping in-flight/already-tracked tasks), so normal
  time/ratio/cache cleanup applies instead of leaking.

Adds SeedLister + aria2 ListSeeds. Tests cover the new config, aria2 args,
seed-time mapping, and orphan adoption (skipping running/tracked tasks).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 02:09:36 -04:00
Jasper VanandClaude Opus 4.8 b959e5e6bf fix(downloader): supervise managed engines and make the CLI go-installable (#461)
Three related changes hardening the remote downloader, bundled because the
module-path rename touches every file — splitting would leave a mid-history
commit that doesn't build.

Engine supervision: a managed engine subprocess (aria2c / qbittorrent-nox)
that exits unexpectedly now crashes the worker. watchEngineProcess waits on
the child, logs the exit at error level, and cancels the run context so
`downloader up` returns non-zero; the container (restart: unless-stopped)
then restarts the whole stack. Previously the exit error was discarded and
the worker kept heartbeating as healthy while every task failed against the
dead RPC. A deliberate shutdown kill is told apart from a crash via a
stopping flag.

aria2 GID recovery: waitAria2 re-discovers the live GID via findTask when
aria2 reports 'GID ... is not found' mid-download (e.g. after an aria2
restart) instead of failing the task. Adds isAria2GIDNotFound, which matches
tellStatus's message format that isAria2DownloadNotFound missed.

CLI install: move the entrypoint to the module root (cmd/main.go) and rename
the module github.com/saltbo/zpan/cmd -> github.com/saltbo/zpan so
`go install .` from cmd/ yields a zpan binary. Internal imports shorten to
github.com/saltbo/zpan/internal/...; the Dockerfile builds the module root.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:56:07 -04:00
Jasper VanandClaude Opus 4.8 63d5b45e0e refactor(api)!: collapse polymorphic GET responses to one monomorphic schema (#449) (#455)
Every endpoint now exposes one monomorphic schema: role/state changes field
values (mask / null / filter), never the shape.

image-hosting/config: drop the `full config | { enabled: false }` union. GET
always returns the full ImageHostingConfig shape carrying `enabled`; not-configured
→ `enabled: false` with every other field null (`createdAt` is now nullable).
`buildResponse` is made total over `row | null` so it is the single producer of
the shape, and `getIhostConfig` no longer returns `| null`.

auth-providers: collapse the admin-config vs public-display union into one
AuthProvider schema (providerId, type, enabled, name, icon, clientId, discoveryUrl,
scopes, clientSecret). Same endpoint, no path split — role changes one value:
admin gets a masked clientSecret, front-of-house gets `clientSecret: null` and the
enabled-only list. The two list usecases collapse into listAuthProviders(deps,
{ isAdmin }); the PUT response and the merged frontend wrapper adopt the same
schema, deleting AuthProviderConfig/PublicAuthProvider entirely.

Regenerated the Go client (union types removed) and updated frontend types/consumers.

Closes #449.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:14:23 -04:00
Jasper VanandClaude Opus 4.8 7b8c8c915e refactor(api)!: unify object upload + rework delete/trash lifecycle (#448) (#454)
Resolve #448 — one upload entry point and an AIP-164 soft delete.

Upload: POST /objects now returns size-decided upload instructions
{ sessionId, partSize, urls }; the server picks single PutObject (<=5 GiB)
vs 5 GiB-part multipart (>5 GiB) and rejects >5 TiB. The client PUTs each
slice, reads its ETag, then POSTs them to
POST /objects/{id}/uploads/{sid}/completions (returns the live object).
DELETE /objects/{id}/uploads/{sid} aborts and discards the draft.

Trash: matters.status drops 'trashed' (enum is {draft,active}); trash is
tracked by the existing trashedAt timestamp. DELETE /objects/{id} now
soft-deletes; the recycle bin lives under /trash/objects (list roots, get,
restorations, purge). Empty-trash is a frontend loop over roots.

BREAKING CHANGE:
- removes PUT /objects/{id}/status and POST /objects/{id}/uploads
- PUT .../uploads/{sid}/status -> POST .../uploads/{sid}/completions {parts}
- DELETE /objects/{id} flips hard-purge -> soft-delete; permanent purge
  moves to DELETE /trash/objects/{id}
- DELETE /trash removed; restore is POST /trash/objects/{id}/restorations
- matters.status enum loses 'trashed' (migration backfills to trashedAt)

The migration swaps the matters_active_name_uniq partial index to exclude
trashed rows (WHERE status='active' AND trashed_at IS NULL). The single-PUT
presign is header-free so the uniform slice uploader's raw PUT matches the
S3 signature. Go downloader client + agent reworked to the unified flow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:21:43 -04:00
Jasper VanandClaude Opus 4.8 78e2550e67 refactor(api)!: unify revoke/cancel on PUT /{resource}/{id}/status (#452) (#453)
* refactor(api)!: unify revoke/cancel on PUT /{resource}/{id}/status (#452)

Retire the misleading DELETE /shares/{token} and PATCH /store/orders/{orderId}
shapes in favor of the existing status-subresource convention already used by
background-jobs and download-tasks.

- Shares: PUT /api/shares/{token}/status {status:'revoked'} -> 200 + the updated
  creator ShareView. revokeShare now resolves the share before the UPDATE (the
  record is unresolvable once revoked) and builds the view via a composeShareView
  helper shared with viewShare; concurrently-revoked tokens now return 404.
  Removed the now-dead getCreatorByToken repo port/adapter method.
- Store: PUT /api/store/orders/{orderId}/status {status:'canceled'} -> 200. Only
  the local route shape changed; the upstream cloud SDK $patch call is untouched.
- Frontend: deleteShare -> revokeShare and cancelCloudOrder now use .status.$put
  via the Hono RPC client; updated the shares route component.
- Regenerated the Go OpenAPI client.

Note: revoking a share whose matter is trashed-but-not-purged now returns 404
(was 204), a consequence of reusing viewShare's resolution path.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(spec): rename share delete scenarios to revoke status-subresource

Align spec/shares.feature scenario tags (@shares/revoke,
@shares/revoke-non-creator) with the renamed [spec:] breadcrumbs so
lint:spec traceability passes.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(shares): keep revoke working for a trashed-but-not-purged matter

revokeShare switched to resolveByToken, which returned matter_trashed for a
soft-deleted (not purged) matter and was short-circuited to 404. Because
trashing a matter does not cascade to its shares, the share stayed active and
still appeared in the owner's list — so the owner could no longer revoke it
(privacy footgun: restoring the file re-exposed a share they believed revoked).

ShareResolution now carries the share/matter/recipient records on the
matter_trashed variant (and splits not_found/revoked into single-literal members
so control-flow narrowing works). Viewer-facing callers still branch on status,
so trashed -> 410 for viewers is unchanged. revokeShare treats matter_trashed as
revocable (ownership check, revokeByToken, revoked creator view), while not_found
and already-revoked still map to 404.

Adds unit coverage (trashed-matter revoke succeeds; non-creator still 403) and a
backend integration test (share a landing matter, trash it, PUT status revoked ->
200 + status:'revoked', DB flips).

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 02:11:08 -04:00
Jasper VanandClaude Opus 4.8 2ae603bbab refactor(api)!: DELETE endpoints return 204 No Content (#443) (#447)
* refactor(api)!: DELETE endpoints return 204 No Content (#443)

Resolves item #4 of #443 — DELETE return-shape inconsistency (8 different
conventions). Standardize every DELETE on 204 No Content with an empty body,
dropping the ack/result bodies: `{id,deleted}`, `{providerId,deleted}`,
`{key,deleted}`, `{id,revoked}`, `{ok:true}`, the download-task tombstone,
license `{deleted,cloud_unbind_error}`, and the entitlement-revoke /
abort-upload-session objects.

Kept (the issue's flagged special case): object-delete and empty-trash still
carry a purge count — the only delete responses with information a caller can't
otherwise derive. Object delete is trimmed from `{id,deleted,purged}` to just
`{ purged: number | false }`; empty-trash keeps `{ purged: number }`.

Backend: 15 DELETE routes → `204: { description }` + `c.body(null, 204)`;
removed the now-dead `deleteDownloaderResponseSchema`.

Frontend: added a `discard()` helper (the 204 counterpart to `unwrap()`); the
unwrap-based delete wrappers now resolve `void`. cancelUpload/deleteObject now
return `{ purged }`. The already-void wrappers (deleteShare, deleteAvatar, …)
were untouched — they never read the body.

OpenAPI document + Go client regenerated (go build clean).

BREAKING CHANGE: all DELETE endpoints now respond 204 with no body. License
unbind no longer returns `cloud_unbind_error`, so a partial cloud-unbind failure
is no longer surfaced in the response body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(licensing): surface cloud-unbind failure as 502, don't swallow it as 204

The DELETE→204 sweep turned license unbind into an unconditional 204, which
hid a real partial failure: when the best-effort cloud unbind throws, the local
binding is cleared but the cloud side is left dangling. Reporting 204 (success)
in that case swallows the error.

`unbindLicense` now returns a Result — `{ ok: true }` only when the cloud unbind
also succeeds, and a 502 AppError (reason `CLOUD_UNBIND_FAILED`, the cloud error
in `details.metadata`) when it fails. The local binding is still cleared either
way; the handler returns 204 on ok and throws the error otherwise.

DELETE success is still an empty 204 — this only restores fail-fast on the one
endpoint whose failure was a soft body field, never a thrown error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): reconcile users.feature with #446 better-auth migration

`pnpm lint:spec` (a CI gate) was red on 11 orphaned `spec/users.feature`
scenarios — leftover from #446, which moved admin user management off our
`/api/users/*` routes onto better-auth's admin plugin and deleted the old
endpoints/tests but not their spec scenarios. Pre-existing on main; surfaced
here as the only failing CI check.

Reconcile the spec with reality:
- Re-link the behaviors that survived (now via better-auth) to the tests that
  already cover them: list / admin-only(403) / disable(ban) / delete(remove) /
  patch-missing(act-on-missing→404) → admin-users-ba.integration.test.ts;
  quota-personal-org → the per-user quota test in users.integration.test.ts.
- Drop scenarios for behavior that no longer exists: batch-toggle (now a
  client-side fan-out, no endpoint), invalid-status (ban/unban are explicit),
  multi-field filter (better-auth search is single-field, untested), the
  unauthenticated 401 guard (better-auth owns it), and the inline
  quota-entitlements-in-list (quota is now a per-user sub-resource).

lint:spec: 413 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:49:45 -04:00
Jasper Van 7d819a5b9f refactor(users)!: move admin user management to better-auth admin plugin (#446) 2026-06-17 11:03:17 -04:00
Jasper VanandClaude Opus 4.8 8abca2f88c refactor(errors)!: unify error handling on typed AppError + single jsonError renderer (#445)
Collapse the two error conventions (string-reason `{ok:false,reason}` outcomes
and thrown domain-error classes) onto one. Usecases now produce typed `AppError`
values via factories (`notFound()`/`quotaExceeded()`/`featureBlocked()`/…);
handlers `throw result.error`; and `jsonError` (renamed from `renderError`) is the
single place that renders any error to an AIP-193 body + access-log line, in
`app.onError`/accessLog.

Why: the previous setup had a string→code mapping (`outcomeError` + the `OUTCOME`
table) living in parallel with a type→code mapping (`mapDomainError`), plus inline
`apiError(c, <status>, …)` calls that hand-wrote the status at every site — exactly
the drift that left the same `quota_exceeded` at 400 in one handler and 422 in the
rest. Now the status/reason live once, in the factory.

- Add `server/usecases/ports/app-error.ts`: `AppError` + factories. Status/reason
  are baked in per factory, so no usecase or handler writes an HTTP code or a
  magic-string reason. `AppError` also carries optional response headers
  (`Retry-After`) via a `rateLimited()` factory.
- Delete `apiError`, `outcomeError`, the `OUTCOME` table, and the dead `ApiError`
  class. The 67 inline guard/middleware `apiError` sites became `throw <factory>()`.
- Control-flow outcomes a handler branches on (not just renders) stay discriminated
  reasons (e.g. `deleteObject` `not_trashed`); internal shared sub-usecases
  (traffic-metering, licensing internals) keep string reasons, mapped at the boundary.
- Regenerate the Go OpenAPI client (saveShare gained a 422 response).

BREAKING CHANGE: POST /shares/{token}/objects quota rejection now returns 422
(was an inconsistent 400); every other quota path already returned 422.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:47:59 -04:00
Jasper VanandClaude Opus 4.8 b3ba6c00ff refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443) (#444)
* refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443)

Settle the API consistency issues from #443 before SDKs ship. Breaking changes
across the error envelope, list envelopes, and the generated Go client.

Errors → AIP-193 google.rpc.Status (https://google.aip.dev/193):
- every error body is now { error: { code, message, status, details:[ErrorInfo] } }
- machine-readable, switchable key is details[0].reason (UPPER_SNAKE); status is the
  canonical google.rpc.Code; dynamic context lives in metadata (string→string)
- built once in server/lib/http-errors.ts (buildErrorBody/ApiError/mapDomainError);
  inline handlers use apiError(c,status,msg,opts?); thrown errors flow through
  app.onError → renderError. Resolves #8 (one casing; no-storage 503 everywhere) and
  #9 (resource/maxBytes/conflictingName/licensing fields folded into metadata;
  featureGateErrorSchema removed)

Pagination → Page<T> = { items, total, page, pageSize } via pageSchema + integer
pageQuerySchema, applied to every list endpoint. image-hosting/images stays cursor
(the one intentional exception). unreadCount moved out of the notifications list into
/notifications/stats; entitlements drop the redundant orgId; team invitations use items.

Access log: every 4xx/5xx carries reason + full message (set by apiError and
renderError); a thrown domain error logs its mapped status (409, not 500); unhandled
500s log the full cause chain while the client gets a generic message.

Frontend ApiError exposes reason/metadata/canonicalStatus; consumers updated. Go
client regenerated from the new OpenAPI document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): fix e2e name-conflict assertion + cover AIP-193 error branches

- e2e/name-conflict.spec.ts: assert body.error.details[0].reason (AIP-193) instead
  of the removed top-level body.code
- unit-test buildErrorBody, ApiError, and every mapDomainError branch
  (server/lib/http-errors.test.ts) and renderError + isHandledError
  (server/middleware/error-handler.test.ts)
- integration-test the apiError error-branch guards the refactor touched:
  shares, redirect, site/invitations, objects, store/storefront, and the
  requirePermission middleware (authz) — restoring patch coverage above target

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): drop ad-hoc [spec:] breadcrumbs from new coverage tests

lint:spec governs spec↔test traceability: a [spec: id] breadcrumb must map to a
documented @id scenario in spec/**/*.feature. The added error-branch coverage
tests are not Gherkin scenarios, so reference no spec id — use plain titles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(objects): allow the file-manager pageSize (500) on the objects list

The shared pageQuerySchema caps pageSize at 100, but the file manager loads a
whole folder client-side (FILES_PAGE_SIZE=500, transfer dialog 200) — the old
z.string() query param was unbounded. With the cap, GET /api/objects?pageSize=500
returned 400, the file-manager list query errored and retried, and the toolbar /
table never rendered (e2e: responsive @desktop + name-conflict table state). Raise
just this list's ceiling to 1000 (default stays 20); other lists keep the 100 cap.

Regression-tested: GET /api/objects?pageSize=500 → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:58:35 -04:00
Jasper VanandClaude Opus 4.8 e132cb9e41 feat(openapi): complete API coverage with truthful schemas + unified error handling (#442)
* feat(openapi): complete API coverage with truthful schemas + unified error handling

Migrate every resource router to `@hono/zod-openapi` so the global OpenAPI
document (and the SDKs generated from it) covers the whole product API, not just
~15% of it. The document now describes 25 resources with named component schemas,
operationIds, and accurate response shapes.

What changed:
- Unified error handling: a single `mapDomainError` (DownloadError, ObjectUpload-
  SessionError, NameConflictError, StorageQuotaExceededError, BackgroundJobError,
  WebDavPathError) wired into a global `app.onError`; handlers throw domain errors
  instead of hand-rolling per-route try/catch. One shared `ErrorResponse` envelope.
- Shared http helpers (`server/http/openapi.ts`): generic `jsonContent`/`jsonBody`/
  `errorResponse` so the precise schema type reaches `createRoute` — typing
  `c.req.valid()` and strictly checking `c.json()` returns (no widened `z.ZodType`).
- Schemas are the truth: response schemas are named (`.openapi('X')`), wire-shaped
  (ISO-string timestamps via per-resource `toXDTO` mappers where the domain type
  uses `Date`), and strictly enforced against handler returns. The strict pass
  surfaced and fixed several latent schema lies (e.g. transfer result shape,
  download-task delete tombstone, object `purged`).
- operationId + summary on every route → clean SDK method names.
- Curated out of the public SDK (kept as plain routes): the `/r` redirect resolver,
  store webhook receiver, internal telemetry endpoint, the PicGo/ShareX image
  upload tool endpoint, the share download redirect, and cron-secret licensing
  sync endpoints.
- Disambiguated user operationIds that collided with better-auth's admin API;
  dropped `additionalProperties` schemas that oapi-codegen mis-generates.
- Regenerated the Go downloader client and realigned its hand-written wrapper to
  the operationId-derived names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(cmd): gofmt the realigned downloader client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:12:08 -04:00
Jasper VanandClaude Opus 4.8 c1542e6a9c refactor(openapi): drop the committed spec artifact; gen Go client from the full doc (#441)
No more docs/openapi/downloader.json and no curated subset. The Go client is
generated straight from the complete, live /api/openapi.json: a single
scripts/openapi-client.ts boots the in-memory app, reads the whole merged
document, and feeds it to oapi-codegen via a throwaway temp file — only the
generated client.gen.go is committed.

- delete docs/openapi/downloader.json, cmd/oapi-codegen.yaml, and the three
  build/generate/check scripts; replace with one scripts/openapi-client.ts
  (`pnpm openapi:client` / `--check`).
- generate from the full document — every endpoint, no allowlist/prune. The
  only transforms are whole-document mechanics for oapi-codegen (3.1→3.0
  nullable, strip security, declare better-auth's missing path params).
- rename the CI step + package scripts openapi:downloader:* → openapi:client*.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:17:33 -04:00
Jasper VanandClaude Opus 4.8 91d10f9b97 feat(openapi): global OpenAPI document + Scalar UI, drop hand-written stubs (#440)
* feat(openapi): global OpenAPI document + Scalar UI, drop hand-written stubs

Replace the curated, partly hand-written "downloader" OpenAPI doc with a
single global document generated from the real routes.

- main app → OpenAPIHono; serve the aggregated spec at /api/openapi.json and
  the Scalar reference UI at /api/docs. A resource appears in the doc as soon
  as it is converted to `.openapi()` — no curation, no drift.
- enable better-auth's openAPI plugin; the auth/device flow now documents
  itself at /api/auth/reference instead of hand-written route stubs.
- convert objects.ts and events.ts to self-documenting OpenAPIHono routes;
  RPC types preserved (responses go through unwrap<T>, {id}→:id accessors hold).
- tag operations (Objects/Events/Download Tasks/Downloaders) + top-level tags
  so Scalar groups them.
- delete server/openapi/downloader.ts and its device/object/events stubs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(openapi): merge better-auth schema into one doc; regen Go client from it

Make /api/openapi.json a single fully-generated document and drive the Go
downloader client from it — no hand-written/maintained spec.

- merge better-auth's auto-generated schema (auth.api.generateOpenAPISchema)
  into /api/openapi.json, prefixed under /api/auth. The device-authorization
  flow and the rest of the auth API now appear in one doc + Scalar.
- correct one upstream bug in the merge: better-auth advertises
  POST /device/token as { session, user } but its handler returns the OAuth
  token { access_token, token_type, expires_in } — override that one response
  so the doc and the generated client match reality.
- rewire the Go-client codegen to generate from the merged document: a new
  build-client-spec.ts boots the in-memory app, reads the real merged
  /api/openapi.json, scopes it to the downloader's paths (device + downloads +
  objects), prunes unreferenced components, strips security metadata, and
  downconverts 3.1 nullable unions to 3.0 for oapi-codegen.
- regenerate docs/openapi/downloader.json + cmd/internal/openapi/client.gen.go
  and adapt cmd/internal/client to the regenerated device types (inline request
  bodies, optional pointer/number fields) and the 201-only object create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:29:16 -04:00
Jasper VanandClaude Opus 4.8 3402a1e099 refactor(api): RESTful resource-oriented API — drop /admin, status sub-resources, merge audience-split routers (#437)
* refactor(api): RESTful resource-oriented API — drop /admin, status sub-resources, merge audience-split routers

Reorganize the entire HTTP surface around resource abstraction instead of
business/audience abstraction.

- Auth: authMiddleware is now soft + global for /api/*; gating is per-route
  (requireAuth/requireAdmin/requireTeamRole), so one resource path serves
  public, user, and admin callers (no security change — guards moved, not dropped).
- Drop /admin from URLs; merge audience-split routers into one resource each
  (announcements, auth-providers, users, teams, quotas, invite-codes,
  site-invitations, downloaders, branding, audit).
- State transitions -> PUT /:id/status: objects (confirm/trash/restore),
  download-tasks (pause/resume/cancel), background-jobs, image-hosting confirm.
- Verbs -> noun sub-resources: objects/:id/copies, download-tasks/:id/attempts,
  background-jobs/:id/retries, site-invitations/:id/deliveries,
  licensing/pairings + /pairings/:code + refresh-runs, teams/:id/invite-links.
- Config -> /api/site/* (branding, email, options, instance, changelog);
  ihost -> image-hosting; me + profiles + admin/users -> one /api/users
  (the :username slot also resolves the internal id, so the admin UI is unchanged).
- External downloader OpenAPI contract cut over in lockstep.

Frontend (rpc.ts + api.ts) and all integration/CF/unit tests updated to match.
Typecheck (server + src), lint:http, biome, and all 4394 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloader): regenerate Go client + sync spec for the new RESTful contract

The Go downloader agent (cmd/) and the BDD spec live in this repo, so they must
move with the API:

- Regenerate docs/openapi/downloader.json and cmd/internal/openapi/client.gen.go
  from the updated server OpenAPI.
- Update the hand-written Go client: heartbeat -> /downloaders/me/heartbeats,
  register -> /downloaders, object confirm -> PUT /objects/:id/status, upload
  complete -> PUT .../status, abort -> DELETE .../uploads/:sid. Drop the now-dead
  union helpers (jsonBody/decodeJSON) and the bytes import.
- spec: drop the obsolete teams invite-token-missing scenario (the route is now
  a path param) and add the auth-providers anon-public-list scenario (the merged
  GET serves the public list to anonymous callers).

gofmt clean, go test (121) pass, lint:spec passes (418 scenarios covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): cover users admin detail/entitlements + getUser wrapper

Close the patch-coverage gaps from the users-resource merge: add integration
tests for GET /api/users/:id (admin detail, success + 404) and
GET /api/users/:id/entitlements (success + 404), and a unit test for the
getUser() api.ts wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): update Playwright specs + global setup to the new RESTful paths

The e2e specs make direct API calls / response matchers that bypass the SPA, so
they need the new paths too: global-setup storage+options seeding
(/api/storages, /api/site/options), image-host (/api/image-hosting, confirm via
PUT /images/:id/status), object confirm in archive (PUT /objects/:id/status),
announcements and site-invitations (/api/announcements, /api/site-invitations,
/api/site/email). The cloud pairing action:'approve' is the external cloud API,
left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): fix cloud-store instance pairing path to /api/licensing/pairings

The cloud-store spec calls the INSTANCE pairing endpoint directly:
POST /api/licensing/pair -> /api/licensing/pairings and the poll
GET /api/licensing/pair/:code/poll -> GET /api/licensing/pairings/:code.
/api/licensing/status and /binding are unchanged; /api/pairings is the
external cloud API, left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): rename /api/site-invitations to /api/invitations

Avoids visual proximity with the /api/site/* config namespace. Top-level
/api/invitations is unambiguous — team invitations are nested under
/api/teams/:id/invitations and invite codes under /api/invite-codes. URL-only
change; the internal site-invitations naming stays (still the accurate concept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): group resources by functional domain (URLs)

Move non-core resources under functional-domain prefixes (not permission):
- /api/site/* absorbs storages, auth-providers, audit-events, licensing,
  invitations, invite-codes (joining branding, email, options, instance, changelog)
- /api/downloads/* = tasks + downloaders (regenerated OpenAPI + Go client)
Core resources stay top-level. Updates app.ts, rpc.ts, OpenAPI doc + Go agent
client, and all integration/CF/e2e tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): mirror functional-domain grouping in http/ and usecases/ dirs

Reorganize source files to match the functional URL domains established for
the routes, so the directory tree reflects the same grouping as the API:

- http/{site,downloads,image-hosting}/ and usecases/{site,downloads,image-hosting}/
- dissolve the permission-based console/ dir — admin resources are grouped by
  domain (site), not by audience
- console/user -> top-level (users is a core resource, not an admin-only one)

Co-located tests move with their sources; relative imports and vi.mock paths
updated for the new depths. Pure file/directory restructure, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): finish structural cleanup — merge split admin routers, drop rename leftovers

Three follow-ups from the directory-structure review, completing the
one-file-per-resource and domain-named-file conventions:

- Merge the last two audience-split router files into their resource file as a
  second export (matching branding/quotas/invite-codes/site-invitations):
  teams-admin.ts -> teams.ts (adminTeams), licensing-admin.ts -> licensing.ts
  (licensing + licensingAdmin).
- Drop pre-rename filename leftovers now that the dirs carry the domain:
  http/image-hosting/{ihost,ihost-config} -> {images,config};
  http/site/site-invitations -> invitations;
  usecases/site/{site-invitation,site-public-origin} -> {invitation,public-origin};
  usecases/image-hosting/{image-hosting,image-hosting-config} -> {images,config}.
- Group the loose store helpers under the store domain:
  http/{cloud-store-helpers,traffic-metering-utils} -> http/cloud-store/{helpers,traffic-metering}.

Routes and exports unchanged; pure file/structure move. tests + co-located
specs move with their sources. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): move announcements under /api/site, co-locate stray tests

Announcements is instance-level, admin-authored content (like branding) — a
site resource, not a top-level one. Move it under the site domain:
- /api/announcements -> /api/site/announcements (mount, RPC base path, api.test, e2e spec)
- http/announcements -> http/site/announcements; usecases/announcement -> usecases/site/announcement

Co-locate the tests that drifted from their sources during the dir reorg
(the 1:1-paired cf-test/unit tests belong next to what they exercise):
- http/storages.cf-test.ts -> http/site/ (next to storages.ts)
- usecases/{license-certificate,license-policy,license-refresh,licensing-admin}.test
  -> usecases/site/ (next to the licensing usecase; imports simplified to ./licensing)

No behavior change beyond the announcements path. Routes/exports otherwise stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(usecases): de-fragment the users and objects domains at the usecase layer

The HTTP layer already serves these as single resources; consolidate their
usecases to match, removing leftover files that mirrored the old split:

- Fold me.ts (avatar) + profile.ts (public lookup) into user.ts — one user
  usecase with self/public/admin sections; drop the stale /api/me/avatar and
  /api/profiles/:username doc comments. Their unit tests move into user.test.ts.
- Fold matter.ts (confirmUpload, draft→active) into object.ts — the objects
  domain is now under one "object" name (the Matter *type* stays in ports/).

Importers updated; no behavior change. server tsc + lint:http + lint:spec clean;
Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(usecases): fold sub-concern usecases into their resource (one file per resource)

Consolidate the usecase layer so each resource is a single source file:

- object.ts absorbs object-upload-session, purge, and save-to-drive (its
  upload-session / recursive-purge / save-to-drive sub-concerns)
- share.ts absorbs share-notification and share-ref

External importers re-pointed (trash, redirect, entry-node, workers/scheduled,
http/share-utils, and the surviving integration/cf tests). share.ts now pulls
copyMatterToOrg/saveShareToDrive from object. share.test.ts asserts the real
notification+email fan-out now that dispatchShareCreated is intra-module.

Shared domain services (storage-usage, cloud-traffic-metering, captcha) stay
separate — they're used by many resources. 5 files removed; no behavior change.
Node 4337 / CF 57 / libsql 6 green; tsc + lint:http + lint:spec clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(http): collapse concern-split integration tests into one per resource

Each resource now has a single Node integration test file; the scenario-split
files fold into their resource's main:

- objects-quota + object-multipart-live -> objects.integration.test.ts
- me + profile -> users.integration.test.ts
- quotas-listing -> quotas.integration.test.ts
- teams-admin -> teams.integration.test.ts
- share-public -> shares.integration.test.ts (share-public.cf-test stays — CF runtime)

Helpers de-duplicated or scoped per describe; all [spec:] breadcrumbs preserved
(lint:spec still 418). 7 files removed, all 4337 tests retained. The multipart-live
block now restoreAllMocks so it exercises the real S3 gateway (latent bug fixed).
Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: finish test-file reorg + convert cloud licensing to a real Playwright e2e

Directory grouping (finishing the reorg): auth tests -> http/auth/, cloud-store
test -> cloud-store/, captcha + signup-mode -> usecases/site/ (with import-depth
fixes the moves needed).

One file per resource at the test layer:
- save-to-drive.integration + purge.integration -> object.integration.test.ts
- save-to-drive.cf-test -> object.cf-test.ts
- share-notification.integration -> share.integration.test.ts
- webdav.e2e (a vitest integration test, not Playwright) -> merged into
  webdav.integration.test.ts

Cloud licensing e2e: e2e-cloud-integration.test.ts was a vitest file mostly
duplicating existing integration coverage (licensing-admin.integration +
licensing-cloud.test) and the pairing e2e already in cloud-store.spec.ts.
Replaced with a real Playwright e2e (e2e/licensing.spec.ts): pair+approve ->
assert a Pro gate opens -> unbind -> assert it closes. Shared pairing helpers
extracted to e2e/helpers.ts (cloud-store.spec now imports them). run-cloud-e2e
runs both cloud specs in one tunnel; CI grep-invert excludes the new title from
the no-cloud run.

tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): move the cloud-store domain under store/ (matches /api/store)

Following the dir move: http/cloud-store/* -> http/store/*, the cloud-store +
cloud-traffic-metering usecases -> usecases/store/, and the top-level
cloud-traffic-metering http integration test -> http/store/. The http/cloud-store.ts
barrel now re-exports from ./store/*. All importers + moved-file imports rewired.

tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drop the cloud- prefix under store/ now that the dir carries it

- usecases/store/cloud-store -> store.ts; cloud-traffic-metering -> traffic-metering.ts
- http/store/cloud-store.integration -> store.integration; cloud-traffic-metering
  .integration -> traffic-metering.integration
- the http barrel http/cloud-store.ts -> http/store/index.ts (re-exports from
  ./storefront + ./webhooks); app.ts imports './http/store'

store/ is now uniformly named (storefront/webhooks/helpers/shared/traffic-metering
+ store + index). tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(e2e): licensing spec asserts the bind/unbind lifecycle, not a pro-only gate

The cloud E2E account is business-tier; its pairing certificate does not grant
open_registration (that's why the old vitest test seeded a local pro cert for
that assertion). Assert the edition-agnostic licensing lifecycle instead:
pairAndApprove (binds + waits active) -> unbind -> /status reports bound:false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:51:01 -04:00
Jasper VanandClaude Fable 5 6521d1b722 feat(events): unify SSE stream, replace frontend polling (#432)
* feat(events): unify SSE stream, replace frontend polling

Add a single /api/events SSE endpoint that multiplexes domains via named
events. Jobs and notifications are always-on; download tasks are an opt-in
per-connection subscription carried in the EventSource URL, so the server only
polls what an open page needs and a browser tab holds one connection.

- Replace refetchInterval polling: sidebar active-job badge, tasks list,
  notification unread count
- Fold the download-tasks SSE into the unified endpoint; remove the now-dead
  /api/download-tasks/events route, downloadTaskEventsUrl, downloadTasksUrlApi
- Client subscription registry: useServerEvents (single connection, reconnects
  when the merged subscription query changes) + useServerEventSubscription
- Decouple the keep-alive heartbeat (25s idle) from the 2s data poll

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloader): regenerate OpenAPI spec/client after dropping /events

Removing /api/download-tasks/events from the unified-SSE refactor drifted the
generated downloader OpenAPI doc and Go client (caught by openapi:downloader:check
in CI). Regenerate both. With the events operation gone, the single-value
assignedTo enum constant collapses from GetApiDownloadTasksParamsAssignedToMe to
Me, so update the hand-written client reference to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(events): cover unified SSE store and endpoint

Codecov flagged the new SSE code as uncovered.

- Unit-test the pure server-events-store (merge/sort query key, listener
  notifications, subscription lifecycle)
- Integration-test GET /api/events: 401 unauthenticated, and that an authed
  user with a queued job receives jobs + notifications events
- Exclude useServerEvents.ts (EventSource/React-effect glue, not unit-testable
  without a DOM) from coverage, matching the existing src/routes & src/components
  exclusions; its logic lives in the now-covered store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): stop waiting on networkidle now that SSE is always connected

The unified /api/events stream is mounted on every authenticated page, so the
network never goes idle and waitForLoadState('networkidle') hangs until the job
times out. Drop the four networkidle waits (sign-in helper + image-host reloads);
subsequent navigations and element auto-waits already gate readiness.

Also broaden the /api/events test to cover the abort and error branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:14:24 -04:00
Jasper VanandClaude Opus 4.8 5a5583b4ed feat(licensing): regroup comparison table by capability and add social-login/downloader gates (#423)
* feat(licensing): regroup comparison table by capability and add social-login/downloader gates

Decouple the feature comparison table's grouping axis from the pricing tier:
features are now grouped by capability (core/advanced) while edition
availability is expressed purely by the per-edition cells. Coming-soon
features show a badge next to the name with a muted check in the target
edition column, so it's clear which edition they will land in.

Tier reclassification (per product decision):
- Social login & OIDC: free = 1 provider, Pro/Business = unlimited
- Downloaders: free = 1, Pro/Business = unlimited
- Site announcements, Multi-IdP SSO, LDAP/SCIM, Analytics: Business-only

New runtime gates (enforced, mirroring the storages count-gate):
- social_login_unlimited in POST /api/admin/auth-providers (402 on 2nd)
- downloaders_unlimited in POST /api/admin/downloaders (402 on 2nd)
- site_announcements added to BUSINESS_ONLY_FEATURES

Copy cleanup:
- Rename rows that embedded a limit word: "Unlimited Team Workspaces"
  -> "Team Workspaces", "Storage Backends" -> "Storages"
- Clarify "Storage Plans" -> "Sell Storage & Traffic" (the quota_store feature)
- Make the Licensing page intro edition-neutral (Pro + Business) instead of
  the leftover Pro-only copy

Tests: add gate tests for both new limits; seed licenses in the existing
multi-provider/multi-downloader tests; switch announcement tests to a
Business license.

Note: site_announcements moving to Business takes full effect for real
licenses only once zpan-cloud stops listing it in Pro certificates; the
local edition-derived path is already updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(licensing): drop unused per-cert feature override, derive from edition

The optional `features[]` override on the license certificate was added with
the Pro/Business split but was never exercised: real certs carry only
`edition`, and entitlements are derived from it via the feature registry
(PRO_GATE_KEYS minus BUSINESS_ONLY_FEATURES). The override was dead in the
normal flow and duplicated the edition→feature mapping in two places.

Remove it so edition is the single source of truth:
- Drop `LicenseAssertion.features` and `normalizeFeatures` (verify.ts)
- `effectiveFeatures(edition)` no longer takes/honors an override list
- Simplify the test seed helpers (no `features` arg, no test-side
  business-only set) and update licensing tests to assert edition-derived
  entitlements

`BindingState.features` (the resolved list exposed to the client) is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloader): regenerate OpenAPI spec and Go client for new 402 response

The downloaders create route gained a 402 (feature_not_available) response
for the free-plan limit; regenerate the committed OpenAPI document and Go
client so openapi:downloader:check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:32:59 -04:00
saltbo e17e2018d3 fix(docker): use host hostname for downloader registration 2026-06-07 01:00:35 -04:00
saltbo 5f83099c7c fix(docker): configure remote downloader runtime 2026-06-06 20:44:08 -04:00
saltbo 7e7eeb4ca7 fix(downloads): expose torrent listen port in docker 2026-06-06 20:26:03 -04:00
saltbo c1cec37ccf fix(downloads): report downloader transfer speed 2026-06-06 20:19:45 -04:00
saltbo 2e12929290 fix(downloads): improve downloader assignment handling 2026-06-06 20:13:23 -04:00
saltbo 47c0af9675 fix(downloader): clear seeding phase on shutdown 2026-06-06 19:03:27 -04:00