feat(user-core): dunite-user-core shared account vocabulary (DEV-517) #31

Merged
longjacksonle merged 1 commit from feat/DEV-517-dunite-user-core into main 2026-08-04 02:23:04 +02:00

DEV-517 under the DEV-495 epic. a8n-tools and bunyip grew the same account model independently, and the vocabulary half of it is byte-identical on both sides. This crate is the single copy.

What moves in

  • UserRole, MembershipStatus, SubscriptionTier with their as_str + From<&str> / From<String> round-trips (identical in both repos today).
  • The pure tier decisions bunyip had already extracted: select, trial_days, trial_label, display_name. They are functions of the admin-configured slot counts and trial windows, no database or clock involved.
  • TierConfigResponse, the tier-settings read model (api/src/models/tier.rs and crates/bunyip-domain/src/models/tier.rs diff to nothing today).

What deliberately stays per-app

  • The User row struct and UserRepository. The schemas have drifted: bunyip carries last_login_country, login_location_alerts, first_name, last_name, phone, has_used_trial, avatar_updated_at, is_super_admin; a8n has no column for any of them. Sharing the row means migrating a8n's users table first, so that reconciliation is its own piece of work. (is_access_allowed is identical on both sides but lives on the row, so it waits for the same reconciliation.)
  • Anything deriving sqlx::FromRow. a8n is on sqlx 0.7, bunyip on 0.8; a derive from one major does not satisfy the other's traits, so a shared row type would be unusable by one consumer. That is why TierConfigRow stays local while the plain-serde TierConfigResponse is shared. The crate depends on serde, chrono and uuid only.

Tests

12 unit tests. The interesting ones pin the fallback direction of each From<&str>, which is a security property rather than a formatting detail: an unrecognised role degrades to Subscriber (never Admin), an unrecognised status to None (never an access-granting variant), an unrecognised tier to Standard (never one of the free tiers). Plus the access predicate (grace period grants, past-due does not) and the exclusive < boundary in select that pins the long-vs-short trial cutover.

Consumer PRs in bunyip and a8n-tools follow.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CGnY8JKXCgH2jiUSqH6g4x

DEV-517 under the DEV-495 epic. a8n-tools and bunyip grew the same account model independently, and the vocabulary half of it is byte-identical on both sides. This crate is the single copy. ## What moves in - `UserRole`, `MembershipStatus`, `SubscriptionTier` with their `as_str` + `From<&str>` / `From<String>` round-trips (identical in both repos today). - The pure tier decisions bunyip had already extracted: `select`, `trial_days`, `trial_label`, `display_name`. They are functions of the admin-configured slot counts and trial windows, no database or clock involved. - `TierConfigResponse`, the tier-settings read model (`api/src/models/tier.rs` and `crates/bunyip-domain/src/models/tier.rs` diff to nothing today). ## What deliberately stays per-app - **The `User` row struct and `UserRepository`.** The schemas have drifted: bunyip carries `last_login_country`, `login_location_alerts`, `first_name`, `last_name`, `phone`, `has_used_trial`, `avatar_updated_at`, `is_super_admin`; a8n has no column for any of them. Sharing the row means migrating a8n's `users` table first, so that reconciliation is its own piece of work. (`is_access_allowed` is identical on both sides but lives on the row, so it waits for the same reconciliation.) - **Anything deriving `sqlx::FromRow`.** a8n is on sqlx 0.7, bunyip on 0.8; a derive from one major does not satisfy the other's traits, so a shared row type would be unusable by one consumer. That is why `TierConfigRow` stays local while the plain-serde `TierConfigResponse` is shared. The crate depends on serde, chrono and uuid only. ## Tests 12 unit tests. The interesting ones pin the fallback direction of each `From<&str>`, which is a security property rather than a formatting detail: an unrecognised role degrades to `Subscriber` (never `Admin`), an unrecognised status to `None` (never an access-granting variant), an unrecognised tier to `Standard` (never one of the free tiers). Plus the access predicate (grace period grants, past-due does not) and the exclusive `<` boundary in `select` that pins the long-vs-short trial cutover. Consumer PRs in bunyip and a8n-tools follow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CGnY8JKXCgH2jiUSqH6g4x
feat(user-core): add dunite-user-core shared account vocabulary (DEV-517)
All checks were successful
Check / fmt + clippy + test (pull_request) Successful in 1m20s
create-release / create-release (pull_request) Has been skipped
82cfbe0364
a8n-tools and bunyip grew the same account model independently and it is byte-identical on both sides: `UserRole`, `MembershipStatus` and `SubscriptionTier` with their string round-trips, plus the `TierConfigResponse` read model. This crate is the single copy; the consumers re-export it so their `models::user::*` / `models::tier::*` paths are unchanged.

The tier decisions that bunyip had extracted (`select`, `trial_days`, `trial_label`, `display_name`) come along, since they are pure functions of the admin-configured slot counts and trial windows.

Two deliberate exclusions. The `User` row struct and `UserRepository` stay per-app: the schemas have drifted (bunyip carries login-location, avatar, profile-name, super-admin and trial-usage columns a8n has no column for), so sharing the row means migrating a8n's `users` table first. And nothing here derives `sqlx::FromRow`, because a8n is on sqlx 0.7 and bunyip on 0.8, so a derive from one major does not satisfy the other's traits: `TierConfigRow` stays per-app for that reason while the plain-serde `TierConfigResponse` is shared.

Tests cover the fallback direction of each `From<&str>` (unknown role -> Subscriber, unknown status -> None, unknown tier -> Standard: never a more-privileged or free-er value), the access predicate, and the tier-selection boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGnY8JKXCgH2jiUSqH6g4x
longjacksonle deleted branch feat/DEV-517-dunite-user-core 2026-08-04 02:23:04 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/dunite!31
No description provided.