lfx-v2-invite-service

module
v0.1.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT

README

LFX V2 Invite Service

Invite issuance and tracking service for the LFX Self-Service platform. Receives invite requests from resource services over NATS request/reply, renders the invite email, delivers it via the email service, and persists every invite record in NATS KV so its status can be queried and updated when the recipient accepts.

Usage

Send an invite

Subject: lfx.invite-service.send_invite
Queue group: invite-service-workers

Sent by resource services (project-service, committee-service, etc.) when a user without an LFID is added to a resource. The invite service generates a signed JWT invite link, renders the email template, forwards delivery to the email service, and persists a pending invite record in KV.

Request payload fields:

Field Type Required Description
recipient object yes* Structured recipient identity — preferred over the deprecated scalar fields below
recipient.email string yes Recipient email address
recipient.name string no Recipient display name, used in the email greeting
recipient.username string no Recipient LFID username, if already known
recipient.avatar string no Recipient avatar URL
inviter object no Structured identity of the person who triggered the invite
inviter.name string no Inviter display name, used in the email body
inviter.username string no Inviter LFID username
inviter.email string no Inviter email address
inviter.avatar string no Inviter avatar URL
resource object yes* Structured resource the recipient is being invited to
resource.uid string yes Resource UID
resource.name string no Resource display name, used in the email body
resource.type string no Resource kind — e.g. "project", "committee", "meeting". Defaults to "resource" when empty
role string yes Access level (e.g. "Manage", "View", "Member"); any non-empty string is accepted
return_url string no URL the invite link redirects to after acceptance. Must use https and match the ALLOWED_RETURN_URL_HOSTS allowlist. Defaults to DEFAULT_INVITE_LINK_RETURN_URL when omitted
org_name string no Foundation or project name used in the email signature ("The X Team"). Defaults to "LFX" when empty
expiration_days int no Number of days before the invite JWT expires. Defaults to 30, capped at 90
custom_claims object no Additional string key/value pairs to embed in the signed JWT. Useful for carrying resource-specific context (e.g. {"committee_invite_uid": "inv-abc"}) that the invite service is not aware of. Reserved JWT keys (iss, aud, sub, iat, nbf, exp, jti, invite_uid, email, return_url, resource_uid, resource_type, role) are ignored with a warning log. Rejected if more than 16 entries, any key exceeds 64 bytes, or any value exceeds 1024 bytes

Deprecated scalar fieldsrecipient_email, recipient_name, inviter_name, resource_uid, resource_name, resource_type are accepted for backward compatibility. The structured recipient, inviter, and resource objects take precedence when both are provided.

{
  "recipient": {
    "name": "Alice Smith",
    "email": "alice@example.com"
  },
  "inviter": {
    "name": "Bob Jones",
    "username": "bobjones",
    "email": "bob@linuxfoundation.org"
  },
  "resource": {
    "uid": "proj-abc123",
    "name": "My Project",
    "type": "project"
  },
  "role": "Member",
  "org_name": "The Linux Foundation",
  "expiration_days": 30,
  "custom_claims": {
    "committee_invite_uid": "inv-abc123"
  }
}

Success response:

{
  "uid": "550e8400-e29b-41d4-a716-446655440000",
  "email": "alice@example.com",
  "expires_at": "2025-02-14T10:30:00Z"
}

Store uid to look up the invite record later or to correlate with the lfx.invite.accepted event.

Error response:

{ "error": "<reason>" }
error value Cause
malformed_request Request body is not valid JSON
invalid_request Missing required field, invalid recipient email, or return_url failed validation (must use https and match ALLOWED_RETURN_URL_HOSTS)
email_dispatch_failed Invite link was generated but the email service could not deliver it
internal_error Unexpected server-side failure

Examples (NATS CLI):

# Structured objects (preferred)
nats req lfx.invite-service.send_invite \
  '{"recipient":{"name":"Alice","email":"alice@example.com"},"inviter":{"name":"Bob","username":"bob","email":"bob@lf.org"},"resource":{"uid":"proj-1","name":"My Project","type":"project"},"role":"Member"}'

