Developer Docs

Identity Security & Cross-Subject Q&A

This guide covers two tightly coupled systems that were hardened together: the Mallnline Identity Sigil System โ€” which ensures all user-facing surfaces display v|handle identities instead of raw UUIDs โ€” and the Cross-Subject Q&A Architecture โ€” which enables a Malet's community page to aggregate questions from all of its Products and Services in a single listing.


Identity Sigil Enforcement

Every surface in the platform that renders a user identity MUST comply with the Mallnline Sigil Taxonomy. Raw UUIDs are classified as P0 security violations and are explicitly banned from URLs, UI text, and log output visible to Visitors.

Sigil Format

Sigil Entity Example Use case
v| Visitor v|meekdenzo All user identities in community conversations
m| Malet m|fashion-demo Malet handles in navigation and badges
o| Organization o|acme-corp Org identities in team dashboards
r| Malet Rep r|sarah Staff member identification

ActorBadge Rules

The ActorBadge component enforces these identity rules:

  1. Always resolve profiles โ€” All userId references pass through resolveUserProfiles() to obtain handle, displayName, and avatarUrl.
  2. Always display sigils โ€” The v| prefix is prepended to every resolved identity, whether the Visitor has a claimed handle or not.
  3. Non-clickable unclaimed handles โ€” If a Visitor has no claimed handle, the badge renders without a link. This prevents raw UUID exposure in the browser URL bar.
  4. Claimed handles link to /u/:handle โ€” Never /u/:userId.
<!-- โœ… Correct โ€” uses sigil -->
<ActorBadge userId={question.authorId} {profiles} />

<!-- โŒ Never render raw IDs -->
<span>{question.authorId}</span>

Post-Mutation Profile Resolution

When creating new content (questions, replies, reviews), the author's createdBy ID is returned from the mutation but may not yet exist in the client-side ProfileMap. The pattern:

// After creating a reply:
const result = await client.request(CREATE_QUESTION_COMMENT, { input });

// Immediately resolve the new author's profile
if (result.createOneQuestionComment.createdBy) {
  resolveUserProfiles(
    [result.createOneQuestionComment.createdBy],
    profiles
  ).then((resolved) => {
    profiles = resolved;
  });
}

This ensures the ActorBadge displays v|handle immediately, without requiring a page reload.


Cross-Subject Q&A (maletId Architecture)

Problem

Product-level questions have subjectId = productId and subjectType = PRODUCT, while the community page historically filtered by subjectId = maletId. This meant product Q&A was invisible on the community listing.

Solution: Denormalized `maletId`

Every Question entity now carries a maletId field alongside subjectId, establishing the parent Malet regardless of subject type:

Field Description
maletId Always the parent Malet ID. Set on every question.
subjectId The specific entity the question is about (Product, Service, or Malet).
subjectType PRODUCT, SERVICE, or MALET.

For malet-level questions, maletId === subjectId. For product/service questions, maletId is the parent Malet while subjectId is the item.

Updated Entity Schema

@FilterableField({
  description: 'ID of the parent Malet. Always set regardless of subjectType.',
})
@prop({ required: true })
maletId!: string;

Query Routing

The QASection component intelligently routes between two queries based on context:

Context Query Filter Shows
Community page (subjectType = MALET) GET_QUESTIONS_BY_MALET maletId: { eq: $maletId } All questions across products + malet
Product/Service page GET_QUESTIONS subjectId: { eq: $subjectId } Only questions for that specific item
# Community page โ€” aggregates all questions for a Malet
query GetQuestionsByMalet($maletId: String!, $first: Int!) {
  questions(
    filter: { maletId: { eq: $maletId } }
    paging: { first: $first }
    sorting: [{ field: createdAt, direction: DESC }]
  ) {
    edges {
      node {
        id, questionID, body, authorId, maletId,
        subjectId, subjectType, status, ...
      }
    }
    totalCount
  }
}

CreateQuestion Mutation

The maletId field is now required in the QuestionInputDTO:

{
  "input": {
    "question": {
      "body": "Does this come in other colors?",
      "maletId": "3fm6914mo3zl9yt0hqfey",
      "subjectId": "Cxs5qtAw9T",
      "subjectType": "PRODUCT"
    }
  }
}

Frontend Props

QASection now accepts a maletId prop:

<!-- Community page: maletId = malet.id, subjectId = malet.id -->
<QASection
  {questions}
  totalCount={totalQuestions}
  maletId={malet.id}
  subjectId={malet.id}
  subjectType="MALET"
/>

<!-- Item page: maletId from parent layout, subjectId = item.id -->
<QASection
  {questions}
  totalCount={totalQuestions}
  maletId={data.malet?.id || ''}
  subjectId={item.id}
  subjectType={type}
  {isOwner}
/>

When a Visitor opens a product-scoped question from the community page, they need a way to navigate back to the product. The question detail page sidebar now includes a Subject section.

Architecture

The question detail page loader resolves the item name and slug via federation single-entity queries:

// For PRODUCT questions
const GET_PRODUCT_NAME = gql`
  query GetProductName($id: ID!) {
    product(id: $id) { id name slug }
  }
`;

// For SERVICE questions
const GET_SERVICE_NAME = gql`
  query GetServiceName($id: ID!) {
    service(id: $id) { id name slug }
  }
`;

These are lightweight queries that only fetch what's needed for the sidebar link โ€” no heavy product detail payloads.

Display Rules

Condition Sidebar shows
subjectType = PRODUCT and item resolved "Subject" โ†’ card link to /{malet}/item/{slug}
subjectType = SERVICE and item resolved "Subject" โ†’ card link to /{malet}/item/{slug}
subjectType = MALET No Subject section shown
Item resolution fails No Subject section shown (graceful degradation)

Security Audit Checklist

Every feature that renders user identity MUST pass this audit before merge:

โœ“ Is every userId rendered via ActorBadge (not raw text)?
โœ“ Does every profile link use /u/:handle (not /u/:uuid)?
โœ“ Do all sigils use v| prefix (not @)?
โœ“ After mutations, are new actor profiles resolved into ProfileMap?
โœ“ Do date formatters handle epoch string timestamps?
โœ“ Does the maletId field propagate correctly in create mutations?

Both the frontend (frontend-implement.md) and backend (implement.md) workflow files now include these checks in their Quality Checklists.