Skip to content

feat!: client plugin contract and the active organization - #168

Merged
juicycleff merged 13 commits into
mainfrom
feat/client-plugin-contract
Nov 10, 2026
Merged

juicycleff merged 13 commits into
mainfrom
feat/client-plugin-contract

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Optional features can now ship as client plugins, the same way they ship as server plugins. You add the client half to your provider and the server half with authsome.WithPlugin, and core knows nothing about either.

This is the second of three PRs, after #167. The next one builds the subscription plugin on top of this.

Using a plugin

<AuthProvider baseURL={url} plugins={[subscription()]}>
  <App />
</AuthProvider>

const billing = usePlugin(subscription);
AuthConfig(baseUrl: url, plugins: [SubscriptionPlugin()]);
final billing = context.auth.plugin<SubscriptionApi>();

A plugin is a name (the server plugin's Name()) and a setup function. Setup gets a small context: an authenticated request, the auth state, the client config, whether the server has the plugin installed, the active organization, and signedIn, signedOut, sessionRefreshed and orgChanged events. We kept it small on purpose, because once plugins version on their own every member is a promise to all of them.

Active organization

Core now knows which organization the session is in:

  • GET /v1/me reports active_org_id, so a reload gets it back.
  • useActiveOrg() in React, and activeOrgId / switchOrg on AuthManager and AuthNotifier.
  • switchOrg keeps the new token a JWT app hands back, runs in turn with token refreshes so it can't undo one, and won't bring back a session that signed out while it waited.
  • Both OrgSwitchers work with no props now: they show the session's organization and switch it. Pass activeOrgId and onOrgChange to keep managing it yourself.

Breaking: <Protect>

role and permission used to render their children for everyone. role now checks the roles /v1/me returns. permission can't be checked in the browser, so without a condition it shows the fallback and warns once. The React guide has a migration note.

Also

  • AuthNotifier rebuilt its config field by field and would have dropped plugins. Fixed.
  • Signing in as someone else without signing out first now fires signedOut then signedIn, so the previous user's organization and plugin caches don't carry over.
  • The Flutter OrgSwitcher's "Create organization" row no longer overflows with a long label.
  • New docs page: Client Plugins, plus active-org sections in the React and Flutter guides. The page uses @authsome/plugin-subscription as its example, which lands in the next PR.

Not in here

  • Dart events are delivered synchronously, so a listener that changes auth state from inside the callback loses the inner event. Nothing does that today.
  • In TS a server outage moves the state to unknown and fires signedOut, while Dart keeps the session and fires nothing. Both are defensible; the docs should say so.
  • Core doesn't ship a fake PluginContext for plugin authors' tests yet.

Checked

GOWORK=off go test ./... passes. In ui, every package builds, type-checks and lints, and the tests pass (core 173, react 25, components 102, nextjs 32). Flutter: authsome_core 67, authsome_flutter 8, authsome_flutter_ui 86, all passing, and dart analyze is clean apart from one existing info in the generated client. Regenerating the clients shows no drift.

GET /v1/me now carries active_org_id, the organization the session has
switched into, read from the session or, for a JWT, from the token's
claims. Clients use it to restore the active organization on reload.
…nization

Optional features can now ship as client plugins, the way they ship as
server plugins. Pass plugins: [subscription()] to AuthManager and look
one up with manager.plugin(subscription). A plugin gets a small context:
an authenticated request (token or cookie), the auth state and client
config, whether the server has it installed, the active organization,
and signedIn, signedOut, sessionRefreshed and orgChanged events.

AuthManager also keeps the session's active organization. It's restored
from /v1/me, set by switchOrg (which keeps a reissued token), and
forgotten on sign-out.
…osed

usePlugin(subscription) returns a registered client plugin's API, and
useActiveOrg() the session's organization with a switchOrg that
re-renders on change. AuthProvider takes plugins through its config.

BREAKING CHANGE: <Protect role> and <Protect permission> used to render
their children for everyone. role now checks the roles /v1/me returns.
permission can't be checked on the client, so without a condition that
checks it Protect shows the fallback and warns once. Pass a condition to
keep a permission-gated Protect working.
…e organization

The Dart side of the ui-core change. Pass plugins: [SomePlugin()] in
AuthConfig and look one up with manager.plugin<SomeApi>(). A plugin gets
a PluginContext with an authenticated request, the auth state, the
client config, whether the server has it installed, the active
organization and an events stream.

AuthManager keeps the session's active organization from /v1/me,
switchOrg changes it (keeping a reissued token), and sign-out clears it.
AuthSomeClient gains send(), the raw request plugins use.
…indings

AuthNotifier passes the configured plugins through to AuthManager (it
used to rebuild the config field by field and would have dropped them),
and gains plugin<Api>(), activeOrgId and switchOrg, notifying on an
organization switch.

Both OrgSwitchers now work on their own: without activeOrgId and
onOrgChange they show the session's active organization and switch it.
Passing the props keeps the old controlled behaviour. The Flutter
switcher's create row also stops overflowing when its label is long.
switchOrg now runs in turn with token refreshes and merges a reissued
access token into the latest stored session, so a refresh that rotated
the refresh token meanwhile isn't undone. It also refuses to restore a
session that signed out, or became someone else's, while it was waiting.

Signing in as a different user without signing out first fires
signedOut and signedIn and drops the previous user's organization. In TS
a second switch no longer replays the org the session was loaded with.
destroy() keeps plugin listeners, so plugins keep hearing events after a
StrictMode remount. Switching to no organization leaves null, not "".

definePlugin types required options as required. Dart's plugin<Api>()
refuses a call without a type argument and an API two plugins share.
OrgSwitcher shows a message (and calls onError) when a switch fails, and
is disabled while one is in flight. The React guide documents Protect
and what changed about role and permission.
@codecov

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.72340% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
sdkgen/cmd/main.go 0.00% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

* feat(plugin-subscription): add the subscription client plugin

A new package, @authsome/plugin-subscription, built only on the client
plugin contract. Register it with plugins: [subscription()] and it gives
you one cached snapshot of the tenant's subscription, whether you may
change it, its entitlements and the plans you can buy, as typed camelCase
models with derived flags (isActive, willCancel, trialDaysLeft).

Writes return the new subscription and update the cache, or a checkout
URL that leaves it alone. It reloads on sign-in and organization switch,
forgets everything on sign-out, drops responses for an organization the
session has left, and reports unavailable without calling the server when
the server has no subscription plugin.

* feat(plugin-subscription): add React hooks and the Entitled gate

useSubscription() returns the subscription, its plan, can_manage and
every write, loading on first use and tracking writes in flight.
usePlans() and useEntitlement(feature) do the same for plans and feature
checks. <Entitled feature> shows its children only on a positive answer:
loading, an error or billing not being installed all show the fallback.

* feat(plugin-subscription): add PricingTable, CurrentPlanCard, UsageMeter and ManageBillingButton

Styled components on @authsome/ui-components primitives, exported from
@authsome/plugin-subscription/components. PricingTable subscribes or
changes plan and hands a checkout URL to onCheckout (redirecting by
default). CurrentPlanCard shows when the plan renews, ends or leaves
trial, seats, and cancel or keep. Buttons only appear for someone who
can manage billing.

* feat(authsome_subscription): add the subscription client plugin for Dart

The Dart half of @authsome/plugin-subscription. Add SubscriptionPlugin()
to AuthConfig.plugins and auth.plugin<SubscriptionApi>() gives you a
snapshot and a change stream of the tenant's subscription, can_manage,
entitlements and plans, as typed models (SubscriptionStatus enum,
DateTime fields, Money with major units and a plain formatter).

Writes return a sealed ChangeResult: SubscriptionChanged with the new
subscription in the cache, or CheckoutRequired with the payment URL. It
follows sign-in, sign-out and organization switches the same way the TS
plugin does.

* feat(authsome_subscription_flutter): add Flutter widgets for the subscription plugin

context.subscription reaches the plugin, SubscriptionBuilder rebuilds
from its snapshot and loads on first build, and EntitlementGate shows
its child only on a positive answer. PricingTable, CurrentPlanCard,
UsageMeter and ManageBillingButton are Material 3 widgets. Opening a
checkout or portal URL is left to the app (onCheckout, onOpen), so the
package doesn't pull in a URL launcher.

* feat(sdkgen): mark tagged operations deprecated in generated clients

A --deprecate-tags "Prefix=note" rule adds @deprecated (TS) or
@deprecated (Dart) to every operation whose tag starts with the prefix.
The embedded ui-core and authsome_core clients use it to point billing
callers at the subscription plugin packages. The standalone SDKs keep
the billing methods as they are, since operators call those routes from
servers.

* chore(sdk): deprecate the billing methods on the embedded core clients

* chore(release): publish the subscription client plugins

The release workflow publishes @authsome/plugin-subscription to npm and
authsome_subscription and authsome_subscription_flutter to pub.dev with
the rest, and the version scripts stamp all three. Trusted publishing
needs a package to exist first, so each one's first release is manual.

Also adds READMEs and changelogs for the three packages, and React and
Flutter sections to the subscription plugin's docs page.

* fix(plugin-subscription): drop stale writes, share loads and tidy the components

A write that lands after an org switch or sign-out no longer overwrites the new tenant's data, and overlapping loads share one request. Prices follow the ledger's currency decimals. The components show plain error copy, mark the highlighted plan in words, and describe unlimited meters to screen readers. Only the React entries carry the use client banner now.

* fix(authsome_subscription): drop stale writes and match the ledger currencies

The Dart plugin now matches the TypeScript one: a write or plan list that lands after an org switch or sign-out stays out of the cache, overlapping loads share one request, and an org switch clears the old org's data while the new one loads. Money uses the ledger's zero-decimal list and formats with integer arithmetic. The Flutter widgets stop spinning when nobody is signed in, show plain error copy with an onError hook, report a missing onCheckout, and label the highlighted plan.

* fix(sdkgen): point operator billing routes at server-side use

Deprecation rules now match on the longest tag prefix, so the operator groups (Billing Plans, Billing Usage and the rest) get their own note telling you to call them from a server, while the tenant Billing routes keep pointing at the subscription plugin. The embedded ui-core and authsome_core clients are regenerated.

* ci(release): note the manual first pub.dev publish
…subscription clients (#172)

* feat: confirm before canceling a plan

CurrentPlanCard asks before it cancels, on both platforms, and says when the plan ends. React does it inline since ui-components has no dialog; Flutter uses an AlertDialog.

* fix(sdkgen): escape deprecation notes for each language

A Dart note is escaped for its single-quoted annotation, and a TypeScript one can no longer close its JSDoc block with */. The --deprecate-tags parser moves into openapi.ParseDeprecationRules and takes \; for a semicolon inside a note.

* feat(plugin-subscription): add a tenant override and warn without a server plugin

api.forTenant(id) returns the same API pinned to another tenant, with its own cache, for operators who hold billing:manage. It keeps its tenant across org switches and clears on sign-out. The hooks and components take a tenantId. When the server doesn't have the subscription plugin, the client now says so once in the console (not in production) instead of quietly reading as unavailable.

* feat(authsome_subscription): add a tenant override and warn without a server plugin

The Dart plugin gets forTenant(id) like the TypeScript one, and debug builds print one warning when the server doesn't have the subscription plugin. Every Flutter widget takes a tenantId, and context.subscriptionFor(id) returns that tenant's API. The docs cover the override, the warning and the cancel confirmation.
main picked up the earlier feat/client-plugins branch as #170, an older copy of this PR's plugin contract. Its sources match this branch's, so the one conflict (api/me_active_org_test.go) keeps this branch's version, which adds the JWT case. The generated spec and clients were regenerated from the merged code and match what git merged.
@juicycleff
juicycleff merged commit b9b4fb1 into main Nov 10, 2026
17 checks passed
@juicycleff
juicycleff deleted the feat/client-plugin-contract branch November 10, 2026 18:25
juicycleff added a commit that referenced this pull request Nov 10, 2026
The plugin mechanism landed in #168 with one package, subscription.
These are the other five server plugins with a user-facing API: phone,
consent, oauth2, organization and scim.

Each exposes "." for the plugin definition, which is framework-free, and
"./react" for its hooks and components, so a host that never touches
React does not load them and a host that installs none of these ships
none of their code. The components live in the plugin package next to
the definition rather than in ui-components, because usePlugin looks a
plugin up by its definition object and a component calling it would have
to import one. The dependency runs one way.

Two of these are easy to mix up. consent is GDPR purpose consent, keyed
on a purpose string. oauth2 is the "App X wants access" screen, which
belongs to oauth2provider. They are different plugins and different
packages.

AuthManager gains adoptSession, because phoneAuthVerify returns a
perfectly good session and nothing could take it. Anything that mints a
session out of band can use it now.

Both test harnesses can register plugins. Before this the storybook mock
made usePlugin a TypeError and test-support made it throw, which blocked
every plugin component from being storied or tested at all.
juicycleff added a commit that referenced this pull request Nov 10, 2026
…173)

* feat(ui): refresh the component look and feel

Tokens move to the reference palette: a blue primary, a warmer neutral
ramp and a slightly larger radius. They are still tokens, so one line of
CSS puts any of them back.

Field heights do not change. The library was deliberately compacted below
stock shadcn and it stays that way, so Button and Input keep h-[30px] at
13px. What they gain is everything other than size: a border you can see
before you focus it, a focus ring that is the blue at low opacity rather
than a hard outline offset from the field, and aria-invalid styling.

AuthCard gets the type scale from the reference design and defaults to
left-aligned, which is what every screen in it uses.

Separator gains a LabelledSeparator, and OrDivider now uses it and takes
a label. The old divider painted bg-card behind its text, which is a
guess about what it is sitting on, and it guessed wrong the moment a form
was rendered borderless on a page background.

* feat(ui): add a verification code input and a phone input

One VerificationCodeInput replaces four hand-assembled InputOTP
arrangements. It takes a length and a mode, numeric or alphanumeric, and
alphanumeric upper-cases as you type because every code authsome issues
is case-insensitive and a lowercase b rendered as B is a bug report
waiting to happen. Boxes shrink to fit their column, so an eight
character device code still sits inside a 400px card.

It also takes focus back when it is re-enabled with an incomplete code.
Disabling a field while its code is checked blurs it, and nothing gave
the focus back, so the cost landed on exactly the person who had just
got their code wrong and now had to click into the boxes again. That
fixes MFA and email verification at the same time.

PhoneInput is a country picker as an input addon plus a national number
that formats itself, on libphonenumber-js. The value handed back is
always E.164, which is what the API wants, while what you see is your own
country's grouping, which is the only format you can check against the
handset in your hand. InputGroup and InputAddon are the general version
of that pattern.

InputOTP and its slots stay exported for anyone who built their own
arrangement. The caret animation they reference is now actually defined.

* feat(ui): remember the last account and report the last sign-in

Two separate things that both answer "who is this".

LastUsedAccount is the returning-visitor shortcut: who signed in last on
this browser, and how. It is per-browser, in localStorage, written on
every successful sign-in and kept for ninety days. Long enough that
someone on their own laptop still gets the shortcut, short enough that a
library computer stops offering a stranger's name and email to whoever
sits down next. Signing out does not clear it, because being remembered
is the point.

It is a convenience and never an authorization. Nothing in that record is
trusted and the person it names still authenticates. It reads through
useSyncExternalStore so the server renders nothing and the chip appears
after hydration, rather than setting state from an effect.

useLastSignIn and AccountSummaryCard are the other one: server truth
about when and where you last signed in. twoStepEnabled is a tri-state,
and null hides the row rather than claiming two-step is off when nothing
has answered the question. That is a lie you do not want in a security
surface.

* feat(api): preview invitations, read MFA state, administer SCIM

Three endpoints the UI needed and the server did not have.

GET /v1/orgs/invitations/:inviteToken returns an invitation by its token,
unauthenticated, because whoever is deciding whether to accept has not
signed in yet and often has no account at all. It returns a composed view
rather than the stored row: the row has no organization name and no
inviter name, which are the two things the page needs, and it carries the
token and the raw invited address, which the page does not. The address
is masked. Expired, consumed and never-existed all answer 404, so a list
of guessed tokens learns nothing.

The path parameter is :inviteToken rather than :token because sdkgen
names a path parameter and the auth argument from the same source, and
:token generated getInvitation(token, token).

GET /v1/mfa/enrollment answers whether two-step is on for the caller, with
which factors and how many recovery codes are left. No secret, no code,
nothing about anyone else.

SCIM gains nine admin routes under /v1 for configurations and tokens. The
service methods existed and none of them were reachable over HTTP. They
authenticate as an organization owner or admin, not as a provisioning
bearer token, which is why they are not under the SCIM base path: an
identity provider holding a token has no business minting more. A caller
who is not a member gets 404 rather than 403, and an app-level config,
which has no organization to check a role against, is not reachable here
at all.

* feat(ui): add five client plugin packages

The plugin mechanism landed in #168 with one package, subscription.
These are the other five server plugins with a user-facing API: phone,
consent, oauth2, organization and scim.

Each exposes "." for the plugin definition, which is framework-free, and
"./react" for its hooks and components, so a host that never touches
React does not load them and a host that installs none of these ships
none of their code. The components live in the plugin package next to
the definition rather than in ui-components, because usePlugin looks a
plugin up by its definition object and a component calling it would have
to import one. The dependency runs one way.

Two of these are easy to mix up. consent is GDPR purpose consent, keyed
on a purpose string. oauth2 is the "App X wants access" screen, which
belongs to oauth2provider. They are different plugins and different
packages.

AuthManager gains adoptSession, because phoneAuthVerify returns a
perfectly good session and nothing could take it. Anything that mints a
session out of band can use it now.

Both test harnesses can register plugins. Before this the storybook mock
made usePlugin a TypeError and test-support made it throw, which blocked
every plugin component from being storied or tested at all.

* docs(ui): cover the new components and the plugin packages

components.mdx gains the session components, the verification code input,
the phone input and the input group, and its CSS variable example now
matches the tokens the library actually ships.

plugins.mdx gains a section per package: install, register, hook,
component, and the one caveat each has. The page already existed and
covered the mechanism, so this is the half that was missing.

playground.mdx was claiming 6 categories and 31 story files. It is 9 and
49 now.

* fix(scim,mfa): satisfy the linters on the new admin surface

The SCIM admin response types were called SCIMConfigListResponse and so
on, which read as scim.SCIMConfigListResponse from outside and stutter.
They are ConfigListResponse, TokenListResponse, TokenCreatedResponse and
StatusResponse now, and the clients are regenerated for it. sdkgen keeps
StatusResponse apart from the organization plugin's by itself.

tokenForCaller returned a configuration nobody read. It returns the
token ID and an error.

The two org-admin handlers now name the parse error separately. Reusing
err makes gocritic call the second assignment sloppy and shadowing it
makes govet call it a shadow, so neither linter accepts the other's fix
and distinct names are the way out. There is a comment saying so, since
the obvious cleanup is to undo it.

The MFA enrollment handler never read its request, so the parameter is
_. It takes nothing: the enrollment it reports is always the caller's.

* test(scim,mfa,organization): cover the new endpoints

Codecov was right: three endpoints and nine SCIM admin routes landed with
no Go tests at all, and admin_handlers.go was at zero.

The SCIM tests spend most of their effort on the authorization boundary,
because that is where this surface can actually hurt. An owner and an
admin may manage a connection and a plain member may not. A non-member
gets 404 rather than 403, and the test asserts that a real organization
and an invented one answer identically, so probing IDs tells an outsider
nothing. An admin of one organization cannot reach another's config, and
cannot rotate or revoke a token by ID either, which is the check that
stops a token ID alone being enough. An app-level config stays
unreachable.

The invitation preview tests pin what the response does not say as much
as what it does: the address is masked, the token never appears in the
body, and expired, accepted, declined and never-existed are the same 404.
maskEmail and inviterName are tested directly through an export_test
seam, including the one-character local part that would otherwise panic
on a slice.

The MFA tests pin that an unverified enrollment does not make two-step
on, that the recovery count excludes used codes, that the TOTP secret
never reaches the response, and that one user's enrollment says nothing
about another's.

admin_handlers.go goes from 0% to between 67% and 100% per function.
What is left is the service-nil guards and the store-failure branches,
which need fault injection to reach.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant