logicspike/docs

Guides

Core Packages TypeScript Refactoring Guide

As LogicSpike moves from a "User-glued-to-one-Tenant" model to a decoupled "Identity-with-multiple-Memberships" model, our foundational internal NPM packages (@repo/core-types, @repo/core-auth, @repo/core-access, @repo/core-tenant) require a significant type overhaul.

This document serves as a guide for developers to understand why the types are changing and how the new models interact.


1. Decoupling Identity: @repo/core-types

The Old World

Previously, an authenticated user's token directly answered: "What role are they?"

export interface User {
  id: UserId
  tenant_id: TenantId
  role: UserRole // 'owner', 'admin', etc.
}

Problem: This schema prevents a user from existing in two workspaces simultaneously because the tenant_id and role are hardcoded to the human identity. If they join another workspace, their role might be completely different.

The New World

Identity (User) is now completely separate from Authorization (Membership and Role).

  1. User (Identity) Represents the human being globally across the platform.

    export interface User {
      id: UserId
      email: string
      name: string
      default_tenant_id: TenantId
      phone?: string
      phone_verified: boolean
      is_two_factor_enabled: boolean
      two_factor_method?: "email" | "phone" | "totp"
    }

    Notice: role and tenant_id are gone from the core definition. default_tenant_id is just a UX convenience.

  2. Membership (The Link) Represents access to a specific workspace.

    export interface Membership {
      id: string
      user_id: UserId
      tenant_id: TenantId
      role_id: string 
      is_owner: boolean // True ONLY for the workspace creator
    }
  3. Role & Permission (RBAC) Roles are now dynamic collections of permissions.

    export interface Role {
      id: string
      tenant_id: TenantId | null // null = Global System Role (e.g., "Default Admin")
      name: string
      permissions: string[] // e.g., ["blog:posts.update", "team:members.invite"]
    }

2. Token Claims (JWTs)

When a user logs in, the Manager service looks at their Membership for the selected Tenant, unpacks the Role, and embeds the final flattened permissions directly into the short-lived JWT token.

New JWTClaims Interface

// @repo/core-types/src/auth.ts
export interface JWTClaims {
  user_id: UserId
  tenant_id: TenantId
  // The crucial change:
  permissions: string[] 
  
  services: Partial<Record<ServiceCode, ServiceAccess>>
}

Microservices (like Blog or Media) no longer care what the user's role is named. They only check if the permissions array contains what they need.


3. Authorization Engine: @repo/core-access

The core access engine will be refactored to verify intent against the token's permissions array.

Old Pattern (Role-Based)

// BAD: Roles are rigid. What if a "Moderator" should also delete?
if (token.role !== "admin" && token.role !== "owner") {
  throw new UnauthorizedError()
}

New Pattern (Permission-Based)

// packages/core-access/src/access.rules.ts
export function assertUserHasPermission(token: JWTClaims, requiredPermission: string) {
  // 1. Owners bypass everything
  if (token.permissions.includes("system:owner")) {
    return true
  }
 
  // 2. Exact match
  if (!token.permissions.includes(requiredPermission)) {
    throw new UnauthorizedError(`Requires permission: ${requiredPermission}`)
  }
 
  return true
}

Usage in Microservices:

// Inside apps/blog-service/src/routes/posts.ts
app.delete("/:id", (c) => {
    assertUserHasPermission(c.get("jwtPayload"), "blog:posts.delete");
    // proceed to delete...
})

4. Upgrading Cryptography: @repo/core-auth

As we move toward a true decentralized microservice architecture (where Gateway, Blog, Media, Manager, etc. run completely isolated), sharing the symmetric JWT_SECRET everywhere becomes a major security risk.

If the Media service is compromised, the attacker steals the JWT_SECRET and can forge tokens to access the Billing service.

Asymmetric Keys (RS256)

We are refactoring core-auth/src/jwt.ts to use Private/Public Key pairs (RS256).

  1. Manager Service (Identity Provider): Holds the Private Key. It is the ONLY service capable of generating and signing tokens (signAccessToken).
  2. All Other Services: Hold the Public Key. They can seamlessly verify the token's authenticity (verifyAccessToken) but cannot create new spoofed tokens.
// core-auth/src/jwt.ts
export async function signAccessToken(payload: JWTClaims, privateKey: string) {
  return sign(payload, privateKey, "RS256")
}
 
export async function verifyAccessToken(token: string, publicKey: string) {
  return verify(token, publicKey, "RS256")
}

5. Security Summary Checklist

When reviewing the refactored code in the future, ensure these invariants hold:

  • No database schema should attach a role directly to a user.
  • Microservices should never query the database for a user's role. They trust the permissions array embedded inside the signed JWT.
  • Only the Manager service has the PRIVATE_KEY initialized in its environment variables.
  • core-access logic strictly evaluates service:resource.action strings (like billing:account.manage).
Guides