콘텐츠로 이동

Web Join (join.umbra.ink/{slug})

외부에서 슬러그 URL 을 통해 Discord 길드에 가입하는 흐름. 사용자가 Discord OAuth2 로 인증하고, Umbra 가 guilds.join API 로 길드에 추가한 뒤 자동 역할을 부여한다. Free Plan 부터 사용 가능한 유일한 Recovery 관련 기능.

Scenario

길드 관리자가 길드의 고유 슬러그 링크 (https://join.umbra.ink/my-cool-guild) 를 외부에 공유한다. 방문자가 링크를 열면 길드 프로필과 "가입하기" 버튼이 표시되고, 클릭 시 Discord OAuth2 를 거쳐 길드에 자동 가입된다.

Actors

  • Visitor — 가입하려는 Discord 사용자 (아직 길드 멤버 아님)
  • umbra-web (join 서브도메인) — 랜딩 페이지
  • Discord OAuth2 — identify + guilds.join scope
  • umbra-api — OAuth2 콜백, guilds.join 호출
  • Member domain — WebJoinRequest 및 Member 레코드 관리
  • Identity domain — 신규 User 자동 생성
  • Guild domain — 슬러그 조회, 설정 참조
  • Bouncer domain — VPN/Proxy/Tor 감지 및 국가 기반 접근 통제 (Pro+ 에서만 활성)
  • Audit, Notification

Preconditions

  • Guild 가 active (bot_installed_at NOT NULL, bot_removed_at NULL)
  • Guild 의 active License 존재 (Free 이상. Free 도 웹 조인 지원)
  • Visitor 가 Discord 계정 보유
  • Visitor 가 해당 길드에 이미 가입되어 있지 않음 (가입 상태 여부는 guilds.join 이 알려줌)
  • web_join_auto_role_id 설정 여부는 무관 (설정 있으면 역할 부여, 없으면 skip)
  • Pro+ 길드일 경우, Bouncer Policy 가 존재하면 판정 통과 (없으면 default allow)

Postconditions

Happy path (Bouncer allow 또는 Free skip)

  • identity.users 에 Visitor 의 User row (이미 있으면 username/avatar 캐시 갱신)
  • member.web_join_requests 에 completed 레코드, access token 제거됨
  • member.members 에 active Member row (joined_via='web_join')
  • Discord 상에서 Visitor 가 길드 멤버
  • (옵션) auto role 부여됨
  • events.outboxMemberJoinedViaWebJoin 이벤트
  • audit.events 기록
  • Pro+ 길드: bouncer.decisionsoutcome='allow' 기록

Denied path (Pro+ 에서 Bouncer deny)

  • identity.users 에 Visitor 의 User row (판정 전 upsert 완료 상태)
  • member.web_join_requests 생성 없음
  • member.members 변경 없음
  • Discord 길드 상태 변경 없음
  • bouncer.decisionsoutcome='deny' 기록
  • events.outboxBouncerDenied 이벤트
  • audit.events 기록

Sequence

sequenceDiagram
    participant Visitor
    participant Web as join.umbra.ink
    participant API as umbra-api
    participant DiscordOAuth as Discord OAuth2
    participant Identity
    participant Bouncer
    participant Member
    participant Discord as Discord API
    participant Outbox

    Visitor->>Web: GET join.umbra.ink/my-cool-guild
    Web->>API: GET /api/v1/web-join/resolve/:slug
    API->>API: GuildService.GetGuildBySlug(slug)
    API-->>Web: { guild_name, icon_url, member_count }
    Web-->>Visitor: 렌더 "가입하기" 버튼

    Visitor->>Web: Click "가입하기"
    Web->>DiscordOAuth: Redirect to /oauth2/authorize
scope=identify guilds.join
state=join:slug DiscordOAuth->>Visitor: Authorize UI Visitor->>DiscordOAuth: Authorize DiscordOAuth->>API: Redirect /oauth/discord/callback?code=...&state=join:slug API->>DiscordOAuth: POST /oauth2/token
(exchange code) DiscordOAuth-->>API: { access_token, refresh_token, scopes } API->>DiscordOAuth: GET /users/@me DiscordOAuth-->>API: { discord_user_id, username, avatar } API->>Identity: UpsertUser(discordUserID, username, avatar) Identity-->>API: user{id} Note over API,Bouncer: Gate (Pro+ only; Free skips) API->>Bouncer: Evaluate(guildID, visitorIP) Bouncer-->>API: Decision{Outcome: allow|deny, Reason} alt Decision.Outcome == deny Bouncer->>Outbox: INSERT BouncerDenied event API-->>Web: Redirect /join/blocked?reason=... Web-->>Visitor: "이 지역/네트워크에서는 가입할 수 없습니다" else Decision.Outcome == allow API->>Member: StartWebJoin(guildSlug, userID, access_token) Member->>Member: INSERT web_join_requests (state=pending, encrypted_token) Member-->>API: request{id} API->>Discord: PUT /guilds/{guildID}/members/{userDiscordID}
{ access_token, roles: [auto_role_id?] } Discord-->>API: 201 Created or 204 No Content API->>Member: CompleteWebJoin(requestID) Member->>Member: BEGIN TX Member->>Member: UPSERT members (joined_via='web_join', role_ids=[auto_role?]) Member->>Member: UPDATE web_join_requests (state=completed, token=NULL) Member->>Outbox: INSERT MemberJoinedViaWebJoin event Member->>Member: COMMIT API-->>Web: Redirect /join/success?guild=G Web-->>Visitor: "가입 완료! Discord 에서 확인하세요" end

Step-by-step

1. Landing page

join.umbra.ink/{slug} 는 Vercel 에서 서빙. {slug} 는 Guild 의 UNIQUE 필드.

프론트엔드가 API 호출:

GET /api/v1/web-join/resolve/{slug}

응답:

{
  "guild_id": "018f...",
  "guild_name": "My Cool Guild",
  "icon_url": "https://cdn.discordapp.com/icons/.../abc.png",
  "description": "프론트엔드 개발자 커뮤니티",
  "member_count": 1243
}

슬러그가 없거나 Guild 가 inactive 이면 404.

2. 사용자 "가입하기" 클릭

Discord OAuth2 authorize URL 로 리다이렉트:

https://discord.com/oauth2/authorize
  ?client_id={UMBRA_CLIENT_ID}
  &response_type=code
  &scope=identify+guilds.join
  &redirect_uri={API}/oauth/discord/callback
  &state=join:{slug}

statejoin:{slug} 를 encode 하여 콜백에서 컨텍스트 복원. CSRF 방지용 nonce 도 포함 (state=join:{slug}:{nonce}, nonce 는 쿠키에 저장).

3. Discord 승인 → Callback

사용자가 승인하면 Discord 가 codestate 로 콜백 URL 리다이렉트. API 는:

3a. Code exchange

POST https://discord.com/api/v10/oauth2/token
Body:
  grant_type=authorization_code
  code=...
  redirect_uri=...

응답: access_token, refresh_token, scope (반드시 guilds.join 포함 확인)

3b. User info

GET https://discord.com/api/v10/users/@me
Authorization: Bearer {access_token}

응답: id, username, discriminator, avatar, locale

4. UpsertUser (Identity)

user, err := identitySvc.UpsertUser(ctx, UpsertInput{
    DiscordUserID: resp.ID,
    Username:      resp.Username,
    AvatarHash:    resp.Avatar,
    Locale:        resp.Locale,
})

신규 User 면 UserRegistered 이벤트 발행. 기존 User 면 username/avatar 캐시 갱신.

4.5. Bouncer evaluation (access control gate)

OAuth2 완료 직후, 방문자 IP 에 대해 Bouncer 판정을 실행한다. 이 지점을 선택한 이유:

  • Discord OAuth2 를 통과했으므로 실제 사용자 도달 이 확실함 (봇 스캐너·링크 프리페치 제외)
  • 실제 리퀘스트의 IP 를 서버사이드에서 직접 관찰 — 조작 불가
  • guilds.join 호출 전이라 차단 시 Discord API 비용 0
decision, err := bouncerSvc.Evaluate(ctx, bouncer.EvaluateInput{
    GuildID:   guild.ID,
    RequestID: nil,  // StartWebJoin 이전이므로 아직 없음
    IP:        extractVisitorIP(echoCtx),
})

extractVisitorIP 는 Cloudflare 의 CF-Connecting-IP 헤더를 신뢰한다 (CF 가 Edge 이므로 스푸핑 차단). CF 부재 환경에서는 X-Forwarded-For 의 leftmost untrusted hop.

Free Plan 길드 — Licensing gate 에서 skip → Decision{Outcome: Allow, Reason: "free_plan_skip"} 즉시 반환, DB 기록 없음. 기존 플로우와 완전 동일하게 진행.

Pro/Enterprise 길드 — Policy 조회 및 IPProfile 판정. 자세한 의사결정 트리는 domain/bouncer.md 의 state machine 참조.

판정 결과가 deny 면:

  • bouncer.decisions 에 기록
  • BouncerDenied Outbox 이벤트 발행 (Audit 구독)
  • policies.notify_on_deny=true 면 관리자 알림 (asynq)
  • 사용자에게 /join/blocked?reason=<code> 페이지 리다이렉트
  • Member 플로우 진입하지 않음 (WebJoinRequest 생성 없음, Discord API 호출 없음)

판정 결과가 allow 면 다음 단계로 진행.

5. StartWebJoin (Member)

req, err := memberSvc.StartWebJoin(ctx, StartInput{
    Slug:        parseSlugFromState(state),
    UserID:      user.ID,
    AccessToken: discordResp.AccessToken,
})

Member Service:

  • Slug 로 Guild 조회
  • Guild active 인지 검증
  • web_join_requests 에 INSERT (state=pending, encrypted_token)
  • request.ID 반환

6. guilds.join API 호출

PUT https://discord.com/api/v10/guilds/{guild_discord_id}/members/{user_discord_id}
Authorization: Bot {UMBRA_BOT_TOKEN}
Body:
{
  "access_token": "{visitor_access_token}",
  "roles": ["{auto_role_id}"]  // 설정 있으면
}

응답:

  • 201 Created — 신규 가입 성공
  • 204 No Content — 이미 멤버 (Discord 가 멱등 처리)
  • 403 Forbidden — 봇이 길드에 없거나 권한 부족
  • 400 Bad Request — access_token 의 scope 부족

7. CompleteWebJoin

성공 시 Member Service:

BEGIN;

-- members 테이블 UPSERT (재가입 시 left_at NULL 로 복원)
INSERT INTO member.members (
    id, guild_id, discord_user_id, user_id,
    joined_via, joined_at, role_ids
) VALUES ($1, $2, $3, $4, 'web_join', NOW(), $5)
ON CONFLICT (guild_id, discord_user_id)
DO UPDATE SET
    user_id = EXCLUDED.user_id,
    joined_at = NOW(),
    left_at = NULL,
    joined_via = 'web_join',
    role_ids = EXCLUDED.role_ids,
    updated_at = NOW();

-- 요청 마감, token 삭제 (보안)
UPDATE member.web_join_requests
SET state = 'completed',
    encrypted_access_token = NULL,
    completed_at = NOW()
WHERE id = $req_id;

-- Outbox 이벤트
INSERT INTO events.outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('member', $member_id, 'MemberJoinedViaWebJoin', $payload);

COMMIT;

8. 성공 화면

API 가 join.umbra.ink/success 로 리다이렉트. 프론트엔드가:

  • "가입이 완료되었습니다" 메시지
  • "Discord 에서 열기" 버튼 (discord://channels/{guild_id} 딥링크)
  • (옵션) 다음 안내 표시 (규칙 읽기 등)

Failure cases

Slug 없음 / Guild inactive

  • When — 슬러그 오타, 봇이 강퇴됨, Guild 삭제
  • DetectionGetGuildBySlug 반환 nil 또는 bot_removed_at NOT NULL
  • Response — 404 페이지 with "이 링크는 만료되었거나 존재하지 않습니다"
  • User experience — 길드 관리자에게 재초대 요청 안내

Visitor 가 OAuth2 거부

  • When — Discord authorize 화면에서 Cancel
  • Detection — callback 에 error=access_denied
  • Response/join/canceled 페이지
  • User experience — "가입이 취소되었습니다" + 재시도 버튼

guilds.join scope 누락

  • When — Visitor 가 scope 를 선택적으로 거부 (일부 Discord 클라이언트에서 가능)
  • Detection — token exchange 응답의 scope 확인
  • Responseweb_join_requests.state='failed', failure_reason='scope_missing', 사용자에게 재시도 안내

Bot 이 길드에 없음

  • When — Guild 데이터는 있지만 실제로는 강퇴된 상태 (race)
  • Detectionguilds.join 403
  • Response
  • Guild 를 bot_removed_at 설정
  • License suspend (이미 감지됐어야 하지만 이벤트 누락 복구 차원)
  • Request failed
  • User experience — "이 길드의 Umbra 설정에 문제가 있습니다. 관리자에게 문의하세요"

Discord rate limit (guilds.join)

  • When — 짧은 시간에 대량 가입 시도
  • Detection — Discord 429 응답, Retry-After 헤더
  • Response — asynq 로 지연 재시도 (최대 3회), 사용자에게 "잠시 후 자동 완료" 메시지
  • User experience — 수 초~수십 초 지연 후 성공

Auto role 없음 또는 유효하지 않음

  • Whenweb_join_auto_role_id 가 삭제된 역할 가리킴
  • Detectionguilds.join 에 role 포함 시 400 가능
  • Response — role 없이 재시도 + Admin 에게 설정 수정 요청 DM
  • User experience — 역할 없이 가입 완료 (부분 성공)

Bouncer denies access (VPN/Proxy/Tor or blacklisted country)

  • When — Pro+ 길드의 Policy 에 매칭되는 IP 특성 감지
  • DetectionBouncer.EvaluateOutcome: deny 반환
  • Response
  • bouncer.decisions 기록 (outcome='deny', reason='policy_match_*')
  • BouncerDenied Outbox 이벤트 발행
  • Policy 의 notify_on_deny=true 면 관리자 Discord DM/채널 알림
  • /join/blocked?reason=<code> 페이지 (구체 사유는 모호하게 표기 — 우회 힌트 방지)
  • User experience — "이 지역 또는 네트워크에서는 가입할 수 없습니다. 길드 관리자에게 문의하세요"
  • Audit traildecisions 테이블과 Audit 이벤트로 관리자가 사후 분석 가능

Bouncer provider outage (fail-open)

  • When — ipquery.io API 장애 또는 MaxMind mmdb 로드 실패
  • DetectionIPIntelligence.Lookup 에러 반환
  • Response — MVP 는 fail-open. 정책 평가 통과 + decisions.provider_status='degraded' 기록. strict_mode 는 Phase 2
  • User experience — 없음 (정상 가입 진행)
  • Operator signal — 로그/메트릭으로 degraded 카운터 관찰, 임계치 초과 시 경보

Pending request 1시간 초과

  • When — OAuth2 콜백이 안 온 채로 방치
  • Detection — cron 이 1시간 지난 pending request 발견
  • Response — state='failed', token 삭제 (이미 NULL 일 수도)
  • User experience — 없음 (사용자가 이미 이탈)

Edge cases

Visitor 가 이미 길드 멤버

  • guilds.join 이 204 반환 (idempotent)
  • Member 는 UPSERT 로 처리, joined_via 는 기존 값 유지 (이미 direct 였다면 그대로)
  • 성공 페이지로 진행
  • Auto role 은 재부여하지 않음 (Discord 가 access_token 기반이라 role 추가 가능하지만 Umbra 는 skip)

Visitor 가 봇을 block 한 사용자

  • Discord API 가 거부 (특정 상황)
  • 실패 처리

사용자 탈퇴 후 재가입

  • Member row 는 left_at IS NOT NULL 로 남아 있음
  • 정책 (ADR-0027 에도 반영): UPSERT 로 left_at NULL 리셋, joined_at 갱신. 별도 row 생성하지 않음.

Visitor 가 이미 Umbra 계정 있음

  • identity.users 에 이미 row → upsert 로 캐시만 갱신
  • 세션 생성 여부는 별개 (여기서는 웹 조인 용 OAuth2 플로우이므로 umbra 웹 세션 생성 불필요)

Slug 이 특수문자 포함

  • guild.guilds.slug CHECK 제약 (^[a-z0-9-]{3,30}$) 이 생성 단계에서 방지
  • URL 에서 슬러그 라우팅은 표준 URL-safe 하게 유지

Auto role 에 Administrator 권한

  • 위험 — 가입하자마자 Administrator 를 받을 수 있음
  • 정책: Umbra 가 auto role 등록 시 Administrator / MANAGE_GUILD / BAN_MEMBERS 등 민감 권한 보유 role 은 경고 + 확인 게이트
  • MVP 는 경고만, Phase 2 에서 강제 차단 검토

Involved domains

Domain Role in this flow
Member WebJoinRequest 생성/완료, Member upsert (writer)
Identity Visitor User 생성/갱신
Guild Slug 조회 (reader)
Bouncer 접근 통제 판정 (Pro+ 에서만 활성; Free 는 skip)
Licensing Bouncer 활성 여부 게이트 (bouncer.evaluate feature)
Audit 이벤트 기록 (가입 완료 및 BouncerDenied)
Notification (옵션) 길드 감사 채널에 "OOO 가 웹 조인으로 가입" 알림, Bouncer deny 관리자 알림

See also

  • domain/member.md
  • domain/identity.md
  • domain/guild.md — slug 생성 규칙
  • domain/bouncer.md — 접근 통제 판정 상세
  • flows/bot-installation.md — 웹 조인이 Free 에서도 동작하는 이유
  • adr/0027-message-content-exclusion.md — 웹 조인은 메시지와 무관
  • adr/0033-bouncer-domain.md — Bouncer 도메인 신설
  • adr/0034-bouncer-ip-intelligence.md — IP 인텔리전스 공급자
  • adr/0035-bouncer-paid-tier-gating.md — Pro+ 에서만 활성