npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pilely/simple-group

v0.7.1

Published

React hooks for the simple_group service.

Readme

@pilely/simple-group

React hooks for the simple_group service — the platform's managed group membership and delegated permissions, and the exact handle-to-user-id lookup. A query hook for every read, a mutation hook for every write. Render them inside @pilely/core's <PilelyProvider>.

Hooks

| Hook | Wraps | Kind | | --- | --- | --- | | useGroups({ limit? }) | groups/list, paged | infinite query; data is GroupWithMemberCount[] | | useMembers(group, { limit? }) | members/list, paged | infinite query; data is Member[] | | useMemberSearch(group, q, { limit? }) | members/search, paged | infinite query; label-prefix search among user members | | useMembership(group, subject) | members/resolve | query; data is boolean | | usePermissions(group, action, { limit? }) | members/{action}/permission/list, paged | infinite query; data is Permission[] | | useHandleLookup(handle) | users/lookup | query; data is { user_id, handle } | | useCreateGroup() | groups/create | mutation, the optional display name, or { displayName?, membersCanList? } | | useRenameGroup() | groups/{g}/rename | mutation, { group, displayName? } | | useSetMembersCanList() | groups/{g}/set_members_can_list | mutation, { group, membersCanList }; owner only | | useArchiveGroup() | groups/{g}/archive | mutation, the group's nanoid | | useUnarchiveGroup() | groups/{g}/unarchive | mutation, the group's nanoid | | useAddMember() | members/add | mutation, { group, subject } | | useRemoveMember() | members/remove | mutation, { group, subject } | | useAddPermission() | members/{action}/permission/add | mutation, { group, action, subject } | | useRemovePermission() | members/{action}/permission/remove | mutation, { group, action, subject } |

Every hook also takes { challenge }, in its options object or as its last argument where it has none (useMembership(group, subject, { challenge }), useCreateGroup({ challenge })): a bot-check answer from @pilely/simple-limiter, or a function answering one asked once per request, sent as the X-Pilely-Challenge header on every request the hook makes. A challenge policy cannot name simple-group.pilely.app today, so nothing here is challenged and root ignores the header; the option is here because every @pilely call takes it (@pilely/simple-limiter).

An undefined group, q, subject, action or handle disables the read that takes it. subject is { subject_type: "user" | "app", subject_id }; action is one of add, remove, list, search, resolve. Every write waits for the server.

There is no cap on how many groups an owner has (archived ones included) or how many members a group holds; storage is the only brake (storage_exceeded). Page through useGroups and useMembers until hasNextPage is false to see every row.

Every Group carries members_can_list (default false). While the owner has it on, every user member may page useMembers for that group, with no grant; it widens nothing else (useMemberSearch and useMembership still need their grants). Turning it off, or removing the member, stops their listing on their next request. A member listing an archived group gets group_archived.

A permission grant names a user. useAddPermission with an app subject is refused with app_grant_unsupported (400), before the group is looked up, and stores nothing. app grants made before that refusal still show in usePermissions and can be removed; they authorize nothing.

The anonymous and authenticated groups

Two groups exist for the whole platform, each with one id that is the same for every app. The simple_group service hands the ids out — POST /groups/anonymous and POST /groups/authenticated, free to any caller, signed in or not — and this package wraps them as two functions:

| Group | Function | Route | Who is in it | | --- | --- | --- | --- | | anonymous | anonymousGroupId() | POST /groups/anonymous | every signed-out visitor | | authenticated | authenticatedGroupId() | POST /groups/authenticated | every person signed in to pilely.app |

Each returns a Promise<string>, the group's group_nanoid, and takes { challenge } like every other function here (anonymousGroupId({ challenge })). Get the id from the function (or the route) rather than copying it into your code. In mock mode the fake answers fixed test ids, which @pilely/simple-db's fake also recognises.

A backend app and an agent are in neither. Membership is decided by who the caller is on every request and never stored, so there is nothing to add, remove, list or wait for. Name one where another service takes a group — a policy's group on a @pilely/simple-db table — to open that API to every signed-out visitor or every signed-in person:

import { anonymousGroupId } from "@pilely/simple-group";

// a signed-out contact form: anyone may create, only the owner reads
const policies = { "records/create": [{ group: await anonymousGroupId(), rows: {} }] };

On these two ids:

  • useAddMember, useRemoveMember (removing yourself included), useRenameGroup, useArchiveGroup, useUnarchiveGroup, useMembers and useMemberSearch get global_group (409) for every signed-in person, through any app — an app's page included;
  • useMembership answers from subject_type alone: a user is a member of the authenticated group and not of the anonymous one; an app is a member of neither;
  • useGroups never lists them, and useSetMembersCanList and the permission hooks get the uniform not_found (404), as on any group you do not own;
  • a signed-out caller and a backend app get the uniform 404, as on every group.

Which hooks work from an app page

