@nuucognition/identity
v0.3.0
Published
Shared identity projection for NUU Cognition apps — reads Clerk public_metadata for plans, memberships, grants, access sets, organisations, handles, and identity
Readme
@nuucognition/identity
@nuucognition/identity is the shared identity projection and access-checking
package for first-party NUU apps. It reads the NUU claims that the account and
organisations systems project into Clerk public_metadata, turns those claims
into typed plans, memberships, app levels, access sets, and organisation
memberships, and gives apps one consistent way to gate UI and backend work.
This package is not an auth provider. Clerk remains the auth provider. This package is the read layer over NUU account state.
What This Package Owns
- Parsing Clerk
public_metadata. - The access catalog: apps, ladders, consumption items, packs, access sets, and memberships.
- Resolving plans, memberships, grants, and sets into one app level and one membership view.
- Resolving organisation memberships from slug-based metadata.
- Defining typed feature schemas for app-owned features.
- React hooks for live Next.js app UI.
- Convex helpers for backend enforcement.
- Pure metering arithmetic over an app-owned event log.
- Redirect/bootstrap helpers for the NUU account sign-in flow.
It does not own:
- Writing Clerk metadata.
- Polar products, prices, or checkout logic.
- Organisation CRUD or invitation workflows.
- Storing usage events. The app owns the event log; this package does the sums.
- Clerk-native Organisations. NUU uses its own organisation model.
An app may read the plan and the grants. An app may not read Polar, prices, or the account app's Convex.
Entry Points
Use the narrowest entry point that fits the runtime.
// Pure code: schemas, extractors, access checks. No React or Clerk imports.
import { defineFeatures, createIdentityToolkit } from "@nuucognition/identity";
// Browser/React code. Depends on @clerk/nextjs and React.
import { createReactIntegration, NuuAuthProvider } from "@nuucognition/identity/react";
// Convex code. No React imports.
import { createFeatureMiddleware, requireIdentity } from "@nuucognition/identity/convex";
// Next.js middleware and return URL helpers.
import { createAuthMiddleware, normalizeReturnTo } from "@nuucognition/identity/redirect";
// Typed client for the Orgs service display-name lookup endpoint.
// Browser- and Convex-action-safe. No React, no Clerk.
import { fetchOrgsBySlugs } from "@nuucognition/identity/orgs-lookup";Projection Contract
The package expects NUU account state to be available in Clerk
public_metadata.
{
"subscriptions": {
"pro": [1745539200, 1748217600]
},
"grants": {
"demo.level.pro": [[1745539200, 1748217600]],
"demo.parses.pack10": [
[1745539200, 1753315200],
[1748217600, 0]
]
},
"sets": {
"demoTesters": [1745539200, 0]
},
"membership": ["founder", 1745539200, 0],
"org_memberships": {
"nuucognition": "owner",
"acme": "admin"
},
"role": "admin",
"handle": "ada-lovelace"
}The projection has seven claims:
subscriptions: plan name → one[start, end]period. Read it withuseEntitlements()/extractEntitlements(), which expand the tuples intoPlanEntitlement[]with parsedDateinstances. A plan with no period is not in the projection.grants: grant key → a list of periods. One key may hold several periods. Each period of a pool grant is one separate drainable pool.sets: access set name → one period. A set is a named bundle of grant keys; see "The Catalog" below.membership: one[name, start, end]tuple, ornull. The admin-set membership with its period. Read it withextractMembership(). See "Memberships" below.org_memberships: organisation slug → role. The organisations app writes it.role: one platform-level user role string."admin"is the only recognised value today. Unknown strings are tolerated and read asnull. See "Platform Roles" below.handle: the person's canonical@handle, lowercase. Read it withuseIdentity().handle/extractHandle(). A malformed value reads asnull. See "Handles" under "User Profiles" below.
The organisation key is the organisation slug, not a UUID. Live apps should pass the active organisation slug from routing or app state into feature checks.
The Period Rule
One rule covers every period in every namespace:
- A period is
[start, end]in epoch seconds. - It is read as
[start, end)— inclusive atstart, exclusive atend. end === 0means open-ended. The period never expires.
At the instant end is reached the next period has begun. Treat that moment as
the reset boundary for any usage counter.
import { activePeriods, isOpenEnded, isPeriodActive } from "@nuucognition/identity";
isPeriodActive([1745539200, 1748217600], Date.now()); // → boolean
isOpenEnded([1745539200, 0]); // → true
activePeriods(grants["demo.parses.pack10"] ?? [], Date.now());now is a Date or epoch milliseconds, as Date.now() returns. The
helpers do the seconds-to-milliseconds conversion. Never compare a wire period
to Date.now() by hand.
Reading Subscription Periods
The [start, end] tuple is [currentPeriodStart, currentPeriodEnd] in epoch
seconds — the current billing-period window for that plan. Apps that need
per-period quota tracking (e.g. "10 actions/month, reset at period end") read
the window from the JWT instead of querying Convex on every gate.
import {
useCurrentPeriod,
useEntitlements,
} from "@nuucognition/identity/react";
// Per-plan window
const { plans } = useEntitlements();
// plans: [{ name, currentPeriodStart: Date, currentPeriodEnd: Date }]
// Highest-precedence active plan resolved against `Date.now()`
const period = useCurrentPeriod();
// { name, currentPeriodStart, currentPeriodEnd, daysRemaining } | nullPure helpers (no React) are available from the root and /convex entries:
| Helper | Purpose |
| --- | --- |
| resolveActivePeriod(plans, now?) | Highest-precedence plan whose [start, end) covers now. |
| isInCurrentPeriod(plan, ts?) | Window check; left-closed, right-open. |
| getPlanPeriod(plans, name) | Look up a plan's window by name. |
| daysRemaining(plan, now?) | Whole days until currentPeriodEnd, rounded up. |
| extractPlanPeriod(source, name) | Read a single plan's window straight from raw metadata. |
Boundary inclusivity is [start, end) — inclusive at start, exclusive at
end. At the instant end is reached, the next period has begun; treat that
moment as the reset boundary for any usage counter.
Writer Boundary
Only writer apps should mutate Clerk metadata:
apps/nuu-accountownssubscriptions,grants,sets,membership,role, andhandle.apps/nuu-organisationsownsorg_memberships.- Other apps should read metadata through this package and enforce access in their own UI and backend routes.
Writers must preserve metadata namespaces that they do not own. Consumer apps should tolerate unknown keys for forward compatibility.
Plans, Memberships, Apps, And Access Sets
Plans
There are four plan names. Three are paid. guest is virtual. PLAN_TITLES
holds the product name of each plan.
| Plan | Title | Default membership | Written to Clerk |
| --- | --- | --- | --- |
| guest | Guest | guest | No. It is virtual. |
| basic | Basic | basicMember | Yes |
| pro | Pro | proMember | Yes |
| max | Max | maxMember | Yes |
PLAN_LADDER orders the plans low to high: guest, basic, pro, max.
PLAN_PRECEDENCE orders them high to low and picks the winner when more than
one plan period is active at the same instant. guest is the answer when no
known plan is active. Never write guest into Clerk metadata.
Memberships
A membership is what a person is called, plus the predetermined sets it
carries. MEMBERSHIPS is the catalog; PLAN_MEMBERSHIPS maps each plan to
the membership it grants by default.
| Membership | Title | Card | Sets |
| --- | --- | --- | --- |
| guest | Guest | the plan card | — |
| basicMember | Basic Member | basic | bundleBasic |
| proMember | Pro Member | pro | bundlePro |
| maxMember | Max Member | max | bundleMax |
| founder | Founding Member | founder | bundleMax |
A membership entry holds a title, a description, an optional card
(the default card id; absent, the plan card of the person's plan shows), and
sets — the predetermined sets the membership carries. The membership is the
one path from a plan to permissions: the plan names its default membership,
the membership carries sets, and the sets carry keys.
One membership, one field. Every person has exactly one membership. The
admin sets it on the person with an optional end date; the projection writes
it as the membership claim — one [name, start, end] tuple. When the claim
is absent, unknown, or past its period, the plan default
(PLAN_MEMBERSHIPS[plan]) holds. There are no membership grant rows and no
winner rule. resolveMembership applies this; see "Resolving Access" below.
import {
MEMBERSHIPS,
PLAN_MEMBERSHIPS,
extractMembership,
getMembership,
isMembershipName,
} from "@nuucognition/identity";
MEMBERSHIPS.founder.title; // "Founding Member"
MEMBERSHIPS.founder.sets; // ["bundleMax"]
PLAN_MEMBERSHIPS.pro; // "proMember"
isMembershipName("founder"); // true
getMembership("nope"); // null
extractMembership(metadata); // ["founder", 1745539200, 0] | null — NOT filtered by timeThe Catalog
APPS and SETS are the shared vocabulary. They are pure data — no prices, no
behaviour.
import { APPS, SETS, appLadder, getApp, getSet } from "@nuucognition/identity";
APPS.demo.title; // "Access Demo"
APPS.demo.ladder; // ["none", "basic", "pro", "max"]
SETS.bundlePro.keys; // ["surf.level.pro", ..., "demo.parses.allowance.50", ...]An app entry holds:
title— the display name.ladder— the app's own access ladder, low to high. Optional.items— consumption items, each with its packs.
Ladder fallback rule. An app with no ladder of its own uses
PLAN_LADDER. An unknown app follows the same rule. appLadder(app) returns
the ladder that applies. There is no plan cell: the level starts at the bottom
of the ladder and rises with active level keys only.
A set entry holds a title, a description, and a list of grant keys. A set
grants permissions and nothing else. It never changes the membership and it
never names a card. The three bundles (bundleBasic, bundlePro,
bundleMax) carry the level key of every app for one plan, plus the
allowance keys of that plan. The allowance numbers live in the set
definitions, not on the items.
Grant Key Grammar
<app>.level.<level> level grant — raises the app level while active
<app>.<item>.<pack> pool grant — adds one drainable pool per period
<app>.<item>.allowance.<n> allowance grant — sets the per-cycle allowance while activeEach segment matches [a-z][a-zA-Z0-9]*.
import { GRANT_KEYS, isKnownGrantKey, parseGrantKey } from "@nuucognition/identity";
parseGrantKey("demo.level.pro"); // { kind: "level", app: "demo", level: "pro" }
parseGrantKey("demo.parses.pack10"); // { kind: "pool", app: "demo", item: "parses", pack: "pack10" }
parseGrantKey("not a key"); // null
isKnownGrantKey("demo.level.pro"); // true — the catalog lists it
GRANT_KEYS; // every grantable key derived from APPSparseGrantKey checks the shape only. isKnownGrantKey checks the catalog.
Key immutability rule. A grant key is written into Convex rows and into JWTs. A grant key is never renamed. To change a key, retire the old key and add a new one. The same rule applies to app names, set names, and membership names.
Resolving Access
The key union. The person's active keys are the union of three sources:
the grants namespace (filtered by active periods), the keys of every active
granted set, and the keys of the resolved membership's sets. The plan itself
grants nothing; it only names its default membership.
resolveAppAccess(source, app, now?) answers what one app may do right now.
import { resolveAppAccess } from "@nuucognition/identity";
const access = resolveAppAccess(user.publicMetadata, "demo");
access.plan; // "basic"
access.planPeriod; // [1745539200, 1748217600] | null
access.membershipTitle; // "Basic Member" — the same membership resolveMembership reads
access.level; // "basic" — the ladder bottom raised by active level keys
access.allowances; // { parses: 5, credits: 3 } — max over active allowance keys
access.pools; // [{ key, item, pack, size, period }] soonest end first
access.sets; // ["bundleBasic"] — the set unionThe level starts at the bottom of the app's ladder and takes the highest
active level key. A key for a level that is not on the app's ladder is
ignored. An active <app>.<item>.allowance.<n> key sets allowances[item]
to the maximum n over the active keys for that item; no active key means
0. Pools come from the grants namespace only, because a pool needs its
own periods; a pool key inside a set makes no pool.
resolveMembership(source, now?) answers how to show the membership.
import { resolveMembership } from "@nuucognition/identity";
const membership = resolveMembership(user.publicMetadata);
membership.plan; // "basic"
membership.planPeriod; // [start, end] | null
membership.membership; // "founder" — the active claim, else PLAN_MEMBERSHIPS[plan]
membership.membershipTitle; // "Founding Member" — MEMBERSHIPS[membership].title
membership.membershipPeriod; // [start, end] for the claim; null for the plan default
membership.membershipSource; // "admin" | "plan"
membership.card; // "founder" — MEMBERSHIPS[membership].card ?? plan
membership.sets; // ["bundleMax"] — the set unionsets is the same list as AppAccess.sets: the set union — active
granted sets in claim order, then the membership's sets, deduplicated. Gate
on sets; a feature can require a set that a membership carries. A granted
set never changes membership; only the claim or the plan does. card is
the code default; the account app stacks a per-membership binding and a
per-user override on top of it.
Both functions are pure and filter every period by now. Leave now unset in
production; pass it in tests to pin the instant.
Feature Schemas
Every app owns its feature schema. Define it close to the app, export the toolkit, and reuse the same schema in React and Convex.
// src/lib/identity.ts
import {
createIdentityToolkit,
defineFeatures,
} from "@nuucognition/identity";
export const FEATURES = defineFeatures({
createProject: {
description: "Create projects",
requiresLevel: { app: "demo", level: "basic" },
},
highVolumeExport: {
description: "Run high-volume exports",
requiresLevel: { app: "demo", level: "max" },
},
testerTools: {
description: "Demo tester tools",
requiresSet: "demoTesters",
},
manageOrgBilling: {
description: "Manage organisation billing",
requiresLevel: { app: "demo", level: "pro" },
requiresOrgRole: "admin",
},
});
export const identityToolkit = createIdentityToolkit(FEATURES);
export type AppFeature = keyof typeof FEATURES;Supported feature fields:
description: human-readable description.requiresLevel:{ app, level }. The preferred gate. The user must be atlevelor higher on that app's ladder.requiresSet: one access set name. The set must have an active period.requiresPlan: one exact plan name. The type isPlanName, so onlyguest,basic,pro, ormaxcompiles.requiresPlans: one of several exact plan names. The type isreadonly PlanName[].requiresOrgRole: minimum role in the active organisation slug.
requiresPlan and requiresPlans are mutually exclusive; defineFeatures
rejects a feature that sets both. Every other combination is allowed, and a
feature that sets more than one field must satisfy every one of them.
Evaluation order. The checker reads the fields in this fixed order:
requiresLevelrequiresSetrequiresOrgRolerequiresPlansrequiresPlan
The first failing field gives reason. So a feature that fails both its level
and its plan reports the level reason.
Plans, grants, and sets are filtered by active period before any of these
checks run. An expired window in the JWT never grants access. A membership is
not a gate by name: no feature field reads the membership claim directly.
requiresSet reads the set union, so a set the membership carries satisfies
it.
A plan gate reads the PlanName union only. An active subscriptions entry
whose name is not in the union — a retired plan, or one a newer writer added —
satisfies no plan gate and never appears as the resolved plan.
The result carries what the gate resolved:
toolkit.getFeatureAccess("createProject", metadata);
// { allowed: true, level: "pro", plan: "pro" }
toolkit.getFeatureAccess("highVolumeExport", metadata);
// { allowed: false, reason: "Requires demo level: max", level: "pro", plan: "pro" }
toolkit.getFeatureAccess("testerTools", metadata);
// { allowed: false, reason: "Requires access set: demoTesters", plan: "pro" }
toolkit.getUpgradeMessage("highVolumeExport");
// "Upgrade demo to max to unlock Run high-volume exports"
toolkit.getUpgradeMessage("testerTools");
// "Requires the Demo Testers access set to unlock Demo tester tools"The feature schema holds no quota limits and no usage windows. Apps that meter
usage use planConsumption from this package with their own event tables; see
"Metering" below.
Platform Roles
The role namespace in public_metadata carries a single platform-level user
role. Today the only recognised value is "admin". Unknown strings are
tolerated and resolve to null so a forward-newer writer cannot lock out older
consumers.
This is distinct from org_memberships.<slug> = "admin", which is per-org. A
platform admin is granted across the whole NUU surface; an organisation admin
is scoped to a single org slug.
import { extractRole, isAdmin } from "@nuucognition/identity";
extractRole(user.publicMetadata); // → "admin" | null
isAdmin(user.publicMetadata); // → booleanThe writer for this namespace is apps/account. A single hardcoded bootstrap
email is granted role: "admin" on every user bootstrap; every other admin is
granted (or revoked) through the account app's admin console, which PATCHes the
target user's metadata. Consumers must not write the namespace.
Apps that need to keep a hardcoded bootstrap email as a fallback (covering the
small window between first login and the next JWT re-mint) should layer that
check on top of isAdmin() in their own consumer code rather than baking it
into the package.
React Integration
React integration is for client components in live Next.js apps. It wraps Clerk state, parses the projection, and returns stable app-facing hooks.
Hooks re-evaluate when the Clerk user object changes (about every 60 seconds with the session token). Enforce exact period boundaries on the server with the Convex helpers.
Package Setup
This package currently exports TypeScript source. Next.js apps that import it must transpile it:
// next.config.mjs
const nextConfig = {
transpilePackages: ["@nuucognition/identity"],
};
export default nextConfig;Apps using pnpm injected workspace dependencies for Clerk peer resolution should keep this in the consumer app package:
{
"dependenciesMeta": {
"@nuucognition/identity": {
"injected": true
}
}
}Create App Hooks Once
Create the React integration next to the app feature schema.
// src/lib/identity.ts
import {
createIdentityToolkit,
defineFeatures,
} from "@nuucognition/identity";
import { createReactIntegration } from "@nuucognition/identity/react";
export const FEATURES = defineFeatures({
createProject: {
description: "Create projects",
requiresLevel: { app: "demo", level: "basic" },
},
manageOrgBilling: {
description: "Manage organisation billing",
requiresOrgRole: "admin",
},
});
export const identityToolkit = createIdentityToolkit(FEATURES);
export const {
IdentityProvider,
useIdentity,
useEntitlements,
useAccess,
useAppAccess,
useMembership,
useGrants,
useSets,
useOrganisations,
useCurrentOrganisation,
useCurrentPeriod,
useFeature,
useAccessibleFeatures,
} = createReactIntegration<typeof FEATURES>();Wire Providers
Use NuuAuthProvider where the app would normally place ClerkProvider, then
place the typed identity provider inside it.
// app/providers.tsx
"use client";
import { NuuAuthProvider } from "@nuucognition/identity/react";
import { IdentityProvider, identityToolkit } from "@/lib/identity";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<NuuAuthProvider>
<IdentityProvider toolkit={identityToolkit}>
{children}
</IdentityProvider>
</NuuAuthProvider>
);
}NuuAuthProvider sets the first-party account sign-in/sign-up defaults and
redirect origins. It does not write metadata. If the app owns a metadata writer,
mount IdentitySync separately and pass a writer-owned Convex function.
// src/components/identity-sync-client.tsx
"use client";
import { useUser } from "@clerk/nextjs";
import { useAction } from "convex/react";
import { IdentitySync } from "@nuucognition/identity/react";
import { api } from "../../convex/_generated/api";
export function IdentitySyncClient() {
const { isSignedIn } = useUser();
const resync = useAction(api.identity.resync);
return (
<IdentitySync
enabled={Boolean(isSignedIn)}
resync={() => resync({})}
/>
);
}Read Identity
Use useIdentity() for display, menus, and signed-in UI states.
"use client";
import { useIdentity } from "@/lib/identity";
export function AccountMenuLabel() {
const identity = useIdentity();
if (!identity.isLoaded) return null;
if (!identity.isSignedIn) return <span>Signed out</span>;
return <span>{identity.displayName ?? identity.email}</span>;
}The identity bundle includes:
userIdemailhandle— the NUU@handlefrompublic_metadata.handle, ornullusername— deprecated; alwaysnullon the NUU instance. ReadhandledisplayNameimageUrlisLoadedisSignedIn
Gate UI With Features
Use useFeature() for a single UI decision.
"use client";
import { useFeature } from "@/lib/identity";
export function ExportButton() {
const exportAccess = useFeature("highVolumeExport");
if (exportAccess.isLoading) return null;
return (
<button disabled={!exportAccess.allowed}>
Export
</button>
);
}UI checks are only for presentation and early user feedback. Backend checks must still enforce the same feature on the mutation, action, route handler, or API endpoint that performs the work.
Gate Organisation UI
Organisation-gated features require an active slug at check time.
"use client";
import {
useCurrentOrganisation,
useFeature,
useOrganisations,
} from "@/lib/identity";
export function OrganisationBillingPanel({
orgSlug,
}: {
orgSlug: string;
}) {
const org = useCurrentOrganisation(orgSlug);
const memberships = useOrganisations();
const access = useFeature("manageOrgBilling", {
activeOrgSlug: orgSlug,
});
if (!org) return null;
return (
<section>
<p>{memberships.length} memberships available</p>
{access.allowed ? <BillingControls /> : <ReadOnlyBilling />}
</section>
);
}The active organisation slug should come from route params, app state, or an explicit selector. Do not infer it from the first membership unless the product experience intentionally has a single active org.
List Accessible Features
Use useAccessibleFeatures() when building menus or dashboards from a feature
schema.
"use client";
import { useAccessibleFeatures } from "@/lib/identity";
export function FeatureMenu({ orgSlug }: { orgSlug?: string }) {
const features = useAccessibleFeatures({
activeOrgSlug: orgSlug,
});
return (
<ul>
{features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
);
}If a feature has requiresOrgRole, it will not appear unless the caller passes
an activeOrgSlug and the user has the required role for that slug.
Read Access Directly
Use useAccess() for low-level level, set, or organisation checks that are not
worth naming as a feature.
"use client";
import { useAccess } from "@/lib/identity";
export function ProjectLimitHint({ orgSlug }: { orgSlug: string }) {
const access = useAccess();
if (access.hasOrgRoleAtLeast(orgSlug, "admin")) {
return <span>Admin controls enabled</span>;
}
if (access.hasSet("demoTesters")) {
return <span>Tester tools enabled</span>;
}
if (access.level("demo") === "pro" || access.level("demo") === "max") {
return <span>Project creation enabled</span>;
}
return <span>Upgrade required</span>;
}useAccess() returns:
| Field | Meaning |
| --- | --- |
| plan | The effective plan of the active subscriptions. guest when there is none. |
| level(app) | The app level: the plan cell raised by every active level grant. |
| hasSet(set) | true when that access set has an active period. |
| isOrgMember(slug) | Membership of one organisation. |
| hasOrgRole(slug, role) | Exact role in one organisation. |
| hasOrgRoleAtLeast(slug, role) | Role at or above role in one organisation. |
Prefer named features for product behavior. Direct checks are best for simple display logic and low-level affordances.
Read The Access Projection
Four more hooks expose the projection directly.
"use client";
import {
useAppAccess,
useGrants,
useMembership,
useSets,
} from "@/lib/identity";
export function DemoPanel() {
const access = useAppAccess("demo"); // AppAccess for one app, periods filtered
const membership = useMembership(); // MembershipAccess, periods filtered
const grants = useGrants(); // raw `grants` namespace, NOT filtered by time
const sets = useSets(); // raw `sets` namespace, NOT filtered by time
return (
<section>
<h2>{membership.membershipTitle}</h2>
<p>Level: {access.level}</p>
<p>Parses left this cycle: {access.allowances.parses}</p>
<p>{Object.keys(grants).length} grant keys, {Object.keys(sets).length} sets</p>
</section>
);
}useAppAccess and useMembership filter every period by the current instant,
so they are safe to gate on. useGrants and useSets return the namespaces as
they stand in the JWT, including expired periods — use them for display,
admin views, and debugging, not for a gate. Every one of the four returns the
guest or empty shape while Clerk is loading or the visitor is signed out.
Gate Platform Admin UI
Use useIsAdmin() (or useRole() when you need the raw value) for
platform-admin gates such as a developer console or staff-only tooling.
"use client";
import { useIsAdmin } from "@/lib/identity";
export function AdminLink() {
const isAdmin = useIsAdmin();
if (!isAdmin) return null;
return <a href="/admin">Admin console</a>;
}UI gates remain affordances only — pair every admin-gated UI with a Convex
requireAdmin(ctx) (or equivalent) check on the underlying write.
Convex Integration
Convex integration is the authoritative enforcement layer for Convex queries,
mutations, and actions. It uses ctx.auth.getUserIdentity() and the same
feature schema as the React app.
// convex/access.ts
import { createFeatureMiddleware } from "@nuucognition/identity/convex";
import { identityToolkit } from "../src/lib/identity";
export const access = createFeatureMiddleware(identityToolkit);Use requireFeature inside operations that perform protected work.
// convex/projects.ts
import { v } from "convex/values";
import { mutation } from "./_generated/server";
import { access } from "./access";
export const create = mutation({
args: {
orgSlug: v.string(),
name: v.string(),
},
handler: async (ctx, args) => {
await access.requireFeature(ctx, "createProject", {
activeOrgSlug: args.orgSlug,
});
// Perform the write after the access check.
},
});Use hasFeatureAccess when a backend path should return a non-throwing result.
const result = await access.hasFeatureAccess(ctx, "manageOrgBilling", {
activeOrgSlug: args.orgSlug,
});
if (!result.allowed) {
return { ok: false, reason: result.reason };
}Use the lower-level helpers when you do not need a named feature.
import {
type ConvexAuthCtx,
requireAdmin,
requireIdentity,
requireLevel,
requireOrgMember,
requireOrgRoleAtLeast,
requireSet,
} from "@nuucognition/identity/convex";
export async function enforceDemoPro(ctx: ConvexAuthCtx) {
// Throws ConvexError("Requires demo level: pro.") when the level is too low.
return requireLevel(ctx, "demo", "pro");
}
export async function enforceTester(ctx: ConvexAuthCtx) {
// Throws ConvexError("Requires access set: demoTesters.") when the set has
// no active period.
return requireSet(ctx, "demoTesters");
}
export async function enforceOrgAdmin(ctx: ConvexAuthCtx, slug: string) {
await requireIdentity(ctx);
await requireOrgMember(ctx, slug);
await requireOrgRoleAtLeast(ctx, slug, "admin");
}
export async function enforcePlatformAdmin(ctx: ConvexAuthCtx) {
// Throws ConvexError("Forbidden: admin role required.") unless the
// caller's projected `public_metadata.role` is "admin".
await requireAdmin(ctx);
}requireLevel and requireSet both call requireIdentity first, so a signed-out
caller gets ConvexError("You must be signed in."). A level that is not on the
app's ladder is a programming error and throws
ConvexError('Unknown level "<level>" for app <app>.').
Both return the ResolvedIdentity, so one call does the check and gives you the
projection:
const identity = await requireLevel(ctx, "demo", "pro");
identity.subject; // Clerk user id
identity.access.plan; // "pro"
identity.membership; // MembershipAccess
identity.grants; // active grant periods only
identity.sets; // active set periods only
identity.resolveApp("surf"); // AppAccess for another app, same instantgetIdentity captures one instant per call and evaluates every period against
it. grants and sets therefore hold active periods only; a key whose every
period has ended is dropped.
Convex checks should mirror UI checks, but the backend check is the one that matters for data integrity.
Metering
The app owns the event log. This package owns the arithmetic: where a cycle starts, how much of the plan allowance is left, and which source pays for a consumption. It reads no storage and writes no storage.
TIME UNITS: every time in the metering API is epoch MILLISECONDS — event
at, now, cycleStart, and cycleEnd. The projection uses seconds; the
metering functions convert what they read from AppAccess. The one exception is
poolId, which keeps the period start in seconds so the identifier matches the
JWT.
planConsumeCycle(planPeriod, now) returns the allowance cycle that contains
now.
import { planConsumeCycle, resolveAppAccess } from "@nuucognition/identity";
const access = resolveAppAccess(metadata, "demo");
const cycle = planConsumeCycle(access.planPeriod, Date.now());
// { cycleStart, cycleEnd } in epoch milliseconds, or null for a guestThe cycle anchor is the plan period start. Cycles are monthly anniversaries of that anchor in UTC. A shorter target month clamps to its last day, and the clamp never sticks because every cycle is computed from the anchor. The cycle is not clamped to the plan period end: the plan period is the truth for entitlement, and the cycle only resets the allowance.
planConsumption(events, item, amount, access, now) decides how to pay.
import { PLAN_CHARGE, planConsumption, poolId } from "@nuucognition/identity";
const result = planConsumption(events, "parses", 3, access, Date.now());
if (!result.ok) {
// { ok: false, planLeft, pools: PoolBalance[], cycleEnd }
throw new Error("Out of parses.");
}
// { ok: true, charges: [{ chargedTo: "plan", amount: 2 }, { chargedTo: "demo.parses.pack10#1745539200", amount: 1 }] }
for (const charge of result.charges) {
await ctx.db.insert("usage", { item: "parses", ...charge, at: Date.now() });
}The plan allowance pays first, then the pools, soonest end first, open-ended
last. chargedTo is PLAN_CHARGE ("plan") or a pool identifier from
poolId(pool). A pool identifier is the grant key and the period start, so a
second period of the same key is a second pool. Record every returned charge as
its own event; the next call reads them back.
The boundary rule. An app may read the plan and the grants. An app may not read Polar, prices, or the account app's Convex. Everything an app needs to meter a user is in the JWT projection and in this package.
Redirect And Session Bootstrap
Use the redirect entry point from Next.js middleware to send unauthenticated visitors to the shared account app.
// middleware.ts
import { createAuthMiddleware } from "@nuucognition/identity/redirect";
export default createAuthMiddleware({
publicPaths: ["/", "/sign-in", "/sign-up", "/auth/redirect"],
});
export const config = {
matcher: ["/((?!_next|.*\\..*).*)"],
};Use normalizeReturnTo() whenever accepting a return URL from a request.
import { normalizeReturnTo } from "@nuucognition/identity/redirect";
const safeReturnTo = normalizeReturnTo(searchParams.get("redirect_url"), {
origin: "https://app.nuucognition.com",
fallback: "/",
allowedOrigins: ["https://account.nuucognition.com"],
});Use buildSignInUrl() when assembling the redirect URL manually.
import { buildSignInUrl } from "@nuucognition/identity/redirect";
const signInUrl = buildSignInUrl({
redirectTo: "https://app.nuucognition.com/projects",
});Pure Server Or Utility Usage
The root entry point is safe for pure TypeScript utilities, server code, tests, and CLIs.
import {
createIdentityToolkit,
defineFeatures,
extractOrganisations,
extractPublicMetadata,
} from "@nuucognition/identity";
const features = defineFeatures({
manageOrgBilling: {
description: "Manage organisation billing",
requiresOrgRole: "admin",
},
});
const toolkit = createIdentityToolkit(features);
const metadata = extractPublicMetadata(userOrClaims);
const organisations = extractOrganisations(metadata);
const access = toolkit.getFeatureAccess("manageOrgBilling", metadata, {
activeOrgSlug: "nuucognition",
organisations,
});This is the same pattern used by @nuucognition/cli-core after it decodes a
stored Clerk JWT.
Caching Organisations And User Profiles
The JWT projection is intentionally compact: it carries plan names, organisation slugs, and roles — nothing else. Two things consumer apps routinely need to render are deliberately absent:
- Organisation display names, because
useOrganisations()returns{ slug, role }[]only. - Other users' profile data (name, picture, handle), because
useIdentity()is the current user only.
This is intentional — the 4 KB public_metadata budget keeps the JWT small,
and consumers cache display data per-app on demand. This section documents the
patterns. apps/operations is the canonical reference implementation; new
consumers should follow the same shape and use the @nuucognition/identity/orgs-lookup
helper described below.
What The JWT Does And Does Not Give You
| You need | Use |
| --- | --- |
| Current user's display name, image, username, email | useIdentity() from @nuucognition/identity/react. No cache needed. |
| List of organisations the current user belongs to (slug + role) | useOrganisations(). Slugs only. |
| Active organisation's display name | App-local cache, populated from fetchOrgsBySlugs(...). |
| Other users' profile data inside an org | App-local users table, populated by each user's own bootstrap. |
| Membership directory including users who haven't signed in | Out of scope for this pattern — use a server-side admin sync. |
Organisation Display Names
Apps cache organisation display names in a local Convex table, populated on demand from the Orgs service's public batch lookup endpoint.
Schema
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
organisations: defineTable({
orgSlug: v.string(),
type: v.union(v.literal("personal"), v.literal("org")),
name: v.optional(v.string()),
// Optional. When true, org-scoped mutations throw. Reads still work. Set
// manually during migration windows.
frozen: v.optional(v.boolean()),
}).index("by_orgSlug", ["orgSlug"]),
});Source Of Truth
The Orgs service exposes a public, CORS-permissive batch lookup at
POST /api/orgs/by-slugs. Use @nuucognition/identity/orgs-lookup instead of
hand-rolling the URL.
import { fetchOrgsBySlugs } from "@nuucognition/identity/orgs-lookup";
const orgs = await fetchOrgsBySlugs(["acme", "beta"]);
// → [{ slug: "acme", name: "Acme Corp", type: "org" }, ...]Defaults to the production Orgs deployment. Override the base URL for dev deployments:
// Convex action
const orgs = await fetchOrgsBySlugs(slugs, {
baseUrl: process.env.ORGS_CONVEX_SITE_URL,
});
// Browser
const orgs = await fetchOrgsBySlugs(slugs, {
baseUrl: process.env.NEXT_PUBLIC_ORGS_CONVEX_SITE_URL,
});Watch the host. Convex HTTP routes are served at
<deployment>.convex.site, NOT<deployment>.convex.cloud(the latter is the SDK URL). Using the wrong host is the most common bug when wiring an override. The default constantDEFAULT_ORGS_CONVEX_SITE_URLencodes this distinction in one place.
The helper trims, dedupes, and drops empty slugs before posting. It returns
[] without issuing a network call when the input is empty. Unknown slugs are
silently omitted from the response.
Lazy Init Per Workspace
When the user enters a workspace, look up the local row; if it's missing, call
an initOrg action that resolves the display name and writes the cache.
Personal workspaces (slug equals the Clerk subject) skip the HTTP fetch and use
a literal label.
// convex/organisations.ts
import { v } from "convex/values";
import { action, internalMutation, query } from "./_generated/server";
import { internal } from "./_generated/api";
import { fetchOrgsBySlugs } from "@nuucognition/identity/orgs-lookup";
export const getMyOrg = query({
args: { orgSlug: v.string() },
handler: async (ctx, args) => {
// Gate with @nuucognition/identity/convex helpers — see the Convex section.
return await ctx.db
.query("organisations")
.withIndex("by_orgSlug", (q) => q.eq("orgSlug", args.orgSlug))
.unique();
},
});
export const writeOrgRow = internalMutation({
args: {
orgSlug: v.string(),
type: v.union(v.literal("personal"), v.literal("org")),
name: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("organisations")
.withIndex("by_orgSlug", (q) => q.eq("orgSlug", args.orgSlug))
.unique();
if (existing) {
if (existing.type !== args.type || existing.name !== args.name) {
await ctx.db.patch(existing._id, { type: args.type, name: args.name });
}
return existing._id;
}
return await ctx.db.insert("organisations", args);
},
});
export const initOrg = action({
args: { orgSlug: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// Personal workspace: no HTTP fetch, literal label.
if (args.orgSlug === identity.subject) {
await ctx.runMutation(internal.organisations.writeOrgRow, {
orgSlug: args.orgSlug,
type: "personal",
name: "Personal",
});
return;
}
const [org] = await fetchOrgsBySlugs([args.orgSlug], {
baseUrl: process.env.ORGS_CONVEX_SITE_URL,
});
if (!org) throw new Error(`Organisation "${args.orgSlug}" not found`);
await ctx.runMutation(internal.organisations.writeOrgRow, {
orgSlug: org.slug,
type: org.type,
name: org.name,
});
},
});The client mounts a gate that triggers init when the cache row is missing:
// src/components/workspace-init-gate.tsx
"use client";
import { useEffect } from "react";
import { useAction, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
export function WorkspaceInitGate({
orgSlug,
isPersonal,
children,
}: {
orgSlug: string;
isPersonal: boolean;
children: React.ReactNode;
}) {
const org = useQuery(api.organisations.getMyOrg, { orgSlug });
const initOrg = useAction(api.organisations.initOrg);
useEffect(() => {
if (org !== null) return; // still loading or already initialised
void initOrg({ orgSlug }).catch(() => {
// Self-heals on the next render — log if you need observability.
});
}, [org, orgSlug, initOrg]);
if (org === undefined || org === null) return null; // or a skeleton
return <>{children}</>;
}Bulk Warm For Switchers
When an org switcher opens, batch-fetch every still-uncached slug the user can see in one call.
// convex/organisations.ts (continued)
export const cacheOrgNames = action({
args: { orgSlugs: v.array(v.string()) },
handler: async (ctx, args): Promise<Record<string, string>> => {
const orgs = await fetchOrgsBySlugs(args.orgSlugs, {
baseUrl: process.env.ORGS_CONVEX_SITE_URL,
});
const names: Record<string, string> = {};
for (const org of orgs) {
await ctx.runMutation(internal.organisations.writeOrgRow, {
orgSlug: org.slug,
type: org.type,
name: org.name,
});
names[org.slug] = org.name;
}
return names;
},
});The switcher reads the cached rows for the trigger label and triggers the warming action on first open. While the cache fills, render skeletons rather than the bare slug so a rename doesn't flash an old value.
Frozen Flag For Migration Windows
The optional frozen boolean lets operations pause writes during migrations
while reads stay live. Layer it on top of the access helpers from
@nuucognition/identity/convex:
import { requireOrgMember } from "@nuucognition/identity/convex";
export async function requireOrgWriteAccess(ctx, orgSlug: string) {
await requireOrgMember(ctx, orgSlug);
const org = await ctx.db
.query("organisations")
.withIndex("by_orgSlug", (q) => q.eq("orgSlug", orgSlug))
.unique();
if (org?.frozen) {
throw new Error("This workspace is frozen for migration. Try again later.");
}
}User Profiles
Use PersonProfile from @nuucognition/identity or @nuucognition/identity/convex for one person across NUU apps.
interface PersonProfile {
clerkUserId: string;
email: string | null;
firstName: string | null;
lastName: string | null;
displayName: string;
handle: string | null;
imageUrl: string | null;
}composeDisplayName(parts) trims every input and ignores empty strings.
It selects the first available value: full name, joined first and last names, handle, email, fallback, or "Someone".
personInitials(displayName) returns up to two uppercase initials, or "?" for an empty name.
profilesEqual(a, b) compares every profile field.
normalizePersonQuery(raw) trims the query, uses lowercase, and collapses whitespace.
A leading @ restricts the query to handles.
scorePerson(profile, query) uses the best matching score:
- Exact handle: 100.
- Handle prefix: 90.
- Display-name word prefix: 80.
- Display-name prefix: 70.
- Email prefix: 60.
- Text within the handle: 50.
- Text within the display name: 40.
- Text within the email: 30.
An empty query or no match returns null.
rankPeople(people, raw, limit?) sorts matches by score, then display name, then Clerk user id.
An empty query returns all people in name order.
The optional limit caps the result.
personSearchText(profile) joins the available display name, handle, and email in lowercase for a command-list value.
profileFromIdentity(identity) reads a raw Convex identity from ctx.auth.getUserIdentity().
It returns null without a non-empty string subject.
It reads email, givenName, familyName, name, and pictureUrl.
It accepts firstName, lastName, and imageUrl as aliases when the primary fields are absent.
It reads the handle through extractHandle(identity) and composes the display name with the subject as the fallback.
The table, the Convex functions, the bootstrap, and the search live in the Webcore people pattern (packages/webcore/patterns/people). An app adopts that pattern instead of writing its own users upsert.
Org members
An app that scopes people by organisation keeps an org_members table (orgSlug, clerkUserId) and fans the JWT org_memberships claim into it in the same bootstrap mutation, with extractOrganisations(identity). Each person writes their own rows on sign-in, so members appear in pickers without extra coordination. The people pattern's visible(ctx, viewer, scope) then reads that table to restrict list and search to the members of scope. Only people who signed into the app exist in the cache; a directory of every member needs an account-side lookup.
The table, the functions, and the bootstrap live in the Webcore people pattern (packages/webcore/patterns/people).
Handles
The account app owns the @handle registry and projects the canonical lowercase value into public_metadata.handle.
This package reads the claim and holds the shared format rule.
useIdentity().handleandgetIdentity(ctx).handlereturn the current user's handle. A missing or malformed claim returnsnull.extractHandle(source)reads the claim from a Clerk user or Convex identity.validateHandle(raw)checks lowercasea-z,0-9, and single hyphens. A handle has 3 to 20 characters and starts and ends with a letter or digit. A handle cannot contain--. The function returns the canonical lowercase handle or aformat/reservedreason with a message.HANDLE_REGEX,HANDLE_MIN_LENGTH,HANDLE_MAX_LENGTH, andRESERVED_HANDLESexpose the shared rule.rewriteLegacyHandle(legacy)converts an old underscore or mixed-case handle into the permitted characters. It never truncates the handle or adds a suffix.
Only the account app writes a handle.
Consumer apps read the JWT claim and use the people pattern to cache it for other users.
A rename reaches other apps when the Clerk session token refreshes, usually within about 60 seconds.
Only a user with an active paid plan can claim a handle.
A null handle is the normal state for a guest.
IdentityBundle.username and ResolvedIdentity.username remain for one release.
They read Clerk's username attribute, which is disabled on the NUU instance, so they are always null.
These fields are deprecated. Read handle.
Live-App Patterns
Use these patterns for production applications:
- Define features once per app and reuse the same schema in React and Convex.
- Use
requiresLevelfor product access. Use raw plan names only when the requirement is truly tied to one plan. - Pass the active organisation slug explicitly into both UI and backend checks.
- Treat React checks as affordances, not security.
- Enforce writes and sensitive reads in Convex or server handlers.
- Keep metadata writers app-owned. The identity package reads projections; it does not write them.
- Keep the usage event log in the app. Use
planConsumptionfor the sums. - Make loading states explicit.
useFeature()returnsisLoadingwhile Clerk is still loading. - Refresh or resync writer-owned projections when an app changes the underlying subscription or organisation state.
Avoid these patterns:
- Do not check
user.publicMetadata.subscriptionsor.grantsdirectly in UI components. - Do not gate on
useGrants()oruseSets(). They are not filtered by time. - Do not read Polar, prices, or the account app's Convex from a consumer app.
- Do not rename a grant key, an app name, a set name, or a membership name. Retire and add instead.
- Do not use organisation UUIDs for access checks against this package.
- Do not assume the first organisation membership is the active organisation.
- Do not use
useFeature()as the only protection for a write. - Do not add metadata write helpers to this package.
Testing Patterns
Most tests do not need Clerk. Test the projection shape directly.
import {
createIdentityToolkit,
defineFeatures,
extractEntitlements,
extractOrganisations,
resolveAppAccess,
} from "@nuucognition/identity";
// Every period is `[startEpochSec, endEpochSec]`. Build one window that covers
// "now" so a test exercises the field under test and not the period gate.
const YEAR = 365 * 24 * 60 * 60;
const nowSec = () => Math.floor(Date.now() / 1000);
const ACTIVE: [number, number] = [nowSec() - YEAR, nowSec() + YEAR];
const EXPIRED: [number, number] = [nowSec() - 2 * YEAR, nowSec() - YEAR];
const metadata = {
subscriptions: { basic: ACTIVE },
grants: { "demo.level.pro": [ACTIVE] },
sets: { demoTesters: ACTIVE },
org_memberships: { acme: "admin" },
};
const features = defineFeatures({
manageOrgBilling: {
description: "Manage organisation billing",
requiresLevel: { app: "demo", level: "pro" },
requiresOrgRole: "admin",
},
});
const toolkit = createIdentityToolkit(features);
expect(extractEntitlements(metadata).plans[0]?.name).toBe("basic");
expect(resolveAppAccess(metadata, "demo").level).toBe("pro");
expect(extractOrganisations(metadata)).toEqual([
{ slug: "acme", role: "admin" },
]);
expect(
toolkit.getFeatureAccess("manageOrgBilling", metadata, {
activeOrgSlug: "acme",
organisations: extractOrganisations(metadata),
}).allowed,
).toBe(true);Pin the instant with options.now instead of mocking the clock:
const future = new Date((nowSec() + 5 * YEAR) * 1000);
expect(toolkit.getFeatureAccess("manageOrgBilling", metadata, { now: future }).allowed)
.toBe(false);Recommended test coverage:
- No plan resolves to
guest. - An expired plan period denies a plan-gated feature.
- An active level grant raises the level above the plan cell.
- An expired level grant does not raise the level.
- A level that is not on the app's ladder denies the feature.
- An expired set period denies a set-gated feature.
- Unknown plan, app, and set names are ignored, not thrown.
- Invalid organisation roles are ignored.
- Missing
activeOrgSlugdenies org-gated features. - A stale or missing membership denies org-gated features.
- Convex operations call backend access helpers before writes.
Troubleshooting
Identity toolkit context is not available
The component using a hook is not below IdentityProvider, or it imported hooks
from a different integration instance. Export hooks from one app-local
src/lib/identity.ts file and import from there.
Feature is denied even though the user is signed in
Check the resolved projection:
- Does
public_metadata.subscriptionscontain a recognised plan key (with a[start, end]tuple)? - Is
Date.now()within[start, end)for that plan? Rememberend === 0means open-ended. - Does the feature require a level the plan cell does not reach? Check
resolveAppAccess(metadata, app).level. - If a level grant should raise it, is the grant key exactly right, and is one of its periods active?
- Does the feature require an access set with an active period?
- Does the feature require an organisation role?
- Did the caller pass
activeOrgSlug? - Does
public_metadata.org_memberships[activeOrgSlug]contain a valid role?
Organisation feature never appears in useAccessibleFeatures
Pass the active slug:
const features = useAccessibleFeatures({ activeOrgSlug: orgSlug });Without a slug, organisation-gated features are denied.
Next.js cannot import the package
Add @nuucognition/identity to transpilePackages. The package currently
exports TypeScript source for workspace consumers.
Convex bundle includes React or Clerk
Use @nuucognition/identity/convex from Convex files. Do not import the React
entry point from convex/.
Verification Commands
From the Main repo root:
pnpm --filter @nuucognition/identity test
pnpm --filter @nuucognition/identity typecheck
pnpm --filter @nuucognition/identity lint