콘텐츠로 이동

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 — 슬러그 조회, 설정 참조
  • 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)

Postconditions

  • 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 기록

Sequence

sequenceDiagram
    participant Visitor
    participant Web as join.umbra.ink
    participant API as umbra-api
    participant DiscordOAuth as Discord OAuth2
    participant Identity
    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} 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 에서 확인하세요"

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 캐시 갱신.

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 — 역할 없이 가입 완료 (부분 성공)

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)
Audit 이벤트 기록
Notification (옵션) 길드 감사 채널에 "OOO 가 웹 조인으로 가입" 알림

See also

  • domain/member.md
  • domain/identity.md
  • domain/guild.md — slug 생성 규칙
  • flows/bot-installation.md — 웹 조인이 Free 에서도 동작하는 이유
  • adr/0027-message-content-exclusion.md — 웹 조인은 메시지와 무관