Move transfer audit persistence and redirect audit target resolution behind usecase functions so HTTP handlers pass dependencies whole and satisfy the architectural boundary.
Centralize request audit recording, preserve immutable download-task history, and derive hourly statistics and backfills from the same authoritative sources. Add durable user registration facts so admin deletion no longer destroys signup history.
Retain purged matter and image metadata while hiding tombstones from live reads. Record exact storage deltas and build hourly trends only after the opening balance.
Serve canonical root-relative DAV URLs on a configured hostname while preserving the existing /dav mount. Reconcile Cloudflare rewrite rules and Worker custom domains during fork deployments, and expose the effective URL through site options.
Build versioned UTC hourly rollups, validate analytics facts, and keep dashboard requests result-only with explicit coverage semantics.
Move derived metrics to the server and simplify the admin dashboard around one reconcilable UTC range without export.
Use metadata-aware personal workspace detection across org repositories and UI, generate personal/team slugs with the new default random rules, and remove user-managed team slug fields.
Encode WebDAV path segments consistently so Finder follows workspace names such as Ambor's Space via %27 instead of XML-escaped apostrophes.
* 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>
* 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>
No callers remain after select('public') was removed in #456 — the
public-bucket flow is gone, so the gateway method, its port declaration
and its tests are dead code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
Download/upload totals were getting wiped on completed tasks. Two causes, both
mine:
1. nextTaskRuntime treated an incoming runtime as a full snapshot and REPLACED
the stored one. reportSeedingStopped sends only {phase, seeding} (no
progress), so the cumulative download/upload progress was erased — a completed
task ended up as {phase: completed, seeding: {active: false}} with no
transfer record. Progress is cumulative, not a per-report snapshot: carry it
forward when a runtime report omits it (progress patches still apply on top).
2. clearStaleSeedingRuntime nulled the entire runtime to drop the seeding phase,
which also erased progress. Now it surgically edits the JSON (phase ->
completed, remove the seeding object) and keeps progress + file list.
Confirmed on prod: affected tasks had runtime collapsed to just engine/phase/
seeding with progress gone. Tests updated to assert a phase-only report
preserves progress.
reportRemoteDownloadUnit short-circuited on a locally-cached 'blocked' usage
record and threw without re-contacting the cloud. Once a unit was blocked, the
task was wedged in 'suspended' permanently — recharging credits had no effect,
because the next charge attempt never re-asked the cloud (confirmed on prod: the
stuck task's unit 1 was status='blocked').
Drop the short-circuit so a previously-blocked unit re-syncs with the cloud on
the next attempt; if credits are now available it's accepted and the task leaves
suspended. A 'reported' (already-charged) unit still short-circuits, so no
double-charge.
Test: a unit blocked on first attempt is accepted on retry after a top-up, and
the task transitions back to downloading.
The first cut keyed off listUnreachableIds (existing downloaders with a stale
heartbeat), which misses a task whose downloader was DELETED — its id is gone
from the table, so it never appears in any stale list. Confirmed on prod: the
lone stuck task was owned by a downloader id absent from the downloaders table.
clearStaleSeedingRuntime now drops the stale seeding runtime on any completed
task NOT owned by a live (recently-heartbeating) downloader — offline, deleted,
or unassigned — and runs every sweep regardless of new staleness. A live
downloader's genuine seed is preserved.
Test now asserts both: a deleted owner's seed is cleared, a live owner's is kept.
A completed task keeps runtime.phase='seeding' while its downloader seeds. If
that downloader goes offline without reporting the seed stopped, the task shows
'Phase: Seeding' forever — the clearStaleSeedingReports one-shot only covers the
current downloader's own (assignedTo=me) tasks, not another offline downloader's.
Stale recovery now also drops the stale seeding runtime on completed tasks of
unreachable downloaders (alongside the canceling/pausing settle), so they stop
showing as seeding. Runs every sweep, idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The previous fix gated control-task settling on listStaleIds, which only
returns downloaders that are still status='online' with a stale heartbeat — the
online→offline transition. A downloader marked offline by an earlier sweep is
never returned again, so a canceling/pausing task it held stayed stuck forever
(exactly the observed case). The early 'no new stale downloaders' return made it
worse.
Add listUnreachableIds (heartbeat past the lease, any status) and settle
canceling→canceled / pausing→paused for those every sweep (idempotent),
independent of the requeue+markOffline transition path.
Regression test now flips the downloader to status='offline' before recovery —
the case the prior fix missed.
When a downloader went offline holding a 'canceling' (or 'pausing') task, the
stale-lease recovery only requeued [assigned,downloading,uploading,interrupted]
— so the control task was never resolved and sat in 'canceling' forever, since
no live downloader would ever ack it.
recoverStaleDownloaderAssignments now also settles control states for stale
downloaders: canceling→canceled (terminal, assignment cleared) and
pausing→paused (resumable). Adds the resolveControlAssignedToMany repo method.
Integration test: a stale downloader's canceling task settles to canceled once
recovery runs.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
* 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>