115 Commits

Author SHA1 Message Date
50da92c1fb chore: require SummerCMS v0.5.0 v0.1.11 2026-10-24 06:32:47 +02:00
0428093d33 test: start the link request before minting the session in TestBackendLink
Go evaluates call arguments left to right, so the two subtests minted the
frontend session before the administrator created the link request. Across a
second boundary (a loaded host) the confirm correctly answered 409
stale_session, because the sign-in predated the request.
2026-10-23 21:45:47 +02:00
98924041c9 docs: mark MintAPIToken deprecated in favour of MintAPITokenFor 2026-10-23 21:16:47 +02:00
ebe408f542 test: an organisation delete removes the blobs, Postgres tests skip under -short
TestOrganisationLogo asserted only that the system_files rows go; it now
also waits for the commit and asserts the logo's and avatar's originals and
thumbnails are gone from the bucket. The Postgres tests of the gap closure
call t.Skip under -short instead of returning, so -short runs report them
as skipped, not passed.
2026-10-23 21:14:37 +02:00
9730110d55 fix: the avatar and logo URL fallback follows the configured public path
When the thumbnail fails, AvatarURL and OrganisationLogoURL answered
"/" + the blob key, which skips storage.uploads.public_path_prefix and
public_url (a 404 with the WinterCMS layout). They now answer the
original's attach.File.URL.
2026-10-23 21:11:04 +02:00
b929ba9f33 docs: state the one-second blind spot of the account link's auth_time proof 2026-10-23 21:08:12 +02:00
92676ba14e fix: the backend link show and confirm POSTs ignore a code in the query string
readFields falls back to r.Form, which includes the query string, so
POST /backend-link/show?code=... and /confirm?code=... worked and put the
code into access logs. Both routes now read the code from the body only
(JSON or a form body).
2026-10-23 21:08:00 +02:00
30f7a01d3d fix: a self-service password change revokes every OAuth grant, however recently refreshed
ChangePassword fired the access-revoked event with Before = the calling
session's iat, so an OAuth/MCP grant whose client refreshed it after that
instant kept working. Before is now the instant of the request; the stored
tokens_valid_after still keeps the calling session.
2026-10-23 20:48:38 +02:00
38551a4104 fix: organisation attachment cleanup runs in FormAfterDelete
FormBeforeDelete keeps its declaration and only releases the members; the
avatar and logo rows are deleted by a new FormAfterDelete hook in the same
transaction, their blobs after the commit. Keeps the plugin additive
(no changed exported method declaration).
2026-10-23 18:40:59 +02:00
e3c164fdf0 test: named tests for the account link proof, revocation, member token scopes and the organisation logo
- TestMemberTokenScopes walks E-2's acceptance list item by item, plus the
  per-application catalog and the legacy MintAPIToken fallback;
  TestMemberTokenScopesBoot (no Docker) the boot refusals
- TestBackendLinkAuthTime (C-CR-02), TestBackendLinkShowPost (C-WR-01,
  C-IN-06), TestAccessRevocation (C-CR-01), TestUserMCPGuardClientIP
  (C-IN-04), TestAPITokenRevokeEvent (C-IN-02), TestOrganisationLogo (E-1)
- README Testing section names them
2026-10-23 18:36:39 +02:00
25b9f2e35c fix: deleting an organisation removes its avatar and logo attachments
The Organisations screen's delete left the organisation's system_files
rows and blobs behind. FormBeforeDelete now deletes them in the delete's
transaction (attach.DeleteForOwner) and removes the blobs once it
commits, as the user delete does.
2026-10-23 18:22:01 +02:00
Jakub Zych
f20a8d3b98 feat: organisation logo (attachOne logo) and payload helpers
- Organisation gains the public attachOne logo (OrganisationLogoField),
  editable as an image upload on the Organisations form; avatar unchanged
- OrganisationLogoURL and OrganisationLogoFields give applications the
  has_logo, logo and logo_url keys for their organisation payloads
- no migration: attachments live in system_files
2026-10-23 17:03:13 +02:00
Jakub Zych
ee5c096d0d feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)
- CompileAPITokenScopes builds the catalog from the built-ins and the
  activated plugins' declarations; a bad name, a redeclared built-in, a
  duplicate, an empty label or a missing phrase key fails boot
- POST /tokens, user:token:create and MintAPITokenFor check the
  application's own catalog (422 text unchanged); MintAPIToken keeps its
  signature with a process-wide fallback for declared scopes
- GET /_user/api/v1/tokens/scopes lists the built-ins and the declared
  scopes with translated labels
- built-in scope labels in en and pl; TestMemberTokenScopesSmoke
2026-10-23 17:00:12 +02:00
Jakub Zych
970719ce75 fix: access-revoked event on ban and password changes, banned users lose their link, token-specific MCP revoke
- AccessRevokedEvent and FireAccessRevoked: fired in the write's transaction
  by the ban action (newly banned users only), the password reset, an
  administrator setting a password and the self-service password change;
  a revoked subject event per user after commit
- LinkedBackendUser answers no administrator for a banned user
- FireSubjectEvent / FireSubjectEventAfterCommit carry TokenID or ClientID;
  DELETE /tokens/{id} ends only that token's MCP sessions
- the MCP personal-token path stamps last_used_ip from towel.ClientIP
- BackendLinksFor replaces the duplicate lookups; requestUsable uses
  IsUsableAt
2026-10-23 16:47:56 +02:00
Jakub Zych
32f92591d6 fix: account link proof by auth_time, code in the URL fragment and POST show
- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
  a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
  /backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
  golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
2026-10-23 16:38:55 +02:00
Jakub Zych
72de822a84 test: backend link, frontend permission catalog and MCP guard matrices (phase 19.7 plan 05)
- TestBackendLink: the fresh sign-in rule at the second, 1:1 on the
  frontend side, the show route's three keys, SetLink and RemoveLink events
  (none after a rollback), deactivated or deleted administrators, and
  deactivating the frontend user
- TestFrontendPermissionCatalog: a second plugin's duplicate and an empty
  code dropped; a group saved with a declared code grants it
- TestUserMCPGuard: parser refusals, a soft-deleted user, an expired token,
  and the dispatch credential against the route's own user.scope:read
2026-10-23 14:09:36 +02:00
Jakub Zych
bfcfea4ed7 chore: require SummerCMS v0.4.4 v0.1.10 2026-10-23 12:25:46 +02:00
Jakub Zych
59cc3bde2e docs: account link, Administrators actions, plugin frontend permissions, MCP guard and user_me 2026-10-23 12:20:50 +02:00
Jakub Zych
dbb3d33b48 fix: user groups delete keeps FormAfterDelete's declaration; members' subject events fire from FormBeforeDelete
The member ids are read in the delete's transaction before the memberships
go, and the permissions events fire after it commits, as before; the
smoke now covers a group delete.
2026-10-23 12:16:53 +02:00
Jakub Zych
6f1cb1a774 feat: link and unlink an administrator's frontend account on the Administrators screen
- golem15.users.manage_backend_links (no default role), en and pl labels
- record actions on summercms.cabana.users: link_frontend_account asks for
  the frontend user's email (exact, case-insensitive, active users only),
  records linked_by and fires the link events; unlink_frontend_account
  (danger) removes the link
- a taken or unknown account answers 422 on the prompt
2026-10-23 12:14:21 +02:00
Jakub Zych
0f545f5c81 feat: user MCP guard provider, plugin frontend permissions and the user_me tool
- classes.FrontendPermissionCatalog: the seeded rows, then codes activated
  plugins declare through pact.HasFrontendPermissions (seeded row wins);
  both permission editors offer it
- classes.MCPUserGuard (guard "user"): reads user.api_token, user.scope:*
  and user.permission:*, allows with the route's checks (session scopes,
  group grants, every code), dispatches with an in-memory token credential,
  resolves usr_ personal tokens; registered in *pact.MCPGuards and published
  as pact.MCPPersonalTokenResolver
- GET /_user/api/v1/token/me (user.api_token, user.scope:read) and the MCP
  tool me (user_me)
