Docs·8777c5dd·Updated Aug 7, 2026·95 ADRs
All Services

Auth Service

Port 3001productioncritical

19

API Endpoints

0

Service Deps

2

Infrastructure

1

DB Schemas

API Endpoints

POST
/founding-circle/submissions

**Public — no auth.** Persist a founding-circle note from the static `karmyq.org/join` form

GET
/founding-circle/submissions

**Authenticated founding-circle reviewer only.** Lists persisted founding-circle submissions newest

PATCH
/founding-circle/submissions/:id/status

**Authenticated founding-circle reviewer only.** Marks a submission `new`, `reviewed`, `contacted`,

POST
/register

Register a new user account.

POST
/login

Authenticate user and receive JWT token.

POST
/auth/refresh

Issue a new access token using a refresh token. Rotates the refresh token on each call (old token revoked, new one issued). Replay attack protection: if an already-used token is presented, all tokens

POST
/auth/demo-session

Issue a short-lived (30-minute) **read-only** Maria demo session for the guided `karmyq.com/demo`

GET
/verify

Verify JWT token and return user info.

GET
/users/:id

Get user profile by ID.

PUT
/users/:id

Update user profile.

GET
/preferences/request-types

Get user's request type subscriptions (v9.0).

POST
/preferences/request-types

Update a single request type subscription.

PUT
/preferences/request-types/bulk

Bulk update request type subscriptions.

GET
/preferences/interests

Get user's interest categories (service_category, item_category, event_type).

POST
/preferences/interests

Add a new interest.

DELETE
/preferences/interests/:id

Remove an interest.

GET
/preferences/feed (ADR-022)

Get user's feed visibility preferences for multi-tier feed.

PUT
/preferences/feed (ADR-022)

Update user's feed visibility preferences.

POST
/auth/push-tokens

Register an Expo push token for the authenticated user (Sprint 41).

DELETE
/auth/push-tokens

Remove an Expo push token for the authenticated user (Sprint 41).

GET
/health

Service health check.

Infrastructure

postgresredis

Full Documentation

Auth Service Context

Quick Start: cd services/auth-service && npm run dev Port: 3001 | Health: http://localhost:3001/health

Purpose

Handles user authentication, registration, and JWT token management for the KarmyQ platform.

Database Schema

Sprint 132 PR A — skills single source (ADR-099)

auth.user_tags with tag_type='skill' is the member skill store used by matching. auth.skill_vocabulary holds slug VARCHAR(50) (primary key), label VARCHAR(100) and synonyms TEXT[]. user_tags.skill_slug is a nullable foreign key into that vocabulary; the user_tags_skill_slug_only_on_skills check rejects a non-null slug on other tag types. Migration 20260930-skill-vocabulary.sql resolves existing tags and copies the legacy picker selections into tags. Both backfills trim/lowercase/collapse whitespace and resolve exact slugs, labels or synonyms. Legacy text outside the vocabulary is preserved with a null slug; exact tag-text collisions keep the existing tag. Replaying the migration leaves tag IDs and values unchanged. Vocabulary rows also live in seed-data.sql for fresh installations.

auth.user_skills is deprecated and retained for image rollback. New code neither writes nor reads it for matching. Tags created after this migration are not mirrored to the legacy table, so a rolled-back image retains the old skill snapshot until the new images are restored.

GET /auth/profile/tags returns current-caller tags grouped as skills, interests, needs, with { id, tag_value, skill_slug } per tag. POST /auth/profile/tags accepts tag_type and tag_value, resolves a skill exactly after trim/lower/whitespace normalization against a slug, label or synonym, and returns { id, tag_type, tag_value, skill_slug } (null on duplicate). Unresolved skills and non-skill tags have a null slug. Caller ownership comes from the JWT; GET, POST and DELETE remain caller-scoped. The existing tag uniqueness constraint is unchanged. GET /auth/profile/tags/suggestions?tag_type=skill reads vocabulary labels alphabetically; interest and need suggestions retain their constants. The three legacy GET/POST /users/:userId/skills and DELETE /users/:userId/skills/:skillId handlers are removed and return 404. The profile uses only ProfileTagsSection and displays a "matched to" hint.

Tables Owned by This Service

-- auth.users
CREATE TABLE auth.users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    phone VARCHAR(20),
    location JSONB,
    bio TEXT,
    skills TEXT[],
    profile_picture_url TEXT,
    status VARCHAR(50) DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Indexes
CREATE INDEX idx_users_email ON auth.users(email);
CREATE INDEX idx_users_status ON auth.users(status);

-- Federation columns (v4.0+)
ALTER TABLE auth.users
ADD COLUMN federated_id VARCHAR(255) UNIQUE,
ADD COLUMN allow_federation BOOLEAN DEFAULT true,
ADD COLUMN federation_privacy JSONB;

-- auth.user_feed_preferences (ADR-022 Multi-Tier Feed) CREATE TABLE auth.user_feed_preferences ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, feed_show_trust_network BOOLEAN DEFAULT true, feed_trust_network_max_degrees INTEGER DEFAULT 3 CHECK (feed_trust_network_max_degrees BETWEEN 1 AND 6), feed_show_platform BOOLEAN DEFAULT false, feed_platform_categories JSONB DEFAULT '["digital", "questions"]'::jsonb, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );


```sql
-- auth.device_push_tokens (Sprint 41)
CREATE TABLE auth.device_push_tokens (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    expo_push_token TEXT NOT NULL,
    platform VARCHAR(20) NOT NULL,   -- 'ios', 'android'
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(user_id, expo_push_token)
);

-- auth.refresh_tokens (Sprint 54 — ADR-052)
CREATE TABLE auth.refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    token_hash VARCHAR(64) NOT NULL UNIQUE,  -- SHA-256 hash of the raw token
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ,
    revoked BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- auth.founding_circle_submissions (Sprint 96 — ADR-076)
-- Public, unauthenticated landing-page intake. Pre-account leads: no FK to auth.users.
CREATE TABLE auth.founding_circle_submissions (
    id            UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email         VARCHAR(320) NOT NULL,
    lens          VARCHAR(200),
    contribution  TEXT,
    concern       TEXT,
    source_page   VARCHAR(64)  NOT NULL DEFAULT 'join',
    status        VARCHAR(24)  NOT NULL DEFAULT 'new',  -- new | reviewed | contacted | archived
    created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    reviewed_at   TIMESTAMP
);

Tables Read by This Service

  • communities.members - Sprint 103 founding-circle reviewer check: allowlisted reviewers must also be active community admins to list/update intake submissions.

API Endpoints

POST /founding-circle/submissions

Public — no auth. Persist a founding-circle note from the static karmyq.org/join form (Sprint 96, ADR-076). Validated and honeypot-screened; persist-only (no email notify).

Request:

{
  "email": "you@example.com",
  "lens": "community organizer",
  "contribution": "What you'd bring.",
  "concern": "The hardest concern to face.",
  "website": ""
}
  • email required, valid shape, ≤ 320 chars; lens ≤ 200; contribution/concern ≤ 4000.
  • website is a honeypot — a non-empty value returns success without persisting (silent drop).

Response (201):

{ "success": true, "data": { "id": "<uuid>" }, "meta": { "timestamp": "…", "requestId": "…" } }

Errors use the ADR-074 contract (sendValidationError / sendInternalError). Mounted without a per-route limiter — the app-wide globalRateLimiter applies.

Implementation: src/routes/foundingCircle.ts

GET /founding-circle/submissions

Authenticated founding-circle reviewer only. Lists persisted founding-circle submissions newest first, optionally filtered by status=new|reviewed|contacted|archived, with limit and offset pagination.

For Sprint 103, a reviewer must be explicitly allowlisted via FOUNDING_CIRCLE_REVIEWER_IDS or FOUNDING_CIRCLE_REVIEWER_EMAILS and must also have at least one active communities.members row where role='admin'. If neither allowlist is configured, every authenticated user receives 403 FORBIDDEN. This is intentionally stricter than the generic admin UI gate because demo/test community-admin credentials must not expose real founding-circle messages. No email, Slack, webhook, queue event, or notification is sent.

Response (200):

{
  "success": true,
  "data": {
    "items": [
      {
        "id": "<uuid>",
        "email": "you@example.com",
        "lens": "community organizer",
        "contribution": "What you'd bring.",
        "concern": "The hardest concern to face.",
        "source_page": "join",
        "status": "new",
        "created_at": "2026-06-17T00:00:00.000Z",
        "reviewed_at": null
      }
    ],
    "count": 1,
    "limit": 50,
    "offset": 0
  }
}

Implementation: src/routes/foundingCircle.ts, src/database/foundingCircleDb.ts

PATCH /founding-circle/submissions/:id/status

Authenticated founding-circle reviewer only. Marks a submission new, reviewed, contacted, or archived. reviewed_at is set the first time a submission leaves new and is preserved afterward. Unknown IDs return 404 NOT_FOUND; invalid statuses return 400 VALIDATION_ERROR.

Request:

{ "status": "reviewed" }

Implementation: src/routes/foundingCircle.ts, src/database/foundingCircleDb.ts

POST /register

Register a new user account.

Request:

{
  "name": "Alice Smith",
  "email": "alice@example.com",
  "password": "securePassword123"
}

Response:

{
  "success": true,
  "user": {
    "id": "uuid-here",
    "name": "Alice Smith",
    "email": "alice@example.com",
    "created_at": "2025-01-10T12:00:00Z"
  },
  "token": "jwt-token-here (1hr lifetime)",
  "refreshToken": "raw-refresh-token-here (7-day lifetime)"
}

Implementation: src/routes/auth.ts:15

POST /login

Authenticate user and receive JWT token.

Request:

{
  "email": "alice@example.com",
  "password": "securePassword123"
}

Response:

{
  "success": true,
  "user": {
    "id": "uuid-here",
    "name": "Alice Smith",
    "email": "alice@example.com"
  },
  "token": "jwt-token-here (1hr lifetime)",
  "refreshToken": "raw-refresh-token-here (7-day lifetime)"
}

Implementation: src/routes/auth.ts:45

POST /auth/refresh

Issue a new access token using a refresh token. Rotates the refresh token on each call (old token revoked, new one issued). Replay attack protection: if an already-used token is presented, all tokens for that user are revoked.

Request:

{
  "refreshToken": "raw-refresh-token-here"
}

Response:

{
  "success": true,
  "data": {
    "token": "new-jwt-token (1hr lifetime)",
    "refreshToken": "new-refresh-token (7-day lifetime)"
  }
}

Implementation: src/routes/auth.ts (ADR-052, Sprint 54)

POST /auth/demo-session

Issue a short-lived (30-minute) read-only Maria demo session for the guided karmyq.com/demo story (Sprint 116, ADR-084). No refresh token is ever issued. The signed JWT carries sessionMode: 'demo_read_only'; the shared auth middleware rejects any non-GET/HEAD/OPTIONS method server-side, so the session physically cannot mutate data.

Gated entirely by environment (DEMO_SESSION_ENABLED, DEMO_PERSONA_EMAIL, DEMO_ORDINARY_REQUEST_ID, DEMO_ORDINARY_MATCH_ID, DEMO_PROVIDER_REQUEST_ID, DEMO_PROVIDER_OFFER_ID). Before signing, the service verifies the resolved persona is an active, non-admin @test.karmyq.com account and that both stories are coherent (Maria owns each request; the match/offer hang off the correct request). Every config/state failure — disabled, missing IDs, wrong persona, incoherent rows, or an unexpected error — collapses to a single opaque 503 DEMO_UNAVAILABLE so resource existence is never leaked.

Response (200):

{
  "success": true,
  "data": {
    "user": { "id": "…", "email": "maria.reyes@test.karmyq.com", "name": "Maria Reyes", "communities": [] },
    "token": "demo-jwt (30 min, sessionMode=demo_read_only)",
    "demo": {
      "expiresInMinutes": 30,
      "stories": [
        { "kind": "ordinary", "requestId": "…", "matchId": "…" },
        { "kind": "provider", "requestId": "…", "offerId": "…" }
      ]
    }
  }
}

Implementation: src/services/demoSessionService.ts + src/routes/auth.ts; read-only write guard in packages/shared/middleware/auth.ts (Sprint 116, ADR-084).

The config surface, and how it goes stale (Sprint 129)

⚠️ DEMO_SESSION_ENABLED defaults to false (infrastructure/docker/docker-compose.yml:90) and the five id vars default to empty (:91-95). The check is a strict !== 'true', so false, 1 and TRUE are all OFF while still looking "set".

⚠️ The four story ids point at rows that are deleted on a schedule. cleanup-service marks an open request expired once expires_at passes (expirationJob.ts:18-22, hourly) and hard-deletes it seven days after that marking (:84-88, daily at 02:00). The demo config therefore goes stale on a timer — this is what caused BUG-039. The answer is to rotate before they age out:

cd ~/karmyq && set -a && . ./.env.demo.rotation && set +a
npm --workspace @karmyq/simulation-service run rotate:demo-stories -- --apply --publish-config

See docs/guides/demo-data.md. .github/workflows/demo-health.yml warns ~14 days ahead.

Diagnosing a 503 (Sprint 129)

The opaque response is deliberate and unchanged, but the reason is now logged server-side at warn with a structured reason field — ADR-084's opacity binds the client, not the operator (see its Sprint 129 amendment). Before this, every expected cause was silent and the endpoint was undiagnosable by design.

src/services/demoSessionSelfCheck.ts also runs once at boot: it reports healthy, reports the specific failure reason, or says explicitly that demo sessions are disabled — so "off" is never mistaken for "broken". It never throws (auth is Critical with seven dependents; the demo is optional) and never logs the issued token.

GET /verify

Verify JWT token and return user info.

Headers:

Authorization: Bearer <jwt-token>

Response:

{
  "success": true,
  "user": {
    "id": "uuid-here",
    "name": "Alice Smith",
    "email": "alice@example.com"
  }
}

Implementation: src/routes/auth.ts:75

GET /users/:id

Get user profile by ID.

Response:

{
  "success": true,
  "user": {
    "id": "uuid-here",
    "name": "Alice Smith",
    "email": "alice@example.com",
    "bio": "Community gardener",
    "skills": ["gardening", "cooking"],
    "created_at": "2025-01-10T12:00:00Z"
  }
}

Implementation: src/routes/users.ts:10

PUT /users/:id

Update user profile.

Request:

{
  "name": "Alice Johnson",
  "bio": "Updated bio",
  "skills": ["gardening", "carpentry", "cooking"]
}

Implementation: src/routes/users.ts:35

GET /preferences/request-types

Get user's request type subscriptions (v9.0).

Authentication: Required (JWT token)

Response:

{
  "success": true,
  "data": {
    "preferences": [
      { "request_type": "generic", "subscribed": true },
      { "request_type": "ride", "subscribed": true }
    ],
    "isDefault": true
  }
}

Implementation: src/routes/preferences.ts:17

POST /preferences/request-types

Update a single request type subscription.

Request:

{
  "request_type": "ride",
  "subscribed": false
}

Implementation: src/routes/preferences.ts:77

PUT /preferences/request-types/bulk

Bulk update request type subscriptions.

Request:

{
  "preferences": [
    { "request_type": "ride", "subscribed": false },
    { "request_type": "event", "subscribed": true }
  ]
}

Implementation: src/routes/preferences.ts:144

GET /preferences/interests

Get user's interest categories (service_category, item_category, event_type).

Implementation: src/routes/preferences.ts:206

POST /preferences/interests

Add a new interest.

Request:

{
  "interest_type": "service_category",
  "interest_value": "plumbing"
}

Implementation: src/routes/preferences.ts:256

DELETE /preferences/interests/:id

Remove an interest.

Implementation: src/routes/preferences.ts:320

GET /preferences/feed (ADR-022)

Get user's feed visibility preferences for multi-tier feed.

Authentication: Required (JWT token)

Response:

{
  "success": true,
  "data": {
    "feed_show_trust_network": true,
    "feed_trust_network_max_degrees": 3,
    "feed_show_platform": false,
    "feed_platform_categories": ["digital", "questions"],
    "isDefault": true
  }
}

Implementation: src/routes/preferences.ts:367

PUT /preferences/feed (ADR-022)

Update user's feed visibility preferences.

Request:

{
  "feed_show_trust_network": true,
  "feed_trust_network_max_degrees": 4,
  "feed_show_platform": true,
  "feed_platform_categories": ["digital", "questions", "services"]
}

Validation:

  • feed_trust_network_max_degrees: 1-6
  • feed_platform_categories: must be an array

Implementation: src/routes/preferences.ts:421

POST /auth/push-tokens

Register an Expo push token for the authenticated user (Sprint 41).

Authentication: Required (JWT token)

Request:

{
  "expo_push_token": "ExponentPushToken[xxxx]",
  "platform": "ios"
}

Response:

{
  "success": true,
  "message": "Push token registered"
}

Implementation: src/routes/pushTokens.ts

DELETE /auth/push-tokens

Remove an Expo push token for the authenticated user (Sprint 41).

Authentication: Required (JWT token)

Request:

{
  "expo_push_token": "ExponentPushToken[xxxx]"
}

Response:

{
  "success": true,
  "message": "Push token removed"
}

Implementation: src/routes/pushTokens.ts

GET /health

Service health check.

Response:

{
  "status": "healthy",
  "service": "auth-service"
}

Dependencies

Calls (Outbound)

  • None (auth service does not call other services)

Called By (Inbound)

  • All services (for token verification via middleware)
  • Frontend (for login/register)
  • Mobile app (for authentication)

Events Published

  • None (auth service does not publish events currently)

Events Consumed

  • None

External Dependencies

  • PostgreSQL (auth schema)
  • JWT library (jsonwebtoken)
  • bcrypt (password hashing)

Environment Variables

# Server
PORT=3001
NODE_ENV=development

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/karmyq_db

# JWT
JWT_SECRET=your-secret-key-here  # MUST be strong in production
JWT_EXPIRATION=7d                # Token validity period

# Founding-circle review queue (comma-separated; deny-by-default when omitted)
FOUNDING_CIRCLE_REVIEWER_IDS=
FOUNDING_CIRCLE_REVIEWER_EMAILS=

# Logging
LOG_LEVEL=info                   # debug, info, warn, error

Key Files

Entry Point

  • src/index.ts - Express app initialization, middleware setup

Routes

  • src/routes/auth.ts - Login, register, verify endpoints
  • src/routes/users.ts - User profile CRUD operations
  • src/routes/preferences.ts - Request type subscriptions, interests, feed preferences (v9.0 + ADR-022)

Services

  • src/services/authService.ts - JWT token generation/verification
  • src/services/passwordService.ts - Password hashing/comparison

Middleware

  • src/middleware/auth.ts - JWT verification middleware (used by other services)

Database

  • src/database/db.ts - PostgreSQL connection pool

Common Development Tasks

Add a New Authentication Method (e.g., OAuth)

  1. Create OAuth route:
// src/routes/oauth.ts
router.get('/auth/google', (req, res) => {
  // Redirect to Google OAuth
});

router.get('/auth/google/callback', async (req, res) => {
  // Handle OAuth callback
  // Create or find user
  // Generate JWT token
  // Return token to client
});
  1. Update user schema if needed:
ALTER TABLE auth.users
ADD COLUMN google_id VARCHAR(255) UNIQUE,
ADD COLUMN oauth_provider VARCHAR(50);
  1. Register route in index.ts:
import oauthRouter from './routes/oauth';
app.use('/auth', oauthRouter);

Add a New User Field

  1. Create migration:
-- infrastructure/postgres/migrations/00X_add_user_field.sql
ALTER TABLE auth.users
ADD COLUMN new_field VARCHAR(255);
  1. Update TypeScript types:
// src/types/user.ts
interface User {
  // ... existing fields
  new_field?: string;
}
  1. Update user update endpoint:
// src/routes/users.ts
router.put('/users/:id', async (req, res) => {
  const { new_field } = req.body;
  // Add to UPDATE query
});

Change JWT Expiration

Edit .env:

JWT_EXPIRATION=30d  # For longer sessions
JWT_EXPIRATION=1h   # For shorter sessions

No code changes needed - reads from environment.

Add Password Reset Flow

  1. Create password reset token table:
CREATE TABLE auth.password_reset_tokens (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES auth.users(id),
  token VARCHAR(255) NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  used BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
  1. Add reset endpoints:
// POST /auth/forgot-password
router.post('/forgot-password', async (req, res) => {
  const { email } = req.body;
  // Generate token
  // Send email with reset link
  // Store token in database
});

// POST /auth/reset-password
router.post('/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;
  // Verify token
  // Hash new password
  // Update user password
  // Mark token as used
});
  1. Integrate email service:
import { sendEmail } from '../services/emailService';
await sendEmail({
  to: email,
  subject: 'Password Reset',
  body: `Reset your password: ${resetLink}`
});

Security Considerations

Password Hashing

  • Uses bcrypt with salt rounds = 10
  • Never store plaintext passwords
  • Verify password strength on registration (min 8 chars recommended)
// src/services/passwordService.ts
import bcrypt from 'bcrypt';

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 10);
}

export async function comparePassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

JWT Tokens

  • Signed with HS256 algorithm
  • Include user ID and email in payload
  • Set reasonable expiration (default: 7 days)
  • Store secret in environment variable, NEVER in code
// src/services/authService.ts
import jwt from 'jsonwebtoken';

export function generateToken(user: User): string {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET!,
    { expiresIn: process.env.JWT_EXPIRATION }
  );
}

export function verifyToken(token: string): any {
  return jwt.verify(token, process.env.JWT_SECRET!);
}

Input Validation

  • Validate email format
  • Enforce password complexity
  • Sanitize all user inputs
  • Rate limit login attempts (TODO: implement)

Debugging Common Issues

"Invalid token" errors

  1. Check JWT_SECRET matches between services
  2. Verify token hasn't expired
  3. Check Authorization header format: Bearer <token>

"User not found" on login

  1. Check email is correct (case-sensitive)
  2. Verify user exists in database: SELECT * FROM auth.users WHERE email = '...'
  3. Check user status is 'active'

Password hash mismatch

  1. Ensure password is being hashed before storage
  2. Check bcrypt salt rounds match
  3. Verify no extra whitespace in password input

Database connection errors

  1. Check DATABASE_URL is correct
  2. Verify PostgreSQL is running: docker ps | grep postgres
  3. Test connection: psql $DATABASE_URL

Testing

Manual Testing with curl

Register:

curl -X POST http://localhost:3001/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Test User", "email": "test@example.com", "password": "password123"}'

Login:

curl -X POST http://localhost:3001/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'

Verify Token:

TOKEN="<jwt-token-from-login>"
curl http://localhost:3001/verify \
  -H "Authorization: Bearer $TOKEN"

Unit Tests

Run tests:

npm test

Test structure:

src/
├── __tests__/
│   ├── auth.test.ts          # Auth endpoint tests
│   ├── users.test.ts         # User endpoint tests
│   └── services/
│       ├── authService.test.ts
│       └── passwordService.test.ts

Performance Considerations

  • Password hashing is CPU-intensive (bcrypt blocks event loop)
  • Consider using bcrypt in worker threads for high-load scenarios
  • Cache user lookups if needed (currently no caching implemented)
  • Database queries use connection pooling (max 20 connections)

Recent Changes

Sprint 129: the demo failure is now legible (2026-09-13) — BUG-039

  • FIXED (BUG-039): karmyq.com/demo returned 503 DEMO_UNAVAILABLE from ~2026-09-09 to 2026-09-12. Root cause was not in this service: cleanup-service had hard-deleted all four demo story rows, so the configured ids pointed at nothing. Restored by wiring and running rotate:demo-stories, which had existed since Sprint 117 but had never been operational on the demo host.
  • CHANGED: src/routes/auth.ts — DemoSessionUnavailableError is now logged at warn with a structured reason. Previously only unexpected errors were logged, so every expected cause was silent and the endpoint could not be diagnosed from logs at all. The HTTP response is unchanged — same 503, same code, same body, byte-identical across causes (ADR-084 Sprint 129 amendment).
  • NEW: src/services/demoSessionSelfCheck.ts — boot-time report of demo health, wired in src/index.ts after listen. Never throws, never logs the token, and distinguishes disabled from broken.
  • Tests: tests/tdd/sprint-129-demo-session-logging.test.ts (response byte-identical across two causes while the logs differ) and tests/tdd/sprint-129-demo-session-selfcheck.test.ts (never throws, never leaks the token). Both proven by injection, not just by a green run.

Sprint 56: Publisher + Logger centralization (2026-05-17)

  • CHANGED: src/events/publisher.ts — now delegates to createPublisher('auth-service') from @karmyq/shared; local 37-line Bull setup removed
  • CHANGED: src/utils/logger.ts — now re-exports createLogger from @karmyq/shared; local winston setup removed

Sprint 41: Expo Push Token API (2026-03-26)

  • NEW: POST /auth/push-tokens — registers an Expo push token for the authenticated user; stored in auth.device_push_tokens
  • NEW: DELETE /auth/push-tokens — removes an Expo push token for the authenticated user
  • Schema: New auth.device_push_tokens table (id, user_id, expo_push_token, platform, created_at, updated_at)
  • File: src/routes/pushTokens.ts
  • Note: Notification-service reads auth.device_push_tokens directly to send Expo push notifications

Sprint 38: User Tags API (2026-03-24)

  • NEW: GET /auth/profile/tags — returns user's tags grouped by type (skills, interests, needs)
  • NEW: POST /auth/profile/tags — adds a tag (tag_type + tag_value). ON CONFLICT DO NOTHING (idempotent)
  • NEW: DELETE /auth/profile/tags/:tagId — removes a specific tag (scoped to current user)
  • NEW: GET /auth/profile/tags/suggestions?tag_type=skill|interest|need — returns hardcoded suggestions
  • Schema: New auth.user_tags table (see migration 20260324-user-tags.sql)
  • File: src/routes/profileTags.ts, src/constants/tagSuggestions.ts
  • Historical note: Sprint 38 added tags alongside auth.user_skills. Sprint 132 PR A supersedes that split: matching now reads skill tags, and the legacy table is deprecated (see above).

Future Enhancements (TODO)

  • Rate limiting on login attempts
  • Two-factor authentication (2FA)
  • OAuth providers (Google, GitHub)
  • Email verification flow
  • Password reset via email
  • Session management (revoke tokens)
  • Account lockout after failed attempts
  • Federated identity support (for cross-instance auth)

Related Documentation

  • Main architecture: /docs/ARCHITECTURE.md
  • Database schema: /infrastructure/postgres/init.sql (lines 1-50)
  • Federation auth: /docs/FEDERATION_PROTOCOL.md (section: User Identity)

Sprint 117: Curated Demo Persona (non-admin, verified)

The demo narrative persona (Maria Reyes, @test.karmyq.com) is created by the curated demo baseline as an active, member-only user — never an admin or moderator in any community. Authorization for demo flows must be re-derived from live membership, not the JWT communities claim (a snapshot from login). The curated reset seeds auth.users password hashes from DEMO_PERSONA_PASSWORD at apply time (bcrypt cost 12); no plaintext or hash is ever committed, logged, or placed in the manifest. Live Maria story IDs are server-generated and published to the demo session only after the non-admin verifier confirms them by authoritative readback.


Sprint 122 — Express 5 (2026-07-29)

@types/express 4.17.21 → 5.0.6. Express 5's path-to-regexp 8 widened route params to string | string[] (a repeatable :ids+ or wildcard *splat segment captures an array), which surfaced as TS2345 at every req.params read. Karmyq declares no such segment, so params are narrowed back to string via RouteParams (exported from @karmyq/shared/middleware/auth) rather than widened with as any. The invariant is enforced by tests/regression/sprint-122-express5-route-params.test.ts, which fails if any route literal introduces wildcard or repeatable syntax.

Handlers here inherit the fix through AuthenticatedRequest, so no route file changed in this service — only the @types/express range.

Express 4.18.2 → 5.2.1, supplied by the root package.json production dependency (the Dockerfiles copy the root manifest and npm install --omit=dev). No endpoint, payload, status code or event contract changed — feedback:check flags this service's src/routes/ diff as a "route change", but the diff is type annotations only, so the API Endpoints section above is still accurate.

Express 5 semantics now in force: async handler rejections auto-forward to the error middleware, res.status() throws RangeError on an out-of-range code, and req.query is a getter rather than a writable own property.

⚠️ req.body default restored (the bug this PR actually shipped to CI). body-parser 1 initialised req.body to {} on every request; body-parser 2 leaves it undefined unless a body was parsed, so const { x } = req.body throws a TypeError on a bodyless request and the route's catch turns it into a 500. app.use(normalizeRequestBody) is now mounted immediately after express.json() in src/index.ts to restore the Express 4 behaviour. It fills in only a missing body, so a parsed array or explicit null is untouched.

Sprint 131 PR B2 — declared imports (2026-09-17)

Now declares cors, dotenv, express, jsonwebtoken, pg in dependencies at root's exact ranges (BUG-046). They were imported but undeclared, resolving only through root hoisting. Resolved versions are unchanged. tests/regression/sprint-131-workspace-declarations.test.ts fails on any undeclared import, and fails when root's hoisted version stops satisfying a range declared here — so a root-only major bump (e.g. D1 dotenv 17) must bump this manifest in the same PR. Range satisfaction alone could not enforce that: npm answers a stranded range by nesting a satisfying older copy under the workspace, which keeps a plain satisfaction check green.

No endpoint, payload, event or schema change.

Sprint 131 D1 — dotenv 17 (2026-09-19)

dotenv 16.6.1 → 17.4.2 (root plus all 9 npm workspaces that declare it; the ranges moved together in one PR, which the declarations gate requires. The two non-workspace test manifests, tests/e2e/package.json and tests/load/package.json, deliberately stay on ^16.3.1 with their own nested installs — dotenv 16 accepts quiet too, so their call sites are fixed either way). src/index.ts now calls dotenv.config({ quiet: true }).

quiet is not cosmetic here. dotenv 16 defaulted it to true; 17 defaults it to falsy, so a bare config() prints ◇ injected env (N) from .env // tip: … on every call — and the tip is drawn at random from an 8-entry list that includes third-party promo URLs. Without the flag this service would log a non-deterministic marketing line on every container start. tests/regression/sprint-131-dotenv-quiet.test.ts discovers the call sites from tracked source and fails on any config() that omits quiet.

No endpoint, payload, event or schema change. parse() output is byte-identical between 16 and 17, and nothing here reads config()’s return value.