# Deprecated scalars (still accepted for backward-compat)
nats req lfx.invite-service.send_invite \
  '{"recipient_email":"alice@example.com","recipient_name":"Alice","resource_uid":"proj-1","resource_name":"My Project","role":"Member"}'

Get an invite by UID

Subject: lfx.invite-service.get_invite
Queue group: invite-service-get-invite

Returns the full invite record for a given invite UID.

Request:

{ "uid": "550e8400-e29b-41d4-a716-446655440000" }

Success response:

{
  "uid": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "recipient": {
    "name": "Alice Smith",
    "email": "alice@example.com"
  },
  "inviter": {
    "name": "Bob Jones",
    "username": "bobjones",
    "email": "bob@linuxfoundation.org"
  },
  "resource": {
    "uid": "proj-abc123",
    "name": "My Project",
    "type": "project"
  },
  "role": "Member",
  "org_name": "The Linux Foundation",
  "return_url": "https://app.lfx.dev",
  "expiration_days": 30,
  "created_at": "2025-01-15T10:30:00Z",
  "expires_at": "2025-02-14T10:30:00Z"
}

Once the recipient accepts, status changes to "accepted" and the response includes accepted_at and accepted_by:

{
  "uid": "550e8400-e29b-41d4-a716-446655440000",
  "status": "accepted",
  "recipient": { "name": "Alice Smith", "email": "alice@example.com" },
  "inviter": { "name": "Bob Jones", "username": "bobjones" },
  "resource": { "uid": "proj-abc123", "name": "My Project", "type": "project" },
  "role": "Member",
  "created_at": "2025-01-15T10:30:00Z",
  "expires_at": "2025-02-14T10:30:00Z",
  "accepted_at": "2025-01-20T14:05:00Z",
  "accepted_by": "alice-lfid"
}

Error response:

{ "error": "<reason>" }
error value Cause
malformed_request Request body is not valid JSON
invalid_request uid field is missing or empty
not_found No invite record exists for the given UID
internal_error Unexpected server-side failure

Example (NATS CLI):

nats req lfx.invite-service.get_invite \
  '{"uid":"550e8400-e29b-41d4-a716-446655440000"}'

Get invites by email

Subject: lfx.invite-service.get_invites_by_email
Queue group: invite-service-get-by-email

Returns all invite records for a given email address across all resources and statuses. Useful for checking whether a user already has a pending invite before sending another.

Request:

{ "email": "alice@example.com" }

Success response — a bare JSON array of invite records (empty array when none exist):

[
  {
    "uid": "550e8400-e29b-41d4-a716-446655440000",
    "status": "pending",
    "recipient": { "name": "Alice Smith", "email": "alice@example.com" },
    "inviter": { "name": "Bob Jones", "username": "bobjones" },
    "resource": { "uid": "proj-abc123", "name": "My Project", "type": "project" },
    "role": "Member",
    "created_at": "2025-01-15T10:30:00Z",
    "expires_at": "2025-02-14T10:30:00Z"
  },
  {
    "uid": "661f9511-f30c-52e5-b827-557766551111",
    "status": "accepted",
    "recipient": { "name": "Alice Smith", "email": "alice@example.com" },
    "resource": { "uid": "comm-xyz789", "name": "My Committee", "type": "committee" },
    "role": "Member",
    "created_at": "2025-01-10T09:00:00Z",
    "expires_at": "2025-02-09T09:00:00Z",
    "accepted_at": "2025-01-11T16:30:00Z",
    "accepted_by": "alice-lfid"
  }
]

Error response:

{ "error": "<reason>" }
error value Cause
malformed_request Request body is not valid JSON
invalid_request email field is missing or empty
internal_error Unexpected server-side failure

Example (NATS CLI):

nats req lfx.invite-service.get_invites_by_email \
  '{"email":"alice@example.com"}'

Invite accepted event (consumed)

Subject: lfx.invite.accepted
Queue group: invite-service-acceptance

Published by the LFX self-serve web app once a user completes the acceptance flow (JWT validation + login). The invite service subscribes to this event alongside other services (e.g. project-service) — each uses its own queue group and receives an independent copy.

On receipt the invite service marks the KV record status: accepted, sets accepted_at to now, and records the LFID username in accepted_by. If the invite_uid is not found in the invite-service KV store (i.e. the invite belongs to a different service's flow), the event is silently ignored.

Event payload:

{
  "invite_uid": "550e8400-e29b-41d4-a716-446655440000",
  "username": "alice-lfid"
}

This subject is consumed, not exposed for direct use — it is published by the self-serve web app.


Invite accepted event (published)

Subject: lfx.invite-service.invite_accepted

Published by the invite service after it marks a KV record accepted in response to lfx.invite.accepted. Carries the full invite record (recipient, inviter, resource, role, timestamps) so downstream services can react without a separate get_invite lookup.

Delivery is best-effort over core NATS — a publish failure is logged but does not roll back the KV update or block the acceptance flow.

Event payload (same shape as a successful get_invite response for an accepted invite):

{
  "uid": "550e8400-e29b-41d4-a716-446655440000",
  "status": "accepted",
  "recipient": { "name": "Alice Smith", "email": "alice@example.com" },
  "inviter": { "name": "Bob Jones", "username": "bobjones" },
  "resource": { "uid": "proj-abc123", "name": "My Project", "type": "project" },
  "role": "Member",
  "created_at": "2025-01-15T10:30:00Z",
  "expires_at": "2025-02-14T10:30:00Z",
  "accepted_at": "2025-01-20T14:05:00Z",
  "accepted_by": "alice-lfid"
}

Duplicate or redelivered lfx.invite.accepted events for an already-accepted invite are ignored and do not emit a second enriched event.


Use with Go

The pkg/api package exports subject constants and request/response types for all NATS interactions consumed and published by the invite service.

go get github.com/linuxfoundation/lfx-v2-invite-service/pkg/api
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/nats-io/nats.go"
	inviteapi "github.com/linuxfoundation/lfx-v2-invite-service/pkg/api"
)

func main() {
	nc, err := nats.Connect(nats.DefaultURL)
	if err != nil {
		panic(err)
	}
	defer nc.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Send an invite.
	req := inviteapi.SendInviteRequest{
		Recipient: &inviteapi.Recipient{Name: "Alice Smith", Email: "alice@example.com"},
		Inviter:   &inviteapi.Inviter{Name: "Bob Jones", Username: "bobjones"},
		Resource:  &inviteapi.Resource{UID: "proj-abc123", Name: "My Project", Type: "project"},
		Role:      string(inviteapi.InviteRoleMember),
		OrgName:   "The Linux Foundation",
	}
	data, err := json.Marshal(req)
	if err != nil {
		panic(err)
	}

	reply, err := nc.RequestWithContext(ctx, inviteapi.SendInviteSubject, data)
	if err != nil {
		panic(err)
	}

	var sendResp inviteapi.SendInviteResponse
	if err := json.Unmarshal(reply.Data, &sendResp); err != nil {
		panic(err)
	}
	if sendResp.Error != "" {
		fmt.Println("send failed:", sendResp.Error)
		return
	}
	fmt.Println("invite sent, uid:", sendResp.UID)

	// Look up the invite record by UID.
	getReq, err := json.Marshal(inviteapi.GetInviteRequest{UID: sendResp.UID})
	if err != nil {
		panic(err)
	}
	getReply, err := nc.RequestWithContext(ctx, inviteapi.GetInviteSubject, getReq)
	if err != nil {
		panic(err)
	}

	var getResp inviteapi.GetInviteResponse
	if err := json.Unmarshal(getReply.Data, &getResp); err != nil {
		panic(err)
	}
	if getResp.Error != "" {
		fmt.Println("get failed:", getResp.Error)
		return
	}
	fmt.Printf("status: %s, expires: %s\n", getResp.Invite.Status, getResp.Invite.ExpiresAt)

	// List all invites for an email address.
	listReq, err := json.Marshal(inviteapi.GetInvitesByEmailRequest{Email: "alice@example.com"})
	if err != nil {
		panic(err)
	}
	listReply, err := nc.RequestWithContext(ctx, inviteapi.GetInvitesByEmailSubject, listReq)
	if err != nil {
		panic(err)
	}

	var invites []inviteapi.Invite
	if err := json.Unmarshal(listReply.Data, &invites); err != nil {
		panic(err)
	}
	fmt.Printf("invites for alice: %d\n", len(invites))
}

KV Storage

Invite records are stored in the invites NATS JetStream KV bucket:

  • Primary key: <inviteUID> → JSON invite record
  • Email index: index/email/<normalizedEmail>/<inviteUID> → invite UID (enables list-by-email)
  • Records are kept indefinitely — no TTL — as a permanent audit trail.
  • return_url stores the destination URL, never the signed JWT token.

The bucket is provisioned by the Helm chart via the nack KeyValue CRD (charts/lfx-v2-invite-service/templates/nats-kv-buckets.yaml). For local development without Kubernetes, create it with:

nats kv add invites --history=20 --storage=file

File Structure

├── charts/                          # Helm charts for Kubernetes deployment
│   └── lfx-v2-invite-service/
│       └── templates/
│           └── nats-kv-buckets.yaml # Declares the invites KV bucket via nack CRD
├── cmd/
│   └── invite-api/
│       ├── main.go                      # OTel bootstrap, signal handling, graceful shutdown
│       └── service/
│           ├── config.go            # AppConfig read from env vars
│           ├── implementations.go   # Infrastructure wiring (NATS, KV, services)
│           └── subscriptions.go     # NATS subscriber registration (all 4 subjects)
├── internal/
│   ├── domain/
│   │   ├── model/                   # Domain types: InviteRecord, SendInviteRequest, roles
│   │   └── port/                    # Interfaces: EmailSender, InviteStore; mocks/
│   ├── infrastructure/
│   │   ├── auth/                    # JWT link generator (HS256)
│   │   ├── nats/                    # NATS client, NATSEmailSender, NATSInviteRepository
│   │   ├── observability/           # slog setup and OTel SDK bootstrap
│   │   └── smtp/                    # Template rendering + embedded templates/
│   └── service/
│       ├── notification.go          # HandleSendInvite — email dispatch + KV persist
│       ├── acceptance.go            # HandleInviteAccepted — marks KV record accepted
│       └── invite_read.go           # GetInvite / GetInvitesByEmail
└── pkg/
    └── api/                         # Public inter-service contract: subjects, types

Architecture Notes

  • No HTTP API — the service is a pure NATS subscriber. All operations use request/reply or fire-and-forget events.
  • Template ownership — the invite service owns and renders the email template; the email service (lfx.email-service.send_email) handles SMTP delivery. Callers publish structured fields — no pre-rendered HTML required.
  • Fail-closed KV persist — the invite record is written to KV before the email is sent. A KV write failure aborts the operation and no email is dispatched. If the email send fails after a successful KV write, a best-effort rollback delete is attempted and email_dispatch_failed is returned to the caller.
  • Own queue group for acceptance — the service uses invite-service-acceptance as its queue group for lfx.invite.accepted, so it receives an independent copy alongside other subscribers (e.g. project-service).
  • Enriched acceptance broadcast — after updating KV, the service publishes lfx.invite-service.invite_accepted with the full invite record for downstream subscribers.
  • Config injected via struct — all env vars are read in cmd/invite-api/service/config.go and passed into service constructors; no os.Getenv calls in business logic.

Environment Variables

