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>