LFX V2 Invite Service

September 3, 2026 · View on GitHub

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:

FieldTypeRequiredDescription
recipientobjectyes*Structured recipient identity — preferred over the deprecated scalar fields below
recipient.emailstringyesRecipient email address
recipient.namestringnoRecipient display name, used in the email greeting
recipient.usernamestringnoRecipient LFID username, if already known
recipient.avatarstringnoRecipient avatar URL
inviterobjectnoStructured identity of the person who triggered the invite
inviter.namestringnoInviter display name, used in the email body
inviter.usernamestringnoInviter LFID username
inviter.emailstringnoInviter email address
inviter.avatarstringnoInviter avatar URL
resourceobjectyes*Structured resource the recipient is being invited to
resource.uidstringyesResource UID
resource.namestringnoResource display name, used in the email body
resource.typestringnoResource kind — e.g. "project", "committee", "meeting". Defaults to "resource" when empty
rolestringyesAccess level (e.g. "Manage", "View", "Member"); any non-empty string is accepted
return_urlstringnoURL 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_namestringnoFoundation or project name used in the email signature ("The X Team"). Defaults to "LFX" when empty
expiration_daysintnoNumber of days before the invite JWT expires. Defaults to 30, capped at 90
custom_claimsobjectnoAdditional 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 valueCause
malformed_requestRequest body is not valid JSON
invalid_requestMissing required field, invalid recipient email, or return_url failed validation (must use https and match ALLOWED_RETURN_URL_HOSTS)
email_dispatch_failedInvite link was generated but the email service could not deliver it
internal_errorUnexpected 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 valueCause
malformed_requestRequest body is not valid JSON
invalid_requestuid field is missing or empty
not_foundNo invite record exists for the given UID
internal_errorUnexpected 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" }

Response — always a GetInvitesByEmailResponse envelope:

{
  "invites": [
    {
      "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"
    }
  ]
}

On error, invites is null and error is set:

{ "invites": null, "error": "<reason>" }
error valueCause
malformed_requestRequest body is not valid JSON
invalid_requestemail field is missing or empty
internal_errorUnexpected 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 listResp inviteapi.GetInvitesByEmailResponse
	if err := json.Unmarshal(listReply.Data, &listResp); err != nil {
		panic(err)
	}
	if listResp.Error != "" {
		fmt.Println("list failed:", listResp.Error)
		return
	}
	fmt.Printf("invites for alice: %d\n", len(listResp.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
│           ├── server.go            # Server struct; NewServer (injection) + NewServerFromConfig (production wiring) + Close
│           ├── request_mapping.go   # apiToModelRequest — api→model converter at the transport seam
│           └── subscriptions.go     # (s *Server) Start — NATS subscriber registration (all 4 subjects)
├── internal/
│   ├── domain/
│   │   ├── model/                   # Domain types: InviteRecord, SendInviteRequest, roles
│   │   └── port/                    # Interfaces: EmailSender, InviteStore, EventPublisher, Subscriber, InboundMessage; 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

VariableDefaultDescription
NATS_URLnats://lfx-platform-nats.lfx.svc.cluster.local:4222NATS server URL
INVITE_JWT_SECRET(required)HMAC-SHA256 key (≥32 bytes) for signing invite JWTs
INVITES_KV_BUCKETinvitesName of the NATS JetStream KV bucket for invite records
DEFAULT_INVITE_LINK_RETURN_URLauto (falls back to LFX_SELF_SERVE_BASE_URL)Fallback return URL when the caller omits return_url
LFX_SELF_SERVE_BASE_URLauto (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.orgComma-separated host patterns permitted for return_url. Wildcard prefix *. matches any subdomain
LOG_LEVELdebugLog level: debug, info, warn, error
OTEL_SERVICE_NAMElfx-v2-invite-serviceOpenTelemetry 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

TargetDescription
make buildCompile binary → bin/invite-api
make runBuild and run ./bin/invite-api
make testRun tests with race detector
make checkfmt + lint + license-check + go vet
make lintgolangci-lint only
make helm-templatesRender the Helm chart with default values (dry-run)
make helm-install-localInstall/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/server.goNewServerFromConfig.

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.
  3. CI also dispatches create-version-bump-pr.yml on lfx-v2-argocd so staging and prod image tags and chart pins are opened as a pull request.
ServiceRelationship
lfx-v2-email-serviceHandles SMTP delivery; this service forwards pre-rendered emails to it
lfx-v2-project-serviceExample resource service that publishes send_invite requests
lfx-v2-committee-serviceExample 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.