Read replica support and query timeouts, Web Push encryption filters, binary API Call bodies, auto-installing module dependencies on deploy
NEW
- Read replica support and per-query database timeouts: PostgreSQL-backed data queries -
records,users,admin_versions, and the deprecatedmodels/user- now accept anoptionsargument that controls how the operation executes, for example:
query get_products {
records(
per_page: 100,
filter: { table: { value: "product" } },
options: { read_replica: true, timeout: 10 }
) {
total_entries
results { id properties }
}
}
read_replica: trueexecutes the whole operation against a read replica database when one is configured for the environment hosting your Instance, offloading heavy read-only work (listings, search, reporting, exports) from the primary database. Without a configured and reachable replica the flag is safely ignored and the query runs on the primary as before. Replica reads may be slightly stale due to replication lag, so opt in on read-only paths rather than right after a write; inside a{% transaction %}block the flag is ignored to guarantee consistency, and mutations never accept the argument.timeoutsets the database statement timeout in seconds for the whole operation - it defaults to 19 seconds (one second less than the platform request timeout) onceoptionsis passed, and the largest value wins when several fields in one query pass it. Queries that do not passoptionskeep the current default timeouts.
See Read Replica and Query Timeouts for details.
ecdh_computefilter: Computes an ECDH (Elliptic Curve Diffie-Hellman) shared secret from your EC private key and a peer's EC public key, for example:
{% assign shared_secret = sender_private_key | ecdh_compute: subscription.keys.p256dh %}
The peer key can be a PEM-encoded EC public key or a Base64url-encoded raw uncompressed point - the format a browser returns from PushSubscription.getKey('p256dh'). The result is raw bytes by default, ready to be piped into the hkdf filter; pass 'hex' or 'base64' (URL-safe) as the second argument for an encoded result. Together with hkdf and the encrypt filter's new explicit IV support, this makes key-agreement schemes - such as Web Push message encryption (RFC 8291) - implementable entirely in Liquid.
hkdffilter: Derives keys via HKDF (HMAC-based Extract-and-Expand Key Derivation Function, RFC 5869), for example deriving a Web Push Content Encryption Key and nonce from an ECDH shared secret:
{% assign ikm = shared_secret | hkdf: auth_secret, key_info, 32 %}
{% assign cek = ikm | hkdf: message_salt, cek_info, 16 %}
{% assign nonce = ikm | hkdf: message_salt, nonce_info, 12 %}
Arguments (all optional): salt, info, output length in bytes (default 32), and hash algorithm (default sha256). Input and output are raw bytes.
- Binary request bodies for API Calls: A body provided directly to the
api_call_sendmutation via theapi_callargument is now delivered to the external service byte-for-byte, so binary payloads - for example a Web Push message encrypted per RFC 8188aes128gcmand decoded withbase64_decode- can be sent as the raw HTTP request body:
{% liquid
assign body = encrypted_payload | base64_decode
graphql result = 'send_push', url: subscription.endpoint, method: 'POST', headers: push_headers, body: body
%}
A directly-provided body is treated as final data: it is not parsed as a Liquid template before sending (your Liquid has already run by the time the mutation receives it). Bodies of API Call notifications defined as files in app/api_calls are still rendered as Liquid templates, exactly as before.
- Deploy now auto-installs module dependencies declared in
pos-module.lock.json: If a dependency module declared in a deploy'spos-module.lock.jsonhas no files in the deploy archive - or its files are at a version different from the one the lock file pins - it's now downloaded from the registry recorded in the lock file's ownregistriesmap and installed as part of the same deploy, for example:
{
"dependencies": { "core": "2.1.9", "user": "5.2.11" },
"registries": { "core": "https://partners.platformos.com", "user": "https://partners.platformos.com" }
}
This kicks in, for example, if you .gitignore your modules/ directory and forget to run pos-cli modules install before deploying, or if you pulled an updated lock file and never re-ran it. A dependency already present in the deploy at exactly the pinned version is left untouched - no extra download. Since resolving a dependency this way costs an extra registry round trip at deploy time, the deploy report includes a warning recommending you run pos-cli modules install locally beforehand to keep your working directory in sync with the lock file and avoid the slowdown.
Details and safeguards:
- Only dependencies with a registry recorded in the lock file's
registriesmap are downloaded. The legacypos-modules.lock.jsonformat has no such map, so deploys using it behave exactly as before. devDependenciesare only auto-installed on staging Instances - they never ship to production.- A registry is only contacted if it is on the allowlist of trusted module registries - by default, only
https://partners.platformos.com. A lock file pointing anywhere else fails the deploy instead of triggering a request. - Downloaded packages are validated before extraction: a package that exceeds size or entry-count limits, contains unsafe (absolute or
..) paths, or contains symlinks is rejected. - If installing any declared dependency fails (registry error, network error, corrupt package), the whole deploy fails and rolls back - no partially-installed dependency state is left behind.
IMPROVED
encryptfilter honors an explicit IV: The optional fourth argument of theencryptfilter (initialization vector) is now used for symmetric algorithms - previously it was accepted but ignored, and a random IV was always generated. Pass raw bytes matching the algorithm's IV length (e.g. 12 bytes foraes-128-gcm, 16 bytes foraes-256-cbc); when omitted, a random IV is generated as before. This enables schemes that derive the nonce deterministically, such as Web Push message encryption (RFC 8291):
{% assign encrypted = padded_plaintext | encrypt: 'aes-128-gcm', cek, nonce %}
The IV is not supported for asymmetric algorithms (RSA, RSA-OAEP) - passing one returns an error.
jwt_encodefilter accepts raw EC private keys: For theES256,ES384, andES512algorithms, the key can now be provided as a Base64url-encoded raw private key scalar - the format VAPID keys (RFC 8292) are commonly generated and distributed in, e.g. by theweb-pushCLI - in addition to the PEM format accepted so far:
{% assign vapid_jwt = claims | jwt_encode: 'ES256', vapid_private_key %}
-
Hardened archive extraction: Deploy archives, Instance Clone/restore archives, and downloaded module packages are now extracted by a pure-Ruby unzipper instead of the system
unzipbinary, which has a history of memory-corruption vulnerabilities and no upstream release since 2009. Archive entries with absolute or..paths and symlink entries are skipped instead of being written to disk. No changes are required in your application - valid archives extract exactly as before. -
Low-level Ruby errors get full Liquid diagnostics: Errors that previously escaped Liquid's error handling entirely - for example a
FrozenErrorfrom mutating a frozen hash, or aNoMethodErrorfrom calling an unsupported method inside a filter - are now wrapped into a proper Liquid error at the point they occur. They render with the usualLiquid error (path:line): ...prefix instead of a bare Ruby message, carry a full structured diagnostic in your instance logs (previouslydata: nil), and return a diagnostic JSON topos-cliinstead of a generic 500. The{% try %}tag's catch variable still exposes the original error class and message, and your error tracker still receives the original exception with its original backtrace. -
Filter argument errors get a location and a full diagnostic: When a filter rejects its arguments - for example
{{ my_hash | array_add: 'x' }}, where the first argument must be an array - the failure is now reported through the same path as every other Liquid error. On Instances that don't run inliquid_raise_modesuch an error was previously logged as a bare message with no location at all; it now renders with the usualLiquid error (path:line): ...prefix and carries a full structured diagnostic in your Instance logs, including the line of the failing filter and the whole{% include %}/{% function %}stack that led to it. The same located message is what{% try %}exposes on its catch variable.The filter itself still returns an empty string, so nothing else about the render changes: the enclosing tag completes (
{% assign %}sets its variable to an empty string,{% return %}still returns from the function,{% include %}still renders the partial), and the rest of the page renders as before. -
Repeated Liquid errors are logged once per render: Errors that are reported without being raised are now de-duplicated by their rendered message, so a bad filter argument inside a
{% for %}loop produces a single log entry instead of one per iteration. Distinct failures are still logged separately, and are no longer lost when a later error ends the render before it finishes.
FIXED
-
splitfilter is idempotent for arrays and strict for hashes: Applyingsplitto a value that is already an Array now returns it unchanged. Previously the array was stringified (JSON-style, escaping quotes) and re-split, so re-applyingsplit- for example in a loop - corrupted the values and doubled the string's size on every iteration. Applyingsplitto a Hash now returns a clear argument error instead of garbage fragments produced by splitting its JSON representation. -
More accurate syntax error locations: Parse errors in partials loaded via
{% render %}now correctly point at the partial's own file and line instead of the file that included it. Writingkey: {{ my_var }}inside a JSON literal (instead ofkey: my_var) now raises a clear, targeted syntax error instead of a misleading generic JSON parsing error.{% include_form %}also now records its own frame in the error diagnostic stack, so errors raised while rendering the form partial point to the correct location. -
Instance Clone no longer stalls on legitimately empty files: A zero-byte file (for example the private-assets manifest of an instance with no private assets) was being treated as blank and silently dropped during upload, leaving its identifier and URL columns
nil; the target instance's clone then crashed trying to open anilURL and stayed stuck instartedforever. Empty-but-existing remote files are now stored correctly. -
Casting and sanitization no longer stay disabled after a caught error: While evaluating the value of
{% assign %},{% return %},{% graphql %},{% session %}or{% print %}, Liquid temporarily turns off type casting and output sanitization. If an error was raised in that value and then caught - for example by{% try %}, so the render continued - those switches were never turned back on, and every variable printed for the rest of the render skipped casting and HTML sanitization. They are now always restored.