Homepage

Two-factor authentication for pos-cli and the Instance API, error responses keep the page's content type, dynamic cache without a layout

September 16, 2026

NEW

  • Two-factor authentication for pos-cli and the Instance API: When the Partner Portal account that owns an API token belongs to a Partner with two-factor authentication enabled, the token now needs a two-factor session before it can be used against an instance. The requirement is set per Partner on the Partner Portal; nothing changes for Partners who have not enabled it.

    • Affected commands. Every pos-cli command that talks to an instance: deploy, sync, exec, exec-graphql, exec-liquid, constants, data export / import, migrations, logs, pull, and the GUI. Read-only commands are included, since a GraphQL query and pos-cli data export both return instance data.
    • Prompting. deploy and sync ask for a code before uploading anything; other commands ask when the instance refuses the request, then retry it. The code is exchanged with the Partner Portal directly and never sent to the instance.
    • Sessions. A session lasts 8 hours and is scoped to a single instance, so a session for staging does not work against production. It is cached per environment in .pos under two_factor_session, and writing it sets the file's permissions to 0600.
    • Automation. Pass --otp-code (or set POS_PORTAL_OTP_CODE) to skip the prompt. In CI, set POS_PORTAL_SESSION_TOKEN to a session minted elsewhere.
    • Exemptions. API keys issued by the instance itself are unaffected, as they belong to no Partner Portal user.
    • Note: update pos-cli before enabling the requirement for your Partner - older versions cannot complete the exchange.

    Custom API clients receive a 401 naming the reason, so it can be distinguished from an expired or revoked token:

{"error":"two_factor_required","errors":["A two-factor session is required to use this instance"]}

A 401 without an error key still means the token is invalid, expired, or revoked.

IMPROVED

  • Error responses keep the content type the page already set: A page sets its content type before rendering, either from its own format or via {% response_headers %}. Anything rendered afterwards used to negotiate the format again against the Accept header, which failed when the two disagreed. The format is now taken from what the response already committed, and unrecognized formats render as text/plain. Errors on .js pages are returned as a JSON string literal instead of bare text, so the browser no longer parses an error message as script.

  • Clearer response when an instance has no object storage configured: Requesting a presigned upload URL returned no response at all, and pos-cli sync reported fetch failed. The endpoint now returns 501 with a direct_upload_unavailable error, and pos-cli falls back to uploading assets through the instance.

  • Host-relative asset URLs with local storage: When an instance serves its own assets from disk, asset_url now returns a host-relative URL. Previously an ASSET_HOST inherited from another environment pointed those URLs at a CDN that never received the files, and an unset ASSET_HOST raised an error on every page calling asset_url. Instances using a CDN are unaffected.

FIXED

  • Dynamically cached pages without a layout: A page combining dynamic_cache with an empty layout rendered correctly on the first request, then failed on every cache hit because reapplying the layout to the cached body required a layout to exist. Pages that declare no layout are now served from cache as-is. This affected cached sitemap.xml pages and any other page declaring layout: "".

  • Redundant Liquid context on dynamic cache hits: The layout built a second Liquid context and reparsed the layout value on every request. It now reuses the context built for the page body.

  • Partner Portal URLs on non-default ports: An instance registered against a Partner Portal running on a non-default port dropped the port when storing the URL, sending later token validations to :80 / :443. These failed with a connection error that surfaced as a plain 401, indistinguishable from an invalid token. Local setups and private stacks were affected; production was not.

  • Swapped exception names in low-level error logs: IP spoofing attempts were logged as missing parameter errors and vice versa. The 403 and 406 statuses returned were correct and are unchanged.

Questions?

We are always happy to help with any questions you may have.

contact us