Phase 21e — Publishable Forms hardening (consumer migration)
Phase 21e — Publishable Forms hardening (consumer migration)
What changes. Three defence-in-depth tightenings on the publishable-forms public surface shipped by Phase 21b:
- H2 — mode-aware validator severity.
PublishableFormConfigValidatornow emitsError(boot fails) in persistent-data modes (Individual/Team/MultiTeam) when aPublishableschema is registered withoutShareTokenStore/PublicBaseUrl.Anonymous/AuthenticatedEphemeralpreserve the priorWarning. The new escape hatchServerConfig.AcceptUnsignedPublishable = true(envTOOLUP_ACCEPT_UNSIGNED_PUBLISHABLE=1) downgrades persistent-modeErrorback toWarningfor staging-shape-in-production-mode. - L1 — collapsed token-rejection messages.
PublicEmbed.describeErrornow renders a single user-visible string for every token failure (Unauthorised,NotFound("token", _),RateLimited). Audit log server-side still distinguishes the cases; the embed no longer doubles as a token-state oracle. - L2 — per-token rate-limit hook. New optional
ShareTokenClaim.RateLimitfield +IShareTokenRateLimiterinterface +InMemoryShareTokenRateLimiterdefault +FormsServerApp.withShareTokenRateLimiterbuilder + newFormError.RateLimitedvariant.
Scope. Forge SDK ships the validator severity change, the new claim field + issue-time validation, the new interface + in-memory default, the withShareTokenRateLimiter builder, the IShareTokenRateLimiterContract portability pack, and the new FormError variant. Consumer adoption is opt-in per deployment.
Diff to apply (consumer side)
1. Decide the validator severity posture
If the deployment legitimately runs in a persistent-data mode with Publishable schemas registered but no token store wired (dry-run / staging dress-rehearsal), set the escape hatch:
let config = {
ServerConfig.defaults with
Mode = MultiTeam
ShareTokenStore = NoShareTokenStore
AcceptUnsignedPublishable = true // ← downgrade to Warning
}
Or set the env var: TOOLUP_ACCEPT_UNSIGNED_PUBLISHABLE=1.
Most production deployments do NOT want this — boot-failure on a missing token store is the safer default. The Error exists because a misconfigured production deployment that boots cleanly with only a Warning ships a public surface with no signed-token gate, no use-limit enforcement, and no revocation.
2. Surface FormError.RateLimited in your UI
The Fable.Remoting wire contract picks up the new DU case automatically. Client-side match statements over FormError are no longer exhaustive — add the case:
match err with
| FormError.Unauthorised | FormError.NotFound _ -> ...
| FormError.RateLimited ->
// NEW: per-token rate limit exceeded. PublicEmbed renders the
// same "link is no longer valid" string as the other token-
// rejection cases; only the audit log distinguishes.
...
| ...
If you use the SDK's PublicEmbed Feliz component, no change is needed — the message collapse is internal.
3. (Optional) Issue tokens with per-token rate limits
For high-value publishable forms where a leaked token could be exercised to skew aggregations, set RateLimit on the issue request:
let issueRequest: ShareTokenIssueRequest = {
ScopeId = scopeId
ResourceKind = PublicFormApi.ResourceKind
ResourceId = schemaId
AttributedHandle = Some recipient.Handle
IssuedBy = userId
ExpiresAt = Some expiresAt
UseLimit = Some (Some 10)
RateLimit = Some {
MaxUses = 5
Window = TimeSpan.FromMinutes 1.0
}
}
MaxUses < 1 or Window < 1s is rejected at issue-time (Error (ShareTokenError.StorageFailed _)). Leave RateLimit = None to preserve pre-21e behaviour byte-for-byte.
4. Multi-replica deployments: wire a distributed IShareTokenRateLimiter
The SDK auto-defaults to InMemoryShareTokenRateLimiter (single-instance only — window state lives in a process-local dictionary). Multi-replica deployments must wire a distributed companion so per-token windows are shared across nodes:
let limiter : IShareTokenRateLimiter =
MyCompany.RedisShareTokenRateLimiter(redisConnString) :> _
FormsServerApp.create ()
|> FormsServerApp.withShareTokenRateLimiter limiter
|> ...
The IShareTokenRateLimiterContract portability pack (ToolUp.Forms.Tests/Contracts/IShareTokenRateLimiterContract.fs) validates any impl against the same conformance bar.
Verification steps
dotnet buildclean on the consumer's sln. Adding the newFormError.RateLimitedcase is the most likely break point — fix non-exhaustive matches. The newServerConfig.AcceptUnsignedPublishablefield is part of the record so any anonymous-record literal construction ofServerConfigfails; use{ ServerConfig.defaults with ... }instead.- Boot a persistent-mode deployment with
Publishableschemas +NoShareTokenStoreand confirm startup fails with anErrorpreflight diagnostic namingAcceptUnsignedPublishable. Set the env var and confirm it boots withWarning. - Issue a token with
RateLimit = Some { MaxUses = 5; Window = 1.0 min }and exercise the public-submit path 6 times rapidly; confirm submissions 1–5 succeed and the 6th returnsFormError.RateLimited. Wait 61 seconds; confirm a fresh submission is admitted. dotnet run --project Build.fsproj -- Verifyclean in forge — the newPublishableHardeningTests(4 test groups including the contract pack) must be green.
Rollback
H2 is opt-out via AcceptUnsignedPublishable = true (or the env var) — set it to preserve pre-21e boot behaviour for persistent modes. L1 is internal to PublicEmbed.describeError; rolling back requires reverting the consumer's adoption PR. L2 is additive — the new claim field is option-typed and defaults to None, so leaving it unset preserves byte-for-byte pre-21e behaviour. The auto-defaulted InMemoryShareTokenRateLimiter is consulted only when RateLimit = Some _; tokens issued without a rate limit skip the gate entirely.
The only non-reversible part is the new FormError.RateLimited DU case (consumers must add the match arm). If a regression in any of the three areas surfaces post-adoption, revert the consumer's adoption PR and pin ToolUp.Forms.Server to the pre-21e release.