- pact.MCPSubjectChanged on token revoke, Users saves and User Groups saves
  and deletes
2026-10-23 12:08:38 +02:00
Jakub Zych
7ac80c9aea feat: verified frontend account link for administrators
- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
2026-10-23 12:03:26 +02:00
Jakub Zych
3828a65481 feat: golem15.user.password.public_routes false leaves POST /login, /forgot-password and /reset-password unregistered, so they 404 like any unknown path
The key defaults to true, so the route table is unchanged. False skips only
those three registrations: they get the router's own 404, with no custom
handler and no framework change. /register keeps allow_registration and the
turnstile guard. The key is read at boot, and a value other than true or
false fails boot like golem15.user.turnstile.
v0.1.9
2026-10-23 00:09:38 +02:00
Jakub Zych
8bdf2c7d1a feat: opt-in golem15.user.jwt.token_cookie sets the HttpOnly session cookie on /login, /register and /activate-by-code instead of returning the token
Default off, so body-token clients are unchanged. When on, the cookie carries the auth.cookie attributes via bouncer.LoadCookieConfig and the body keeps user (and message); SameSite stays Lax, with no CSRF token; personal API tokens are untouched.
2026-10-22 23:44:44 +02:00
Jakub Zych
846087db45 fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
2026-10-22 23:39:18 +02:00
Jakub Zych
a484d0e9a2 fix: reset-password and the admin password set stamp tokens_valid_after in whole seconds, so a token minted in the same second is accepted
JWT iat is whole seconds and every cutoff check is iat before tokens_valid_after, so a sub-second cutoff refused the login that followed a reset within the same second. POST /reset-password and the admin Users password set now truncate the cutoff to the second, like RevokeUserAccess; email_verified_at keeps full precision. Change-password and bouncer's comparison are unchanged.
2026-10-22 22:56:38 +02:00
Jakub Zych
b6f8055c56 fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register
Behind a reverse proxy every visitor had the proxy's RemoteAddr, so one client's failed logins locked the account for all. The throttle key is now surf.ClientIP with http.trusted_proxies, the expression /register uses; X-Forwarded-For from an untrusted peer is still ignored. RemoteIP is unchanged.
2026-10-22 22:53:29 +02:00
Jakub Zych
4bf44fc6ea fix: logout revokes the session under serve: the jwt blacklist and GORM hooks are wired when the database is published
Boot runs before summer serve publishes the database, so the plugin's GORM
hooks, the Postgres jwt_blacklist store and its sweep now go through
lagoon.OnDatabase, and the jwt guard resolves the published store on every
request. A logged-out token is refused on jwt.auth, /fetch and /refresh.

The jwt guard reads the cookie names from classes.SessionCookieNames().
2026-10-22 22:29:59 +02:00
Jakub Zych
8e4b863a3f fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads
The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard.
A bearer header still wins and no credentials stays 401 with the old bodies.
A cookie-carried /refresh answers the rotated token in the body and writes no cookie.
2026-10-22 21:45:52 +02:00
Jakub Zych
15ff9793ba fix: login refuses a not-activated account while require_activation is on
The refusal is the same 401 invalid-login answer as a wrong password and is counted by the login throttle.
The activation is checked after the password and before the deactivation restore, so a refused account stays deactivated.
require_activation false keeps the previous behaviour.
2026-10-22 21:25:53 +02:00
Jakub Zych
930b1587a0 chore: require SummerCMS v0.4.3
The organisation role cleanup uses cabana's ClearOnChange, released in v0.4.3.
v0.1.8
2026-10-22 10:42:22 +02:00
Jakub Zych
358ee5d308 fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the
jwt guard also accepts the token and auth_token cookies. A session carried
by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that
the browser cannot turn into a bearer header) was answered 401, nothing was
revoked and the cookie kept authenticating jwt.auth routes until the access
token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a
cookie either, and it and EndRequestSession kept the jwt_blacklist row only
until the access token's exp, so once the sweep removed the row POST
/refresh revived the logged-out token for the rest of its refresh window.

classes.EndSession, which Logout now runs, revokes every session token the
request carries (the bearer header, the token and auth_token cookies) that
verifies, an expired one too while it could still be refreshed, until the
later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh
and the admin logout do. The answer, a 401 included, expires the
auth.cookie token cookie and any other session cookie the request carries,
with that cookie's path, domain, SameSite and Secure. EndRequestSession is
its best-effort form. Response bodies are unchanged; an expired but still
refreshable token now logs out with 200 instead of 401.
2026-10-22 04:51:36 +02:00
Jakub Zych
aa8396916e fix: clear a stale organisation_role when a user's organisation changes in the admin
The members relation manager and the organisation field of the Users form
declare cabana ClearOnChange on organisation_role: adding or removing a
member, and a form save that moves a user to another organisation or to
none, clear the role unless the same save writes it. Needs a SummerCMS
release with ClearOnChange.
2026-10-22 03:33:12 +02:00
Jakub Zych
c3712dc0d4 fix: deactivate and ban revoke sessions and API tokens, and /refresh refuses deactivated, banned and cut-off subjects
classes.RevokeUserAccess stamps users.tokens_valid_after (whole seconds) and
revokes the users' personal API tokens; DeactivateUsers and BanUsers call
it for every selected user. Restore and unban revive nothing, and a
deactivated user who signs in again gets a fresh working token.

POST /refresh now loads the token's subject after bouncer.Refresh and
refuses a missing, deactivated or banned user and a token issued before
tokens_valid_after with the existing 401 body; a refused refresh stamps no
last_seen. Suspension is deliberately not checked.
v0.1.7
2026-10-22 03:11:32 +02:00
Jakub Zych
e0b5ffe20d fix: log /register listener failures instead of discarding them
A failing user.Register or user.Created listener is logged at Error level
with the user ID; the committed registration still succeeds.
2026-10-22 03:07:22 +02:00
Jakub Zych
b6b7ac7534 fix: registration fills only an explicit allow-list and never takes the IP columns from the body
User.RegisterFillable lists what a registration body may set: name,
surname, email, password, preferred_locale and marketing_consent.
RegisterUser fills from it instead of Fillable, so organisation_id,
organisation_role, is_onboarded and must_change_password can no longer be
mass-assigned through POST /register, and created_ip_address and
last_ip_address come only from RegisterOptions.IP. Fillable is unchanged
for the admin form and trusted application code.
2026-10-22 03:03:18 +02:00
Jakub Zych
499b6dc408 test: migration rollback counts include the email_verified_at migration
The rollback tests of user groups, extend-users, throttle and jwt blacklist
stopped one migration short since 202610210001 was added.
2026-10-22 03:02:08 +02:00
Jakub Zych
9986342c23 feat: console help, examples and named loggers (SummerCMS v0.4.0)
Every command gets long help and examples, descriptions in imperative
sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs.
The plugin logs through a diary component logger instead of the process
default logger. Requires SummerCMS v0.4.0; command names and flags unchanged.

user:token:create and user:token:list gain --json, user:backfill-handles a
progress bar; planning IDs leave the descriptions.
v0.1.6
2026-10-21 18:03:50 +02:00
Jakub Zych
35cf16689d test: email_verified_at writers, non-writers and the migration
- TestEmailVerifiedAt: register (auto, admin), activate, activate-by-code, reset-password, change-password, update
- TestAdminEmailVerifiedAt: admin create, activate action, invitation link, email change, JSON
- TestEmailVerifiedAtMigration: column shape, no backfill, existing column kept, rollback keeps column
v0.1.5
2026-10-21 14:01:52 +02:00
Jakub Zych
a99cb467b7 fix: an email change drops the address proof
- /update clears email_verified_at in the same write when the email changes (case-only changes keep it)
- the admin Users form clears it in FormBeforeUpdate; storedEmail helper shared with guardCredentials
- README: the proof follows the address, never mass-assigned, no backfill
2026-10-21 14:01:52 +02:00
Jakub Zych
add1f7a806 feat: users.email_verified_at records a proved address (mail activation, reset code)
- additive migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no backfill, no-op rollback)
- User.EmailVerifiedAt (json:"-", not fillable)
- VerifyActivationCode and ResetPassword stamp it in the write that activates or stores the password
2026-10-21 13:53:19 +02:00
Jakub Zych
c7635c56e3 test: change-password still requires the current password (PHP map)
TestChangePassword/real_password_account_still_requires_current_password
ports the PHP OAuthPasswordBootstrapTest case: a missing or wrong
current_password is a 422 with the unchanged body, and the password stays.
2026-10-21 01:23:13 +02:00
Jakub Zych
d6d8f117ea test: provider source, unlink rule and race, sign-in helpers
- TestOAuthProviders: old bytes without a source, order, per-request lookup
- TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s,
  microsoft by default, source names and opts.Providers precedence
- TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders,
  the handler waiting for the lock, concurrent handlers
- TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
2026-10-21 00:49:00 +02:00
Jakub Zych
c43ba87371 fix: user:backfill-handles opens its own database
- without a published *gorm.DB the command opens one with lagoon.OpenFromApp
  for its run and closes it, as the plugin's other commands do (withDB)
- TestBackfillHandlesOpensFromConfigWhenUnpublished; README CLI note
v0.1.4
2026-10-20 23:07:14 +02:00
Jakub Zych
174161895e fix: atomic unlink with the PHP last-identity rule
- OAuthIdentitiesDestroy locks the users row (SELECT ... FOR UPDATE) and runs
  the PHP guarded DELETE in one transaction: a self-set password or another
  identity allows the unlink; otherwise 409 with the unchanged body
- the default allow-list adds microsoft, and an OAuthProviderSource's known
  names are used when the host passes no Providers
- TestOAuthIdentitiesDestroyLastIdentityWithPassword (204),
  TestOAuthIdentitiesDestroyConcurrentSmoke; README
- gofmt of the forgotPasswordBody line touched in the previous commit
2026-10-20 23:05:14 +02:00
Jakub Zych
78a867bccb feat: provider source for oauth-providers and sign-in helpers for social login
- classes.OAuthProvider and classes.OAuthProviderSource; GET /oauth-providers
  looks the source up per request and keeps the empty body without one
- exported RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP
- RegisterOptions.PasswordLess (has_self_set_password=false) and FinishLogin
  (restore + reactivate mail, last_ip_address, last_seen; no event)
- TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke; README
2026-10-20 23:02:37 +02:00
Jakub Zych
1fc0ce7ed0 test: TestTurnstileRoutes pins golem15.user.turnstile per mode
Replaces TestTurnstileRoutesSmoke: empty adds nothing; open, closed and a
space-padded closed add turnstile:{mode} to POST login and register only,
every other route keeps its middleware; invalid values fail naming the key.
2026-10-20 19:19:36 +02:00
Jakub Zych
7b9faa3419 feat: opt-in Cloudflare Turnstile on login and register (golem15.user.turnstile)
- empty (default) adds nothing; open or closed appends turnstile:{mode} to
  POST /login and /register only; any other value fails boot naming the key
- TestTurnstileRoutesSmoke records the declared routes with a recording router
v0.1.3
2026-10-20 14:45:22 +02:00
4c5d3b8935 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	plugin.go
v0.1.2
2026-10-19 22:35:54 +02:00
9335e80342 test: RequestUser, EndRequestSession, AvatarURL and user.Deactivating
- TestRequestUser: bearer, token and auth_token cookies in guard order;
  expired, foreign, blacklisted, revoked by tokens_valid_after and
  deactivated tokens are anonymous; only infrastructure failures error
- TestEndRequestSession (no database): jti blacklisted until the token
  expires, forget cookie with the configured Domain and Path, best effort
- TestAvatarURL: thumbnail, broken-image and storage-failure fallbacks,
  gravatar with the size, the user payload's avatar_url unchanged
- TestDeactivatingEvent: one event per newly deactivated user inside the
  bulk action's transaction, a listener error rolls the action back
2026-10-19 21:40:41 +02:00