Every hook calls simple_group with the token @pilely/core holds, which in an app's page is that app's token. simple_group changes a group only for a person signed in with a token for simple-group itself: a group records no app and may guard the resources of several of the owner's apps, so no app's page may change one (https://simple-group.pilely.app/README.md → ## Auth).

  • Read hooks work from any app's page, under the group rules above: useGroups, useMembers, useMemberSearch, useMembership, usePermissions, useHandleLookup.
  • Write hooks are refused from every app's page: useCreateGroup, useRenameGroup, useSetMembersCanList, useArchiveGroup, useUnarchiveGroup, useAddMember, useRemoveMember, useAddPermission, useRemovePermission (a member removing themselves included). Each gets the uniform hide, not_found (404), in its error, and nothing changes. The owner makes group changes from their agent, acting on simple_group directly. Build an app's admin screen to read groups and look people up, and leave the changes to the owner's agent.

Find a person by handle

Member and grant routes take a user id, never a handle. useHandleLookup(handle) turns an exact handle into the account's id: the server trims it, drops one leading @ and lower-cases it, and only an exact match answers (ali never finds alice). A handle no account holds — never registered, reserved, or released by a deleted account — is an error with code not_found (404). Any signed-in user may look up; each lookup is a metered request billed to the caller.

const { data: person, error } = useHandleLookup(handle || undefined);
// once person is set, show person.user_id; the owner's agent adds it to the group

What each write refreshes

| Write | Invalidates | | --- | --- | | group create / rename / set members_can_list / archive / unarchive | useGroups() | | member or permission add / remove on group G | G's members, searches, membership checks and permissions; useGroups() (member counts) |

Example

An app page reads; it never changes a group (## Which hooks work from an app page).

import { useGroups, useMembers } from "@pilely/simple-group";

export function Team({ group }: { group: string | undefined }) {
  const { data: groups } = useGroups();
  const { data: members } = useMembers(group);

  return (
    <>
      <ul>{groups?.map((g) => <li key={g.group_nanoid}>{g.display_name} ({g.member_count})</li>)}</ul>
      <ul>{members?.map((m) => <li key={m.subject_id}>{m.label ?? "(deleted account)"}</li>)}</ul>
    </>
  );
}

Query keys

| Key | Query | | --- | --- | | ["pilely", "simple-group", "groups", { limit }] | useGroups | | ["pilely", "simple-group", "group", group, "members", { limit }] | useMembers | | ["pilely", "simple-group", "group", group, "search", q, { limit }] | useMemberSearch | | ["pilely", "simple-group", "group", group, "resolve", subject_type, subject_id] | useMembership | | ["pilely", "simple-group", "group", group, "permissions", action, { limit }] | usePermissions | | ["pilely", "simple-group", "lookup", handle] | useHandleLookup |

Deleted subjects send a null label

label is the subject's handle, filled in at read time. A member or permission row survives its subject: when the account or app behind it is deleted, label comes back null. Always render a fallback, as the example does.

Mock mode

Importing this package in a build made with VITE_PILELY_MOCK=1 registers an in-browser fake of simple_group behind @pilely/core's mock runtime — nothing else to call.

The fake keeps groups, members and permission grants, persisted to localStorage across reloads, paged on the service's cursors. A missing group answers the bare 404, and an archived group refuses writes with group_archived (409). A subject's label is its id; the mock user's is its handle. It does not model most permission rules — every signed-in user may run most routes on every group, and the write hooks succeed even though the service refuses them from an app page — storage walls, quotas or traffic credits, and like the service it has no group or member cap. It does model the rules this package's contract turns on:

  • the group's creator owns it, and only the owner may set_members_can_list (anyone else gets the bare 404);
  • members/list answers the owner, a list grant holder, and a user member while members_can_list is on; anyone else gets the bare 404, and a non-owner on an archived group gets group_archived;
  • permission/add refuses an app subject with app_grant_unsupported;
  • users/lookup matches exactly over the mock's one account, the mock user (mock_user): a miss throws PilelyError(404, "not_found"), and a signed-out lookup PilelyError(401, "unauthenticated");
  • the two platform-wide groups answer exactly as on the service (## The anonymous and authenticated groups): global_group (409) on the seven refused routes before the body is read, members/resolve from subject_type alone, the bare 404 on set_members_can_list and the permission routes, never listed by groups/list, and the bare 404 to a signed-out caller.

Writes need a signed-in user: a signed-out write answers the uniform bare 404, which reaches the hook's error as PilelyError(404, null). See @pilely/core for the switch, seedMock() and resetMock().

Error codes

Refusals arrive as PilelyError in the hook's error, with a code drawn from SimpleGroupErrorCode. Match on the code, never on the status.

From an app's page a write hook gets only not_found (404), and nothing further is checked (## Which hooks work from an app page) — except on the two platform-wide groups, where it gets global_group; the write codes below reach a person acting on simple_group directly. Group create, member add and permission add can answer storage_exceeded (402, the owner's allowance is full — the owner must act) and quota_unavailable (503, transient). Permission add alone can answer limit_reached (409, the 200-grants-per-action cap — groups and members have no cap) and app_grant_unsupported (400, an app subject). global_group (409) is a member change, rename, archive, unarchive, member list or member search on one of the two platform-wide groups (## The anonymous and authenticated groups). The rest: not_found (404, the uniform hide; on a handle lookup, no account holds that handle), group_archived (409), unknown_subject (400), bad_request (400), unauthenticated (401), bad_forwarded_identity (401), out_of_traffic_credits (503), rate_limited (429, reserved — the service sets no rate limit and @simple_limiter accepts no policy on its host, so nothing answers it today), challenge_required and challenge_failed (403, reserved the same way: root's challenge check answers them only on a host a challenge policy can name, and this is not one), internal (500).

Removing yourself from a group, on simple_group directly, always succeeds, whether or not you were a member.

Install

npm install @pilely/simple-group @pilely/core @tanstack/react-query

@pilely/core, react (^18.3 || ^19) and @tanstack/react-query (^5) are peer dependencies. Always install with the scope — unscoped names like pilely-group are not owned by this project. This is a pre-1.0 package and moves with the platform.