Updating
Keelmint keeps improving after you buy it. Because your product lives next to core instead of inside it, you can merge a newer release into your repo and keep your own code. Read this before your first update, and again whenever a release note mentions your database.
Set up once
Your product lives in your own repository, cloned from Keelmint, with Keelmint’s private repository keelmint/keelmint as a second remote named upstream (Quickstart shows the commands). If you cloned without renaming, add it now:
git remote add upstream https://github.com/keelmint/keelmint.gitUse a clone, not a GitHub fork: a fork of a private repository is deleted when you lose access to it. Your access to upstream lasts as long as your licence and your seat on it (the developers are managed on the claim page linked from your receipt); code you have already fetched stays yours.
To hear about new releases, open keelmint/keelmint on GitHub and choose Watch → Custom → Releases. Every release is a tag (v1.1.0) with release notes; anything that touches your database is also listed under Notes for existing databases.
Three habits keep merges clean:
- Your code lives in
apps/<product>andlibs/<product>. Do not editlibs/core; if core needs a change, extend it from your product (config, hooks, your own libraries) or ask for the change upstream. - Keep
apps/keelmintandlibs/keelmint. They are the reference product and upstream keeps changing them; deleting them turns every later merge into modify/delete conflicts. To hide Keelmint, don’t serve, deploy or test it (--exclude='keelmint-*'in CI). - Expect conflicts in
README.mdandCLAUDE.mdonce you have edited them: keep your text and pick up what upstream added that you want.
Take a release
git fetch upstream --tagsgit merge v1.1.0 # the release's tag (or upstream/main for the latest)pnpm installThen, for every product <p>:
pnpm nx run <p>-db:compose-schemapnpm nx run <p>-db:migrate-dev # creates your product's migration for any core model changeCommit those migrations, run pnpm nx affected -t lint,test,build and each product’s e2e suite, and deploy as usual (the migrate step applies the new migrations before the API starts).
Notes for existing databases
Fresh installs need none of this. These notes are for databases created by an earlier version of Keelmint.
- Upgrading to English as the default language (
20261013133706_english_language_default): new users and invitations default to English (users.languageanduser_invitations.languagedefault toEN; existing rows keep their language). Your product’s migration: runpnpm nx run <product>-db:migrate-dev, which creates the same twoALTER COLUMN ... SET DEFAULT 'EN'statements. Products that setdefaultLanguageexplicitly see no change at sign-up. - Billing arrived with an empty
organization_subscriptionstable: organizations that existed before it start on the default (free) plan. Give them a plan first: see Billing. - Upgrading to the same-organization triggers (
20261012100000_same_organization): the database refuses a unit under another organization’s unit, and a membership or invitation given another organization’s role or unit (orPLATFORM_ADMIN). Your product’s migration:migrate-devsees no schema change; create an empty one (pnpm prisma migrate dev --create-only --name same_organization --config libs/<product>/db/prisma.config.ts), pastelibs/core/db/prisma/same-organization.sqlinto it and apply it. The triggers check new writes only: run the query below (and its unit and invitation variants) first, and fix any row it finds. - Upgrading to the tenant review fixes (no migration). Behaviour changes to check in your own clients:
- On reporter routes (
/api/support/tickets…) a staff author has noid, and one with no name shows as “Staff”, never their email. GET /api/members?directUnit=from a unit admin, naming a unit outside their subtree, lists nobody.- Nobody takes away more than they hold: changing a member’s roles, removing a member and removing a permission from an organization role answer 403
PERMISSION_NOT_HELDwhen an assignment or permission taken away is one the caller does not hold (a platform admin has no limit). POST /api/organization/billing/checkoutand/portalrefuseX-Organization-Id(400ORGANIZATION_OVERRIDE_NOT_ALLOWED); the Billing tab shows a platform admin only the plan override.- A pending invitation whose inviter left, was removed or deleted their account is a dead link (400
INVALID_OR_EXPIRED_TOKEN). Resending an invitation makes the resender its inviter and needs them to hold what it grants (403PERMISSION_NOT_HELD). - Token responses (login, refresh, organization switch, 2FA, Google) carry
Cache-Control: no-store. - A role assigned in an organization other than its own, or the
PLATFORM_ADMINrole, no longer reaches the access token’s permissions. The API never writes such an assignment; data or fixtures inserted by hand that did must use each organization’s own role (SELECT count(*) FROM membership_roles mr JOIN memberships m ON m.id = mr."membershipId" JOIN roles r ON r.id = mr."roleId" WHERE r."organizationId" <> m."organizationId"should be 0).
- On reporter routes (
- Upgrading to platform admins across organizations (no migration): platform admins no longer have an active organization and work across every organization; everyone gets unit and member filter headers (The platform admin, Tenant data). Behaviour changes to check in your own code and clients:
- Login, refresh, 2FA and Google sign-in give a platform admin
organization: null, whatever their memberships andlastOrganizationId;POST /api/auth/organizationanswers them 403PLATFORM_ADMIN_NO_SWITCH. The client has no switcher, Enter button or no-organization page for them: they land on/users,/organizationsends them to/organizations, and a notification opens without switching. In single mode they now see/organizations(the one organization). - On your
@Entityroutes a platform admin withoutX-Organization-Idreads, updates and deletes rows of every organization (a soft-deleted organization’s stay out);X-Organization-Idalso takes a comma-separated list. Your list pages show them rows of several organizations: return the organization with each row (Keelmint’s todo rows gainorganizationIdandorganizationName,GET /api/todosincluded) and add the level filters (Pages across organizations). - Creates need one organization: a platform admin’s create without exactly one
X-Organization-Idis 400ORGANIZATION_REQUIRED(it was 403 when they had no active organization), and so is any code that callsTenantContext.requireOrganizationId(). Give your create forms the organization picker. An update that namesorganizationIdunder the every-organization scope is refused. X-Filter-Unit/X-Filter-Memberare parsed on every route, for everyone: a malformed value (not uuids, more than 50) is 400VALIDATION_FAILED, and on// @owned/// @unitmodels they narrow reads, updates and deletes. A client that sent headers with these names for its own purposes must rename them.- E2E helpers that switched a platform admin into an organization must send
X-Organization-Idinstead. - The audit log’s sidebar link no longer opens filtered to an organization for a platform admin.
- Login, refresh, 2FA and Google sign-in give a platform admin
- Upgrading to admin pages per level (
20261010090000_admin_levels): addsticket_messages.staffScope(enumTicketStaffScope:PLATFORM,ORGANIZATION) and sets it toPLATFORMon every existing staff message; the built-inORG_ADMINgetsHelpdesk.Read,Helpdesk.ReadOne,Helpdesk.ReplyandHelpdesk.UpdateStatus, andUNIT_ADMINthose four andAudit.Read(once; a permission already held is not repeated; organization roles are left alone). Organization admins then answer their reporters’ tickets and unit admins read their people’s audit events (Admin pages per level). Your product’s migration:migrate-devcreates only the enum and the column; append Keelmint’sUPDATEstatements (the backfill and the two role updates) to it by hand. The backfill keeps the data consistent (a staff message with no scope is read as a platform one, so earlier internal notes and their images stay hidden from organization staff either way); without the role updates, your admins get no helpdesk. Behaviour changes to check in your own clients:GET /api/members?unit=now means members of that unit or its sub-units, and takes several values; the old meaning (linked to that unit itself) isdirectUnit.roletakes several values too.GET /api/auth/organizationslists only the caller’s memberships, platform admins included; a platform admin finds every organization throughGET /api/organizations.GET /api/usersreturns at most fiveorganizationsper user, plusorganizationCount.- The
organizationfilter ofGET /api/audit-eventsandGET /api/helpdesk/ticketstakes several values. - A ticket message’s
authorgainsstaffKindandorganizationName; reporters see “Platform support” instead of “Support”. - The Users page moved from
/admin/usersto/users(the old path and/adminredirect there).
- Upgrading to data ownership (
20261009120000_todo_owned): todos become// @ownedand lose their unit: the migration dropstodos.unitId(with its foreign key and index), and the built-inORG_ADMINandUNIT_ADMINgetTodos.UpdateAnyandTodos.DeleteAny(once; organization roles are left alone), so admins edit and delete their members’ todos.GET /api/todos/unit-optionsis gone. Your own// @unitmodels change too: a plain permission now reaches only the rows of the caller’s direct units, even when held organization-wide, and organization-level (no unit) rows only with an organization-wide*Any; a list that showed an org admin every row needs an@Action('ReadAny')route (orReadOne/Update/Deleteroutes widened by the*Any), and a route without a permission (@Authenticated()) reads none of their rows (Tenant data). A product generated before this version must update its Prisma client factory (libs/<product>/db/src/lib/prisma.client.ts): runpnpm nx run <product>-db:compose-schema(it now writesOWNED_MODELS), calltenantScope(TENANT_MODELS)withoutUNIT_MODELS, and chainownershipScope(OWNED_MODELS, UNIT_MODELS, ownershipLookups(base))right after it in both the main chain andwithDeleted, as in Keelmint’s factory. Dropping the oldUNIT_MODELSargument alone leaves@unitrows unfiltered inside requests. - Upgrading to the role hierarchy (
20261009100000_role_hierarchy): adds the built-inUNIT_ADMIN(a copy ofORG_ADMINwithout the organization-wide-only permissions) andPLATFORM_ADMINroles, andorganizations.defaultRoleId. EveryORG_ADMINassigned in a unit (memberships and pending invitations) becomesUNIT_ADMIN. In Keelmint,TODO_MEMBERassignments becomeUSER(which gets the own-todo permissions) andTODO_SUPERVISORbecomes a role of each organization that used it. From this version the boot no longer resets the system roles to the code’s definitions: it only creates a missing built-in, and the platform admin manages them on/admin/roles. A product that definedsystemRolesin its config: move those roles’ permissions tosystemRolePermissions(USER,UNIT_ADMIN,ORG_ADMIN) or create them as system roles on/admin/roles, and move their assignments with a migration like Keelmint’s. - Upgrading to system and organization roles (
20261008090000_organization_roles): every live role that is notUSERorORG_ADMINand is in use becomes one copy per organization using it, with its assignments and pending invitations re-pointed; unused ones are retired.USERandORG_ADMINbecome system roles, and the first boot sets them to the code’s permissions: anything added to them by hand on the old Roles page is removed (logged as a warning and auditedSYSTEM_ROLE_SYNCED). Before upgrading, checkSELECT name, permissions FROM roles WHERE name IN ('USER','ORG_ADMIN'); give extra permissions back through an organization role. Two live roles whose names differ only in case, used in the same organization, make the migration fail on the new case-insensitive index: rename one first. - Organizations (a database from before multi-tenancy): after upgrading, the built-in
defaultorganization has no org-wide admin: the migration copies each user’s old roles into theirdefaultmembership, and none of those isORG_ADMIN(added by the same migration, held by nobody). TheLAST_ORG_ADMINrule does not trap anyone (an organization that has no admin cannot lose its last one), but until a platform admin opensdefault(Organizations) and assignsORG_ADMINto someone on its Members tab, only the platform admin can manage its members, units, invitations and settings.