hazo_subauth
v1.0.0
Published
Parent-owned child sub-profiles with consent records, visibility rules, and cascading GDPR delete
Maintainers
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_subauthPeer 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?)—methoddefaults to"email";"email-plus"and"credit-card"are accepted (for a sub-profile created against a future method) butverifyConsentrejects them withconsent_method_not_implementeduntil implemented.getConsentStatus(consentId)verifyConsent(consentId, code)— single-use; throwsinvalid_verification_codeon a wrong/expired/already-used/locked-out code (5 wrong attempts locks it out, independent of the TTL),consent_revokedif the record was revoked first. The code itself comes from a CSPRNG (node:crypto'srandomInt) and is compared withtimingSafeEqual.revokeConsent(consentId, { subProfileId, ownerId })— flips status, callsoptions.gdprAnonymiserif given. Does not delete the sub-profile row itself — seerevokeConsentAndDeletebelow 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)—nullif missing or not owned byownerId.getSubProfileOrThrow(ownerId, id)— throwssub_profile_not_found/owner_mismatchinstead of returningnull.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 nogdprAnonymisergiven.assertUsable(ownerId, id)— throwsconsent_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)—linkedByUserIdis consent proof (the parent who performed the link). Throwsgroup_link_existsif the pair already has an active link.unlinkFromGroup(subProfileId, groupId)— sets the link removed (never deletes the row); returnsfalseif there was no active link to remove. Relinking the same pair afterwards reuses the row (nogroup_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 agroupIdthe 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?)—scopeContextis 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 (vialistSubProfilesInGroup) — 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'sSubProfileplus 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 }); // truecreateDataExpiryService 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.onRevokeConsentis the cascade entry point (revokeConsentAndDelete);onDeleteis a direct, non-cascading delete.SubProfileForm({ initialValue?, metadataFields?, onSubmit, onCancel? })— create/edit form fordisplayName+profileMetadata.metadataFieldsis the caller's own list of keys to render (this package has no opinion on what's inprofileMetadata).GroupLinkManager({ links, onLink, onUnlink, groupNames? })— link-by-join-code UI; resolving the join code to agroupIdand callinglinkToGroupis 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
