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

hazo_subauth

v1.0.0

Published

Parent-owned child sub-profiles with consent records, visibility rules, and cascading GDPR delete

Readme

hazo_subauth

Parent-owned child sub-profiles with consent records, visibility rules, group links, and cascading GDPR delete.

A child sub-profile has no login credentials of its own — a parent (a hazo_users row from a peer hazo_auth install) owns one or more sub-profiles, each gated by its own consent record. Consent blocks play: a sub-profile isn't usable until its consent status is verified. Revoking consent is a single cascade — this package's own data is always cleaned up, and an injected GDPR anonymiser (e.g. a hazo_secure GdprRegistry) can fan the same revocation out across every other domain a consuming app registers (mastery, economy, whatever it has).

A sub-profile can also be linked to a hazo_auth group (e.g. a teacher's class) — the other half of hazo_auth's user ↔ group membership (ADR-0005), scoped per-role field visibility (a teacher sees progress, never a parent's email), and a pure eligibility check for data-expiry sweeps on orphaned, inactive profiles.

Installation

npm install hazo_subauth

Peer dependencies: hazo_auth (the parent's hazo_users.id — this package stores it as an opaque TEXT FK, no actual import), hazo_connect (the DB adapter every function below takes as its first argument), hazo_core.

Apply the package's own tables — migrations/0001_hazo_sub_profiles_and_consent.sql and migrations/0002_hazo_sub_profile_group_links.sql are picked up automatically by a consuming app's own runMigrations() call if you pass this package's migrations/ directory alongside your own; otherwise run db_setup_sqlite.sql (dev) or db_setup_postgres.sql (production) by hand once.

Quick start

import { createHazoConnect } from "hazo_connect/server";
import { createConsentService, createSubProfileService } from "hazo_subauth";

const adapter = createHazoConnect({ /* ... */ });

const consent = createConsentService(adapter, {
  // Transport-agnostic — plug in hazo_notify, Resend, whatever you already use.
  sendEmail: async ({ userId, code }) => sendEmailSomehow(userId, code),
  // Optional — wire a real hazo_secure GdprRegistry once you have cross-domain cleanup
  // to fan out to. Omit it and revokeConsent still cleans up this package's own rows.
  gdprAnonymiser: { anonymiseSubProfile: async (ctx) => myGdprRegistry.anonymiseUser(ctx) },
});
const subProfiles = createSubProfileService(adapter, consent);

// 1. Parent creates a child sub-profile — a "pending" consent record is created with it.
const profile = await subProfiles.createSubProfile(parentUserId, { displayName: "Ava" });

// 2. Parent enters the 6-digit code from the email.
await consent.verifyConsent(profile.consentId, "123456");

// 3. Now (and only now) the child can play.
const usable = await subProfiles.assertUsable(parentUserId, profile.id); // throws if not verified

// 4. Parent revokes consent later — cascades through the GDPR anonymiser, then deletes
//    this package's own sub-profile row. The consent record itself survives, revoked, as
//    the audit trail.
await subProfiles.revokeConsentAndDelete(parentUserId, profile.id);

API

createConsentService(adapter, options?)

  • createConsentRecord(userId, method?) — method defaults to "email"; "email-plus" and "credit-card" are accepted (for a sub-profile created against a future method) but verifyConsent rejects them with consent_method_not_implemented until implemented.
  • getConsentStatus(consentId)
  • verifyConsent(consentId, code) — single-use; throws invalid_verification_code on a wrong/expired/already-used/locked-out code (5 wrong attempts locks it out, independent of the TTL), consent_revoked if the record was revoked first. The code itself comes from a CSPRNG (node:crypto's randomInt) and is compared with timingSafeEqual.
  • revokeConsent(consentId, { subProfileId, ownerId }) — flips status, calls options.gdprAnonymiser if given. Does not delete the sub-profile row itself — see revokeConsentAndDelete below for the full cascade.

Options: sendEmail, gdprAnonymiser, now (injectable clock, for tests), codeTtlMinutes (default 15).

createSubProfileService(adapter, consentService, options?)

  • createSubProfile(ownerId, { displayName, profileMetadata?, consentMethod? })
  • getSubProfile(ownerId, id) — null if missing or not owned by ownerId.
  • getSubProfileOrThrow(ownerId, id) — throws sub_profile_not_found / owner_mismatch instead of returning null.
  • listSubProfiles(ownerId)
  • updateSubProfile(ownerId, id, { displayName?, profileMetadata? })
  • deleteSubProfile(ownerId, id) — hard delete, no consent involvement.
  • revokeConsentAndDelete(ownerId, id) — the cascade: revokes consent (see above), then always deletes this package's own sub-profile row, even with no gdprAnonymiser given.
  • assertUsable(ownerId, id) — throws consent_required (pending) / consent_revoked, otherwise returns the profile plus its live consent status in one read.

createGroupLinkService(adapter, options?)

A sub-profile ↔ group link — the other half of hazo_auth's user ↔ group membership (hazo_groups + hazo_group_memberships, ADR-0005): a teacher is a group member there, a child's sub-profile is linked here. groupId is opaque TEXT to this package, same posture as ownerId/userId — no import of hazo_auth.

  • linkToGroup(subProfileId, groupId, linkedByUserId) — linkedByUserId is consent proof (the parent who performed the link). Throws group_link_exists if the pair already has an active link.
  • unlinkFromGroup(subProfileId, groupId) — sets the link removed (never deletes the row); returns false if there was no active link to remove. Relinking the same pair afterwards reuses the row (no group_link_exists).
  • listGroupLinks(subProfileId) — active links only, for one sub-profile.
  • listSubProfilesInGroup(groupId) — active links only, for one group. This is the seam a consuming app uses to enforce "teacher scoped to own groups only": call it with a groupId the teacher is actually a hazo_auth member of, never one a client supplies.

createVisibilityService(overrides?)

Per-role field-visibility (allow-lists, not deny-lists — a field absent from a role's list is denied by omission). Pure — no DB/Node imports, also exported from ./client.

  • getVisibleFields(role, scopeContext?) — scopeContext is reserved for Phase 3B (coordinator/principal); every role shipped today ignores it.
  • getScope(role) — "all" or "own_groups", documentation for how a consuming app should further restrict which rows it reads (via listSubProfilesInGroup) — this package can't enforce row-level access itself.
  • filterSubProfileData(data, visibleFields) — picks the allow-listed keys out of any plain object. Pass it a combined view (this package's SubProfile plus a consuming app's own app-level fields — mastery%, practice time, ...) in one call.
  • getRuleConfig(role) — the full { fields, scope } for a role.

Defaults (DEFAULT_VISIBILITY_RULES, spec "Visibility Rules"):

| Role | Fields | Scope | |---|---|---| | parent | display_name, profile_metadata, consent_status, mastery_percent, practice_time, accuracy, topics_practiced, group_name, teacher_name | all | | teacher | display_name, mastery_percent, practice_time, accuracy, topics_practiced | own_groups | | coordinator | (none yet) | own_groups | | principal | (none yet) | own_groups |

Override a role's field list without losing the others' defaults:

const visibility = createVisibilityService({ teacher: { fields: [VISIBILITY_FIELDS.DISPLAY_NAME] } });

A consuming app wanting these editable via hazo_config's ConfigEditor stores its own override shape there and passes it into createVisibilityService — this package never imports hazo_config.

isExpirable(input, options?) / createDataExpiryService(groupLinkService, options?)

Data-expiry eligibility for orphaned sub-profiles (spec: "no group links + no activity within configurable days"). isExpirable is pure — this package owns no "last activity" data of its own (that's a consuming app's app_mastery/app_game_state), so you resolve lastActivityAt yourself and hand it in:

isExpirable({ hasGroupLinks: false, lastActivityAt: "2026-01-01T00:00:00Z" }, { inactivityDays: 90 }); // true

createDataExpiryService composes a real GroupLinkService so a background job (spec ticket 07's data-expiry-sweep) can just ask per sub-profile:

const expiry = createDataExpiryService(groupLinks, { inactivityDays: 90 });
await expiry.isSubProfileExpirable(subProfileId, lastActivityAt);

A group-linked sub-profile is never expirable, regardless of activity.

hazo_subauth/ui

Client components ("use client") — dumb/controlled, same "accept already-resolved facts" posture as the server services: none of them fetch or mutate on their own.

  • ConsentStatusBadge({ status }) — a small status pill.
  • SubProfileCard({ subProfile, consentStatus, onEdit?, onDelete?, onRevokeConsent? }) — a parent-facing summary card. onRevokeConsent is the cascade entry point (revokeConsentAndDelete); onDelete is a direct, non-cascading delete.
  • SubProfileForm({ initialValue?, metadataFields?, onSubmit, onCancel? }) — create/edit form for displayName + profileMetadata. metadataFields is the caller's own list of keys to render (this package has no opinion on what's in profileMetadata).
  • GroupLinkManager({ links, onLink, onUnlink, groupNames? }) — link-by-join-code UI; resolving the join code to a groupId and calling linkToGroup is the caller's job.

Styling

These components use plain Tailwind v4 utility classes — no hazo_ui/design-token dependency. A consuming app's Tailwind entry CSS needs to scan this package's compiled dist/ for the classes to be picked up:

@import "tailwindcss";
@source "../node_modules/hazo_subauth/dist"; /* adjust the relative depth to your app */

Errors

Every thrown error is a SubAuthError (.code, .statusCode) with one of: sub_profile_not_found, consent_required, consent_revoked, visibility_denied (reserved — this package filters by omission instead, see createVisibilityService above; no code path throws it yet), group_link_exists, owner_mismatch, consent_method_not_implemented, invalid_verification_code. Input validation (e.g. an empty displayName) throws a plain SubProfileValidationError instead.

hazo_subauth/client

Types (SubProfile, ConsentRecord, ConsentStatus, ConsentMethod, SubProfileMetadata, GroupLink, VisibilityRole, VisibilityRuleConfig) + isConsentUsable(status) + CONSENT_STATUSES + the whole of createVisibilityService (pure — no DB/Node imports). No DB/Node imports anywhere in this subpath — safe to import from a browser bundle. No React components here — those live in ./ui.

License

MIT