Releases and security
2.2.0 - 2026-07-27
Migration to Tera 2.1. Beyond the port itself, Tera 2 stops treating a missing value as a false one: an undefined variable, a block with no parent, an unknown filter are now errors instead of silence. That single change surfaced ten latent defects, all predating this release and none detectable by the compiler, the test suite or normal use. They are listed under Fix and Security below.
Breaking — runique (Tera 1.20 → 2.1)
- Template engine upgraded to Tera 2.1. Project templates may need changes. The three rules that bite:
- a
{% block %}can no longer sit inside{% if %}or{% for %}— inheritance resolves blocks statically, so a block whose existence depends on runtime state is rejected. Move the condition inside the block; - a block defined in a child but absent from the parent is now an error (it was silently discarded);
x.y is definedno longer protects an undefinedx— the attribute access is evaluated first. Use the new optional chaining:x?.y.
- a
- Filters moved out of Tera's core into
tera-contrib. Runique re-registers them so no template change is required, under both their upstream names and their Tera 1 names:urlencode,urlencode_strict,date,json_encode,striptags,spaceless,regex_replace,slug(aliasslugify),filesize_format(aliasfilesizeformat), functionsnow,get_random, filtershuffle, testsbefore,after,matching. filter(attribute=…)was removed upstream with no replacement. Rewrite as a list comprehension:{% set x = items | filter(attribute="k", value=v) %}→{% set x = [i for i in items if i.k == v] %}.intlost itsdefaultargument (it now only takesbase): usevalue | default(value=0) | int.- Admin template blocks
create_denied,edit_denied,delete_denied,group_edit_deniedremoved. They were unreachable: the server denies and redirects before rendering. A project overriding them will now fail at template load. AdminConfig::site_titlenow defaults to empty instead of"Administration", so an unset title falls back to the translatedadmin.base.titlerather than a hardcoded English-ish string. Set it explicitly via.site_title(...)to override.
Security — runique (password hashes written to the audit log and shown in the admin)
eihwaz_historyrecorded the value of every changed field, password columns included. Editing a user through the admin stored the old and new Argon2 hashes in the history table, and the history diff view displayed them. Impact: credential material duplicated outside the users table, retained indefinitely, and readable by anyone holding the history permission without access to the users table — a permission-boundary bypass, plus offline-cracking material for weak passwords. Sensitive values are now replaced by••••••at five chokepoints — two on write (diff_fields,summary_jsoncovering all four bulk paths) and two on read (DiffField,parse_diff). The field is still listed: that a password changed is legitimate audit data, its value is not. Redaction on read also neutralizes rows written before this release, with no data migration.
Security — runique (non-bypassable sensitive-field marking)
FieldConfig::is_password, private, one-way, derived from the type. Any field built withtype_field == "password"is marked at construction — the protection cannot be lost by forgetting to ask for it.mark_password()sets it; no operation clears it. The readeris_password()also re-tests the type, so even a deserialization setting the flag tofalseis ineffective. It replaces thefield_type() == "password"string comparisons previously scattered acrossfill(), and now drives widget rendering, the GET skip, therequiredrelaxation on edit, and log masking.form_fieldsno longer carries password values. They were serialized into the create/edit page context — not rendered by the built-in templates, but a project template iteratingform_fieldswould have printed them.
Fix — runique (readonly/disabled never applied on nine field types)
base_datetime.html,base_file.html,base_special.html,base_string.htmlandbase_hidden.htmlall testreadonly.choice/disabled.choice, but 9 of the 18 field renderers never inserted them — Tera 1 evaluated the missing variable as false, so the attribute was simply never emitted, silently.text.rseven carried an// IMPORTANT: Inject readonly/disabled configcomment followed by nothing. Fixed by collapsing the contract into a single path:FormField::base_context()providesfield,readonlyanddisabled, and all 18 renderers start from it. A new field type inherits the contract without thinking about it.
Fix — runique (admin templates duplicated the permission rules — and got them wrong)
- Nine admin templates recomputed permissions in Tera by walking
current_user.groupes[].permissions[], shadowing the flagsinject_contextalready provides. That copy tested onlycan_update/can_deleteand ignoredcan_update_own/can_delete_own: a user holding own-record rights on a record they own was granted access by the server and refused by the UI. The duplicated computation is removed fromcreate,edit,deleteandbulk_edit, whose guards were unreachable anyway (authorize_get/MemberAction::authorizeredirect before rendering).
Fix — runique (twenty-two inert title blocks, and a <title> ignoring site_title)
- Eleven admin templates declared
{% block title %}while the root template hardcoded its<title>with no such block: every admin page shared one browser-tab title. The root now declares the block, defaulting tosite_title, then to the translatedadmin.base.title— so per-page titles work, an unset title is localized, and the value configured inmain.rsfinally reaches the tab.
Fix — runique (internal templates loaded one by one)
- Tera 2 resolves
{% include %}and{% extends %}when a template is added, so a loop overadd_raw_templaterejected any template whose dependency came later in iteration order (debug.htmlincludes six partials declared after it) — the framework would not boot. Internal templates are now added as a single batch viaadd_raw_templates, which validates once at the end and rolls back atomically on failure.
Fix — runique (login page crashed; per-field errors never displayed)
login.htmldereferencedform.usernameanderrors.username, neither of which is inserted by any of the five login render paths — a fatal error under Tera 2. Beyond the crash, it means the per-field error messages of the login form could never be displayed: the only place insertingerrorsis a local_contextinrenderer.rsthat is built and discarded.
Fix — runique (debug error page parsed a fabricated error)
from_runique_errorcalledtera.get_template(name).unwrap_err()and parsed a line number out of the resulting "template not found" error — unrelated to where rendering actually failed. It now reads the message carried byRuniqueError::Template. (get_templatealso became private upstream.)
Fix — runique (eight missing i18n keys)
admin.base.theme_toggle,admin.base.back_to_site,admin.dashboard.kpi_resources,admin.dashboard.kpi_entries,admin.dashboard.kpi_largest,admin.dashboard.th_count,admin.detail.btn_reset_passwordand its new short variant were referenced by templates but existed neither inADMIN_MESSAGE_KEYSnor in any translation file. The{% if key %}…{% else %}French{% endif %}pattern hid the gap: eight admin labels were hardcoded French in a framework shipping nine languages. Added across all nine.
Fix — runique (migration generator: invalid SQL ordering, Postgres identifier case-folding, SQLite panic)
ALTER COLUMN ... TYPE ... USINGcombined with a nullable spec produced invalid SQL.modify_column(...)chained.not_null()/.null()together with.using(...)for enum/type-transition migrations; sea-query appendsUSINGat the very end of the wholeALTER TABLEstatement regardless of which clause it "belongs" to, so the result readTYPE t, ALTER COLUMN x SET NOT NULL USING expr—USINGstranded afterSET NOT NULLinstead of right afterTYPE, rejected by Postgres. An enum/type transition never changes nullability, so the nullable spec is simply no longer set on that particularmodify_column.- Postgres identifier case-folding mismatch on
ALTER TYPE ... ADD VALUE/RENAME VALUE. The rawCREATE TYPEDOblock is never quoted, so Postgres folds the name to lowercase at creation — but theType::alter()builder always quotes its type name, preserving case, so it looked up a different, non-existent identifier ("ChangelogCategory"instead of the storedchangelogcategory). Fixed by lowercasing the name passed to that specific builder call;CREATE/DROP TYPEandColumnType::Enumneeded no change since neither is ever quoted. modify_columnpanics unconditionally on SQLite. Not a SQL-syntax rejection — sea-query's SQLite backend refuses to render anymodify_columnat all, a real engine limitation (SQLite'sALTER TABLEonly supportsADD/RENAME/DROP COLUMN). Affected every enum-transition and general nullable/type/unique-change migration once it reached that engine. Now skipped on SQLite: harmless for enum transitions (SQLite has no native enum —ColumnType::Enumalready renders as plain text), and flagged with an explicit warning comment in the generated file for the general case, where the constraint change is genuinely not applied on that engine.- Foreign keys are now always declared inline in
CREATE TABLE, not only for SQLite. A migration file is fixed forever once committed, so its shape cannot depend on whichever enginemakemigrationshappened to target when it was generated. The separate..._create_relationsmigration mechanism is removed — it would otherwise double-declare the same constraint. updated_attriggers are now always emitted with a runtime backend check, for the same reason: previously gated at generation time, a file generated while targeting Postgres silently broke every other engine.
Fix — runique (makemigrations: a migration/ bootstrapped via sea-orm-cli migrate init produced invalid Rust)
sea-orm-cli migrate initis the documented first step to scaffold themigrationcrate (Cargo.toml,src/main.rs) before ever runningmakemigrations— but its generatedlib.rsuses a different shape than Runique's own (a single-linevec![Box::new(X::Migration)],pub use sea_orm_migration::prelude::*;). The old line-based patching inupdate_migration_lib/ensure_admin_migration_positionedassumed Runique's own multi-line layout: new entries were inserted after the whole, already-closedvec![...]statement instead of inside it, the framework migrations (Eihwaz*) landed outsideMigrator::migrations()entirely, andsea-orm-cli's own placeholder migration (up()/down()both a baretodo!()) was never removed — it would panic the first time any migration actually ran.lib.rsis now parsed into a small, formatting-independent model (module list + vec entries) and always re-rendered canonically; the placeholder module and its file are dropped on the very firstmakemigrationsrun.
Fix — runique new (scaffold: invalid {% static %} syntax crashed new projects at boot)
- The generated
templates/about/about.htmlused{% static 'css/about.css' %}(single quotes) — invalid syntax, failing the whole app at template-load time withUnknown tag. Fixed to double quotes, matching every other template.
Fix — runique (CAST(... AS TEXT) invalid on MySQL/MariaDB — search and filters)
CAST(col AS TEXT)is Postgres/SQLite syntax; MySQL/MariaDB rejectTEXTas a cast target and requireCHAR, since sea-query never translates the cast type name across backends. Affected every case-insensitive search and column filter built on a rawCAST: the admin generator'slist_fn/count_fncolumn filters, the bulk-action lookup, the m2m option query, the FK label lookup, the built-inusers/groupes/droitsresources, and both arms of the publicsearch_cond!macro (all_columns icontains,or(...)). Fixed with new shared helperstext_cast_type/text_eq/ilike(runique::admin::helper::sql_dialect) that pickCHARvsTEXTfromdb.get_database_backend()at runtime — a migration file or generated resource is committed once but must run correctly on whichever engine the project actually uses, not whichever engine happened to be configured when it was generated.- Breaking for
search_cond!callers: every arm now takes the live&DatabaseConnectionas its first argument —search_cond!(db => Entity => or(...))instead ofsearch_cond!(Entity => or(...))— including the two?Col in/not_inarms that don't need it internally, since a macro can't mix arms with different argument counts without introducing parsing ambiguity between a leading$entity:tyand a leading$db:expr.search!is unaffected (it filters through typedColumnTraitmethods, which sea-orm already renders correctly per backend).
Fix — runique (CSRF check ignored the X-CSRF-Token header — broke JSON/fetch clients)
Prisme's CSRF check (forms/extractor.rs) only ever read thecsrf_tokenfield from the parsed body/query — Runique itself exposes the token via anx-csrf-tokenresponse header (so JS clients can read it without scraping a hidden form field), but nothing on the way back in ever read it from a request header. Afetch/XHR client sending the token asX-CSRF-Tokenwith a JSON body (nocsrf_tokenfield) always failed CSRF validation, sochecked_data()returnedNoneand any code relying on it silently saw an empty request — no error, just data that was never there.check_csrfnow falls back to theX-CSRF-Tokenheader when the body has nocsrf_tokenfield. Safe as a fallback: a plain cross-site<form>can't set custom headers at all, and afetch/XHR that does triggers a CORS preflight the browser only completes for origins the app explicitly allowed (CorsConfigis disabled by default, and itsany_origin() + allow_credentials(true)combination is a build-time error) — so this opens no path a body-only check didn't already have to guard against.
Internal — runique
JsonMapis nowHashMap<String, serde_json::Value>. It was typed ontera::Value, which in Tera 1 wasserde_json::Value; the two types diverged in 2.0, dragging the whole forms layer into the migration for no reason. Tera 2 passes filter arguments asKwargs, so no alias is needed there.- Custom filters and functions ported to the Tera 2 signature (
Fn(Arg, Kwargs, &State) -> Res).markdown,sanitize,formandcsrf_fieldnow declareis_safe()and returnValue::safe_string, which removesMARKDOWN_REGEXandSANITIZE_REGEXfrom the template preprocessor: safety is now an invariant sitting next to the sanitizer rather than a| safeinjected into template source by a regex.plaintextis deliberately left escaped. - Both CSRF entry points (
csrf_fieldfilter,csrf_token()function) now run the token throughescape_htmlbefore interpolation — the tag is emitted unescaped, so its safety must not depend on a guarantee made in another module. - Test helper
tests/helpers/tera.rs(kwargs(),no_kwargs()) and a newtests/context/test_autoescape.rscovering the escaping contract end to end: a plain variable is escaped,plaintextstays escaped,markdownemits HTML without| safe, and the same value is escaped without the filter.
Added — derive_form (pk-uuid feature, Pk on regular fields)
- New Cargo feature
pk-uuid: thePkalias resolves toUuid(Uuid::now_v7()), mutually exclusive withbig-pkvia acompile_error!if both are enabled. - The
Pkkeyword can now be used on any field, not just the primary key — typically foreign keys pointing at aPk-typed table. It resolves immediately at parse time to the concretei32/i64/Uuidtype behind the active feature, the same mechanism already used forpk: id => Pk. This closes a real FK/PK desync bug class: a foreign key hardcoded asintsilently breaks (or worse, silently truncates) the moment the referenced table's PK switches tobig-pk/pk-uuid. Five such fields were found and fixed indemo-app.
Breaking — derive_form (legacy fields: syntax removed)
- The v1 field grammar (
fields: { name: SqlType [options] }insidemodel!{}) is removed. The anonymous-block syntax ({ name: SemanticType [options] }) introduced alongside it is now the only accepted form.extend!{}is unaffected — it always requiredfields:and still does; the two macros have distinct grammars. - Auditing this removal surfaced that v2 had shipped without full parity with v1. Closed before removing v1:
- missing types added:
char,i8,i16,u32,u64,f32,timestamp,timestamp_tz,json_binary,binary,var_binary,blob,cidr,mac_address,interval; - missing options added:
readonly,label: "..."; - confirmed-dead options removed rather than ported:
index(was already a no-op under v1),select_as(set but never read anywhere in the codebase).
- missing types added:
Fix — derive_form (v2 grammar: silent misroutes and no-ops)
jsonfields declared under the v2 grammar were routed toFieldType::Textinstead ofFieldType::Json— grouped by mistake withrichtext/textarea.min_length,min,max,min_f,max_fandmax_sizeparsed successfully under the v2 grammar but were never translated into the generated validation/schema — writing[max_size: 1000]on a v2 field compiled and had zero effect.var_binaryfell through to the genericString/VARCHARSQL mapping instead ofVarBinary, in both themodel!{}andextend!{}migration parsers.