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 fields —
recipient_email,recipient_name,inviter_name,resource_uid,resource_name,resource_typeare accepted for backward compatibility. The structuredrecipient,inviter, andresourceobjects 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_urlstores 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_failedis returned to the caller. - Own queue group for acceptance — the service uses
invite-service-acceptanceas its queue group forlfx.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_acceptedwith the full invite record for downstream subscribers. - Config injected via struct — all env vars are read in
cmd/invite-api/service/config.goand passed into service constructors; noos.Getenvcalls 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 manualkubectl create secretabove is sufficient.
Test
make test
Lint & format
make check
Adding a New NATS Subscription
- Add the handler method to the appropriate service in
internal/service/. - Add a queue-subscribe block in
cmd/invite-api/service/subscriptions.goand append the stop func. - Add subject constant + payload types to
pkg/api/invite.go. - Wire any new infrastructure (e.g. a KV binding) in
cmd/invite-api/service/implementations.go.
Releases
- Merge the PR, then create a GitHub release with a
v{version}tag. - CI builds and publishes the container image and Helm chart automatically.
Related Services
| 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. |