Skip to content

v0.13.1

Choose a tag to compare

@thatskiff33 thatskiff33 released this 21 Jul 20:23
321f4ad

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:

  1. 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 the v0.13.0 runtime-prep (Release A) made
    blue/green-legal. They make this release breaking: unlike v0.13.0 (whose
    four migrations were all expand / metadata-only), both drop schema and are
    irreversible — the deploy requires ALLOW_BREAKING_BLUE_GREEN_MIGRATIONS=1
    and a non-empty BLUE_GREEN_MIGRATION_OVERRIDE_REASON, and it is legal only
    on top of a deployed, drained, soaked v0.13.0
    .
  2. Encrypted DB-only provider credentials (#2079) — a Critical security/ops
    change.
    Xero credential resolution is hard-cut from env XERO_* to an
    encrypted IntegrationCredential store. 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 until v0.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.
The v0.13.0 colour names none of them, which is what makes this drop legal
now. If the live colour is v0.12.2 or earlier, stop.

Highlights

Release B — legacy contract drops (#2129 step 2, #2130 STEP 2)

  • 20260721120000_contract_drop_season_rateDROP TABLE "SeasonRate". The
    frozen member/non-member boolean-keyed nightly-rate table is dropped. Its rows
    were copied forward into MembershipTypeSeasonRate by 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 outside src/ (the include: { rates: true } read and rates: { create: … } write in
    e2e/setup/seed-second-lodge.ts, and createMissingSeasonRates plus its two
    call sites in prisma/seed.ts). Because the E4 fan-out that copied those rows
    forward was conditional on the install having a MEMBER_RATE-behaviour
    membership type and a NON_MEMBER-keyed type, the migration opens with a
    pre-drop coverage guard: a read-only anti-join that counts SeasonRate rows
    with no MembershipTypeSeasonRate counterpart for the same season and age tier
    and raises, aborting the transaction before the DROP TABLE, if any exist. A
    fork whose types never matched keeps its only copy of that pricing instead of
    losing it. docs/UPGRADING.md publishes 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 legacy HUT_FEE item-code rows that carry no membershipTypeId
    (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, drops XeroItemCodeMapping.isMember, and drops
    AgeTierSetting.xeroContactGroupId / xeroContactGroupName (their data moved
    into XeroContactGroupRule at E8, 20260716140000_xero_member_grouping). The
    still-live partial index XeroItemCodeMapping_hutfee_flat_unique is untouched.
    This is legal only because v0.12.2 narrowed 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 a SELECT or an implicit RETURNING.

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 .dark neutral 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 under website-theme). In light
    mode there are two deliberate changes: the five grey text tints collapse onto
    the single AA-clamped text-muted-foreground tone (an accessibility
    improvement), and recessed panels use bg-muted while cards use bg-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_credential creates one standalone
    IntegrationCredential table holding AES-256-GCM ciphertext under a key derived
    by real HKDF-SHA256 from the canonical getAuthSecret() 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
    secretSource field makes a silent AUTH_SECRETNEXTAUTH_SECRET flip
    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; env XERO_CLIENT_ID /
    XERO_CLIENT_SECRET / XERO_REDIRECT_URI / XERO_ENCRYPTION_KEY /
    XERO_WEBHOOK_KEY are 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.example literal),
    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_KEY become unreadable by design (no silent key import); a typed
    XeroTokenDecryptError maps 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 dropped XERO_* vars
    (warn-only), docker-compose no longer plumbs them, and the .env examples 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 contract migrations
    (Release B) plus one additive/expand migration (#2079).
    The two Release B
    contract migrations are both old_code_compatible=yes and ledgered with
    previous_expand_release = 20260717140000_pricing_rekey_by_membership_type;
    20260721210000_add_integration_credential is 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
    acknowledgement ALLOW_BREAKING_BLUE_GREEN_MIGRATIONS=1 plus a non-empty
    BLUE_GREEN_MIGRATION_OVERRIDE_REASON recording the v0.13.0 soak — 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
    env XERO_* no longer read for operation, the previously stored OAuth tokens
    (wrapped under the dropped XERO_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 in DEPLOYMENT.md → "Provider credentials: DB-only upgrade". Credential
    capture is hard-blocked unless AUTH_SECRET (or NEXTAUTH_SECRET) is strong
    — at least 32 characters and not the .env.example placeholder — 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.
    See docs/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 is v0.13.0, not just what merged. Against a
    draining v0.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:
    a DROP TABLE and a DROP COLUMN cannot be undone by rolling the app back, so
    restore from backup is the only recovery for the dropped data.
  • Pre-flight the SeasonRate coverage 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 COLUMN are metadata-only catalog changes taking a brief ACCESS EXCLUSIVE lock 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:

  1. Migrations applied cleanly. Both contract migrations ran (the coverage
    guard did not fire), the IntegrationCredential table exists, and the app is
    healthy on the new colour.
  2. 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
    old XERO_* vars are removed.
  3. 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.
  4. Xero member grouping still resolves. Visit the member-grouping admin page
    and run its dry-run; grouping has been driven by XeroContactGroupRule since
    E8 and the dropped AgeTierSetting columns were dead copies.
  5. Public {{hut-fees}} embed renders. If you use it, confirm the publicly-
    listed membership types still produce the expected columns.
  6. 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.