v0.13.1
AlpineClubBookingsNZ v0.13.1
Release target: v0.13.1 on 2026-07-22. This patch public reference release
brings all changes landed after v0.13.0 into one supported tag. It carries two
lead items, both requiring operator action:
- Release B of the #2129/#2130 contract series — two destructive
contract
migrations that finish the expand/migrate/contract work E4 (#1930) and E8
(#1934) began and that thev0.13.0runtime-prep (Release A) made
blue/green-legal. They make this release breaking: unlikev0.13.0(whose
four migrations were all expand / metadata-only), both drop schema and are
irreversible — the deploy requiresALLOW_BREAKING_BLUE_GREEN_MIGRATIONS=1
and a non-emptyBLUE_GREEN_MIGRATION_OVERRIDE_REASON, and it is legal only
on top of a deployed, drained, soakedv0.13.0. - Encrypted DB-only provider credentials (#2079) — a Critical security/ops
change. Xero credential resolution is hard-cut from envXERO_*to an
encryptedIntegrationCredentialstore. On upgrade, an existing
Xero-connected install enters a documented "needs re-entry" state and Xero work
pauses until a Full Admin re-enters credentials in-app and reconnects OAuth.
Its migration is additive/expand, so the release ships two contract
migrations plus one expand migration.
The canonical detailed entries remain in CHANGELOG.md; this page is the
release/operator overview.
⚠️ Do not deploy this release untilv0.13.0(Release A) has been the live,
drained production colour long enough to soak. These migrations drop a table
and three columns that the v0.12.2 colour still named in its SQL. Shipped
against a draining v0.12.2, they cause anonymous public 500s on every page
carrying{{hut-fees}}(42P01 relation "SeasonRate" does not exist), admin
seasons pages 500, Xero item-code saves 500 (42703 column "isMember" does not exist), and age-tier saves plus the boot-time config self-heal failing on
every blue container start — none of it recoverable by rolling the app back.
Thev0.13.0colour names none of them, which is what makes this drop legal
now. If the live colour isv0.12.2or earlier, stop.
Highlights
Release B — legacy contract drops (#2129 step 2, #2130 STEP 2)
20260721120000_contract_drop_season_rate—DROP TABLE "SeasonRate". The
frozen member/non-member boolean-keyed nightly-rate table is dropped. Its rows
were copied forward intoMembershipTypeSeasonRateby the E4 re-key
(20260717140000_pricing_rekey_by_membership_type), and nightly pricing, Xero
hut-fee item codes, and the public{{hut-fees}}embed have all read the new
table since #2129 step 1, so nothing user-visible changes. Release A removed the
last application-runtime reader (the{{hut-fees}}embed); this PR removes the
only survivors, which were seed-only and outsidesrc/(theinclude: { rates: true }read andrates: { create: … }write in
e2e/setup/seed-second-lodge.ts, andcreateMissingSeasonRatesplus its two
call sites inprisma/seed.ts). Because the E4 fan-out that copied those rows
forward was conditional on the install having aMEMBER_RATE-behaviour
membership type and aNON_MEMBER-keyed type, the migration opens with a
pre-drop coverage guard: a read-only anti-join that countsSeasonRaterows
with noMembershipTypeSeasonRatecounterpart for the same season and age tier
and raises, aborting the transaction before theDROP TABLE, if any exist. A
fork whose types never matched keeps its only copy of that pricing instead of
losing it.docs/UPGRADING.mdpublishes the same check as a read-only operator
pre-flight query — run it before you start.20260721130000_contract_drop_ismember_and_agetier_xero_columns. Deletes
the orphaned legacyHUT_FEEitem-code rows that carry nomembershipTypeId
(not resolvable for pricing by the current runtime — production held 16; the
only paths that still touch them count or collect item codes in aggregate and
name no dropped column), drops the old(category, ageTier, seasonType, isMember)unique index, dropsXeroItemCodeMapping.isMember, and drops
AgeTierSetting.xeroContactGroupId/xeroContactGroupName(their data moved
intoXeroContactGroupRuleat E8,20260716140000_xero_member_grouping). The
still-live partial indexXeroItemCodeMapping_hutfee_flat_uniqueis untouched.
This is legal only becausev0.12.2narrowed the reads (#2133 STEP 1) and
Release A narrowed the writes (#2130 STEP 1.5) on both models, so the draining
colour names none of these columns in aSELECTor an implicitRETURNING.
Both migrations carry full rationale rows in
docs/BLUE_GREEN_MIGRATION_SAFETY.tsv
(previous_expand_release = 20260717140000_pricing_rekey_by_membership_type,
old_code_compatible = yes), and both refuse to run without the breaking-
migration acknowledgement. The #2130 select guard
(doomed-column-select-guard.test.ts) is kept even though its original columns
are gone — narrow selects remain the rule for both models.
Admin theming
- The admin area now follows the club's saved site colours in light mode
(#2144). A sweep of 1,410 class occurrences across the admin tree moved
hard-coded light-grey ("slate") Tailwind colours onto the same semantic theme
tokens the finance dashboard has used since #2137, so a club with a strongly
non-default palette now sees it applied consistently across admin. Dark mode
is visually unchanged for ~98% of occurrences (90.6% land on the exact token
the existing.darkneutral remap already assigned — a provable dark-mode
no-op — and a further 7.3% land on a token that renders identically today);
~2% genuinely change dark rendering, chiefly small deliberate dark-mode fixes on
admin surfaces the remap never covered plus four occurrences on the public
hut-leader instructions page (which renders underwebsite-theme). In light
mode there are two deliberate changes: the five grey text tints collapse onto
the single AA-clampedtext-muted-foregroundtone (an accessibility
improvement), and recessed panels usebg-mutedwhile cards usebg-card
(the finance precedent) so insets stay visibly recessed. A widened
source-contract test now gates the whole admin tree (plus finance) against raw
neutral classes, with a nine-entry per-file allowlist covering the deliberate
literal-colour exclusions (print pages, signage letterboxes, code-preview panes,
status chips, the member-import active-step border).
Encrypted DB-only provider credentials (#2079)
- New encrypted credential store. The additive migration
20260721210000_add_integration_credentialcreates one standalone
IntegrationCredentialtable holding AES-256-GCM ciphertext under a key derived
by real HKDF-SHA256 from the canonicalgetAuthSecret()resolver (fixed
documented salt, versioned info labels), with a fresh random IV per encrypt and
GCM AAD (provider:key:labelVersion) binding every ciphertext to its row. A
secretSourcefield makes a silentAUTH_SECRET↔NEXTAUTH_SECRETflip
diagnosable (still decrypts, flagged), while a changed secret value fails
cleanly into "needs re-entry" rather than crashing. - Hard cutover of Xero resolution to the store.
getOperationalXeroConfig(),getOperationalXeroEncryptionKey(), and the new
getOperationalXeroWebhookKey()resolve from the store; envXERO_CLIENT_ID/
XERO_CLIENT_SECRET/XERO_REDIRECT_URI/XERO_ENCRYPTION_KEY/
XERO_WEBHOOK_KEYare no longer read for operation — legacy values are
detected and flagged for removal in setup readiness, never silently honoured and
never silently ignored. The webhook route stays fail-closed (missing/unreadable
key rejects every delivery), and the OAuth redirect URI now derives from
NEXTAUTH_URL. - Capture, cache, and gating. A 45 s cross-process credential cache
(invalidated on write; negatives and DB errors never cached beyond the TTL) lets
the cron-leader observe a web-slot write without a restart. An AUTH_SECRET
strength gate hard-blocks credential capture on a weak secret (< 32 chars, or a
placeholder — a blocklist that catches the 41-char.env.exampleliteral),
shows a passive amber readiness warning from day one, and imposes no boot-time
enforcement. Writes go through a write-only, Full-Admin-only API (values never
returned, metadata-only audit; area admins keep a metadata-only status GET), with
an interim Xero Credentials entry section on/admin/xero/setup.
Verify-reset: a client-credential write drops stored OAuth tokens; a
webhook-key write re-arms verification without dropping tokens. - Truthful reconnect after upgrade. Tokens previously wrapped under the dropped
XERO_ENCRYPTION_KEYbecome unreadable by design (no silent key import); a typed
XeroTokenDecryptErrormaps to the reconnect state so the admin status panel,
setup readiness, and finance-report messaging show "reconnect Xero" instead of a
false "Connected". The credential entity is excluded from configuration export,
the blue/green deploy script no longer hard-requires the droppedXERO_*vars
(warn-only), docker-compose no longer plumbs them, and the.envexamples were
rewritten.
Compatibility and operator impact
- Runtime requirements remain Node.js 24 LTS, npm 11+, and PostgreSQL 16.
- The release contains three migrations: two destructive
contractmigrations
(Release B) plus one additive/expand migration (#2079). The two Release B
contract migrations are bothold_code_compatible=yesand ledgered with
previous_expand_release = 20260717140000_pricing_rekey_by_membership_type;
20260721210000_add_integration_credentialis a purely additive standalone
table (no ledger row required — it matches no hot-table or breaking regex). The
blue/green validator refuses a destructive migration without the operator
acknowledgementALLOW_BREAKING_BLUE_GREEN_MIGRATIONS=1plus a non-empty
BLUE_GREEN_MIGRATION_OVERRIDE_REASONrecording thev0.13.0soak — set both
for this deploy only, then unset them so the next deploy does not inherit the
override. - Xero enters a "needs re-entry" state immediately after cutover (#2079). With
envXERO_*no longer read for operation, the previously stored OAuth tokens
(wrapped under the droppedXERO_ENCRYPTION_KEY) become unreadable by design and
the legacy env values are flagged for removal, not honoured. Nothing
crashes — at boot, cron, webhook, or page load — but Xero sync, webhook
verification, and invoice/payment automation are fail-flagged and paused until
a Full Admin re-enters the client id, client secret, and webhook key in
Admin → Xero → Setup and reconnects OAuth. Follow the per-provider re-entry
order inDEPLOYMENT.md→ "Provider credentials: DB-only upgrade". Credential
capture is hard-blocked unlessAUTH_SECRET(orNEXTAUTH_SECRET) is strong
— at least 32 characters and not the.env.exampleplaceholder — so confirm the
secret before you start; setup readiness shows a passive amber warning until it
is met. - Credentials at rest — trust concentrates in the auth secret (#2079). A
database backup plus the auth secret decrypts every stored provider credential,
so treat the auth secret with the same care as the database, and production and
staging/clones must never share an auth secret — a restored clone is expected
to fail decryption and enter re-entry, which is the correct outcome, not a bug.
Seedocs/SECURITY-ATTACK-SURFACE.md→ "Credentials at rest". - This is legal only on top of a deployed / drained / soaked
v0.13.0.
Confirm the running image/tag isv0.13.0, not just what merged. Against a
drainingv0.12.2(or earlier) the drops break the old colour — see the boxed
warning above. - A fresh, restore-tested backup is mandatory. There is no down-migration:
aDROP TABLEand aDROP COLUMNcannot be undone by rolling the app back, so
restore from backup is the only recovery for the dropped data. - Pre-flight the
SeasonRatecoverage check with the read-only query in
docs/UPGRADING.md→ "Before deploying Release B". Zero rows means you are
clear. Any rows returned name seasons and age tiers whose rates exist only
in the table about to be dropped — reconcile them as per-membership-type rates
(Admin → Seasons & Rates) and re-run until the query is empty. The migration
carries the same check as an aborting guard, so skipping this fails the deploy
safely rather than losing data — but mid-deploy is a worse place to discover it.
Do not delete the orphaned rows or edit the guard out. - No special traffic window and no provider call. Both are cold admin-only
config tables absent from the hot-table regex:DROP TABLE,DROP INDEX, and
DROP COLUMNare metadata-only catalog changes taking a briefACCESS EXCLUSIVElock each, and the row delete touches a handful of rows. Neither
migration contacts Xero — no contact, contact group, item, or invoice is
touched. - The Release B drops are invisible and need no reconfiguring — they remove
only structures the current runtime no longer reads — and #2144 is a light-mode
theming refinement with no schema, permission, or behaviour change. The one
operator-visible change this release is the #2079 Xero re-entry above.
Upgrade and verification
Follow docs/UPGRADING.md from v0.13.0 to v0.13.1 ("v0.13.0 → v0.13.1"). In
summary: confirm v0.13.0 is the deployed, soaked colour; take and restore-test a
fresh backup (mandatory — the drops are irreversible without it); pre-flight
the SeasonRate coverage query until it returns zero rows; set
ALLOW_BREAKING_BLUE_GREEN_MIGRATIONS=1 with a
BLUE_GREEN_MIGRATION_OVERRIDE_REASON recording the soak and backup id; deploy in
the normal window; then unset the override. Then complete the #2079 Xero
re-entry — confirm the auth secret is strong, re-enter the Xero credentials in
Admin → Xero → Setup, reconnect OAuth, and remove the now-ignored XERO_*
env vars (per DEPLOYMENT.md).
After cutover, verify at minimum:
- Migrations applied cleanly. Both contract migrations ran (the coverage
guard did not fire), theIntegrationCredentialtable exists, and the app is
healthy on the new colour. - Xero credential re-entry (#2079). Setup readiness shows the reconnect
prompt until you re-enter credentials; after re-entering and reconnecting, Xero
sync and webhook verification resume and the legacy-env warning clears once the
oldXERO_*vars are removed. - Hut-fee pricing unchanged. Quote a member and a non-member booking and
confirm the totals and item codes match what you saw before the deploy — they
should be identical, since the migration removes only structures nothing reads. - Xero member grouping still resolves. Visit the member-grouping admin page
and run its dry-run; grouping has been driven byXeroContactGroupRulesince
E8 and the droppedAgeTierSettingcolumns were dead copies. - Public
{{hut-fees}}embed renders. If you use it, confirm the publicly-
listed membership types still produce the expected columns. - Admin theming (#2144). An admin screen under a strongly non-default club
palette follows the club colours in light mode, and dark mode is unchanged.
This release does not publish an npm package (package.json remains private).
The supported artefacts are the Git tag/GitHub release and the repository's
container-image release workflow.