Skip to content

v0.2.34

tagged this 29 Jul 03:04
Share is the hero SKU that anchors Pro, and it could not be used end to end: an
operator minted a link and nothing could open it. `resolveShare()` had exactly one
caller in the repo — its own test. `revokeShare`/`listShares` were unrouted too.
Intake, its twin, shipped the whole pattern (pre-auth public routes + a landing in
core); Share was scaffolded engine-only and never finished.

Core (free/MIT) gets the shell and the plumbing; every enforcement decision stays
in the gitignored paid module, reached through the ModuleRegistry gate:

- Three pre-auth public routes `/api/fm/share/{info,unlock,file}` next to Intake's,
  gated installed+licensed only (a public request carries no main JWT, so there is
  no claim to check), plus the two missing operator routes.
- `public/share.html` — static, English, no build step, deliberately brand-neutral:
  every displayed value comes from the module's response, so the Share+ "branded
  landing" item stays monetizable. Server strings render through textContent, and a
  preview URL is accepted only when it matches our own image/bytes routes.
- `ShareGrant` (`t=share_grant`): POST /share/unlock exchanges the password for a
  short-TTL jti-bound grant. The password never rides a query string, so it never
  lands in an access log next to the token.
- Claims `share_url_ttl` / `share_base_url` / `share_preview`, forwarded through
  embed/node/laravel/wordpress; 8 `error.share_*` codes across all 16 locales.

Three defects found while building this, all fixed at the root:

1. A share token was not self-contained — the tenant prefix was a caller argument
   that a public request cannot supply, so nothing could locate shares.json.
   `createShare` now signs the store into the token and `resolveShare` LOST the
   parameter rather than defaulting it, which removes the bug class.
2. A share on a plain local disk was cosmetic. /api/fm/stream serves only
   gated-local and SFTP, so a redirect to the file's static URL would have bypassed
   password and cap outright. The bytes route now dispatches on the disk DRIVER:
   S3/R2 enforce then 302 to a short presigned URL; local (private and public) and
   SFTP stream through the app. A static local URL is never emitted.
3. The share token was a fully usable main JWT. Signed with FLUXFILES_SECRET and
   carrying perms/disks/prefix, it could be replayed as `Authorization: Bearer` on
   /content, /zip and /presign — password, cap and revocation all bypassed, and
   revoke could not stop it because the JWT outlived the deleted record by up to 30
   days. `JwtMiddleware::assertAccessToken()` now refuses anything carrying a type
   marker; Share and Intake mint `t=share`/`t=intake` like StreamToken/ImageToken
   already did. The rule is positive — a main access token never has `t` — so it
   covers every typed token including future ones, and the legacy booleans keep
   already-issued links refused. The share payload also pins allow_download/zip/
   code_edit off as a second layer.

Revocation is now durable: revoke writes a tombstone and putRecord refuses to write
over one, so a page view that started before the revoke can no longer resurrect the
share. The underlying read-modify-write race on shares.json is unchanged and still
accepted — the worst a lost update can now do is re-revoke.

Unlock brute force is bounded by a per-share ceiling independent of IP (rotating
addresses buys nothing) sized 6x the per-IP bucket, so a shared-office NAT still
hits the per-IP limit first. Failed unlocks are counted on the record so the
operator has a signal.

Known limitation, stated plainly rather than oversold: on S3/R2 `max_downloads`
counts grants, not downloads — a handed-out presigned URL stays fetchable until it
expires. Exact caps require local/SFTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assets 2
Loading