Variable Default Description
NATS_URL nats://lfx-platform-nats.lfx.svc.cluster.local:4222 NATS server URL
INVITE_JWT_SECRET (required) HMAC-SHA256 key (≥32 bytes) for signing invite JWTs
INVITES_KV_BUCKET invites Name of the NATS JetStream KV bucket for invite records
DEFAULT_INVITE_LINK_RETURN_URL auto (falls back to LFX_SELF_SERVE_BASE_URL) Fallback return URL when the caller omits return_url
LFX_SELF_SERVE_BASE_URL auto (see LFX_ENVIRONMENT) Base URL of the invite acceptance web app
LFX_ENVIRONMENT (unset → dev) Sets self-serve URL default: prod, stg, or dev
ALLOWED_RETURN_URL_HOSTS *.lfx.dev,*.linuxfoundation.org Comma-separated host patterns permitted for return_url. Wildcard prefix *. matches any subdomain
LOG_LEVEL debug Log level: debug, info, warn, error
OTEL_SERVICE_NAME lfx-v2-invite-service OpenTelemetry service name

Quick Start

Option 1 — Run directly
# Prerequisites: Go 1.25+, NATS with JetStream (docker run -d -p 4222:4222 nats:latest -js)
make build
nats kv add invites --history=20 --storage=file

NATS_URL=nats://localhost:4222 \
INVITE_JWT_SECRET=$(openssl rand -base64 48) \
./bin/invite-api
Option 2 — Deploy to a local cluster with Helm
cp charts/lfx-v2-invite-service/values.local.yaml.example charts/lfx-v2-invite-service/values.local.yaml
# Edit values.local.yaml to set the NATS URL and JWT secret
make helm-install-local

Development

Prerequisites
  • Go 1.25+
  • NATS server with JetStream enabled (Docker: docker run -d -p 4222:4222 nats:latest -js)
Make targets
Target Description
make build Compile binary → bin/invite-api
make run Build and run ./bin/invite-api
make test Run tests with race detector
make check fmt + lint + license-check + go vet
make lint golangci-lint only
make helm-templates Render the Helm chart with default values (dry-run)
make helm-install-local Install/upgrade the chart into the current kube context
Create the Kubernetes secret

The Helm chart expects a secret named lfx-v2-invite-service with an invite-jwt-secret key. Create it before deploying (or the pod will fail to start with secret "lfx-v2-invite-service" not found):

kubectl create secret generic lfx-v2-invite-service \
  --from-literal=invite-jwt-secret=$(openssl rand -base64 48) \
  -n lfx

In production the secret is managed by External Secrets Operator, which pulls the value from AWS Secrets Manager at /cloudops/managed-secrets/cloud/invite-service/jwt. For local Kubernetes (e.g. OrbStack) the manual kubectl create secret above is sufficient.

Test
make test
Lint & format
make check

Adding a New NATS Subscription

  1. Add the handler method to the appropriate service in internal/service/.
  2. Add a queue-subscribe block in cmd/invite-api/service/subscriptions.go and append the stop func.
  3. Add subject constant + payload types to pkg/api/invite.go.
  4. Wire any new infrastructure (e.g. a KV binding) in cmd/invite-api/service/implementations.go.

Releases

  1. Merge the PR, then create a GitHub release with a v{version} tag.
  2. CI builds and publishes the container image and Helm chart automatically.
Service Relationship
lfx-v2-email-service Handles SMTP delivery; this service forwards pre-rendered emails to it
lfx-v2-project-service Example resource service that publishes send_invite requests
lfx-v2-committee-service Example resource service that publishes send_invite requests

License

Copyright The Linux Foundation and each contributor to LFX.

Source code is licensed under the MIT License. See LICENSE.
Documentation is licensed under CC-BY-4.0. See LICENSE-docs.

Directories

Path Synopsis
cmd
invite-api command
internal
pkg
api
Package api contains the public contract types and NATS subjects that resource services (project-service, committee-service, etc.) use to interact with the invite service.
Package api contains the public contract types and NATS subjects that resource services (project-service, committee-service, etc.) use to interact with the invite service.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL