Web Join (join.umbra.ink/{slug})¶
외부에서 슬러그 URL 을 통해 Discord 길드에 가입하는 흐름. 사용자가 Discord OAuth2 로 인증하고, Umbra 가
guilds.joinAPI 로 길드에 추가한 뒤 자동 역할을 부여한다. 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.outbox에MemberJoinedViaWebJoin이벤트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 호출:
응답:
{
"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}
state 에 join:{slug} 를 encode 하여 콜백에서 컨텍스트 복원. CSRF 방지용 nonce 도 포함 (state=join:{slug}:{nonce}, nonce 는 쿠키에 저장).
3. Discord 승인 → Callback¶
사용자가 승인하면 Discord 가 code 와 state 로 콜백 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
응답: 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 삭제
- Detection —
GetGuildBySlug반환 nil 또는bot_removed_atNOT 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확인 - Response —
web_join_requests.state='failed',failure_reason='scope_missing', 사용자에게 재시도 안내
Bot 이 길드에 없음¶
- When — Guild 데이터는 있지만 실제로는 강퇴된 상태 (race)
- Detection —
guilds.join403 - 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 없음 또는 유효하지 않음¶
- When —
web_join_auto_role_id가 삭제된 역할 가리킴 - Detection —
guilds.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_atNULL 리셋,joined_at갱신. 별도 row 생성하지 않음.
Visitor 가 이미 Umbra 계정 있음¶
identity.users에 이미 row → upsert 로 캐시만 갱신- 세션 생성 여부는 별개 (여기서는 웹 조인 용 OAuth2 플로우이므로 umbra 웹 세션 생성 불필요)
Slug 이 특수문자 포함¶
guild.guilds.slugCHECK 제약 (^[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.mddomain/identity.mddomain/guild.md— slug 생성 규칙flows/bot-installation.md— 웹 조인이 Free 에서도 동작하는 이유adr/0027-message-content-exclusion.md— 웹 조인은 메시지와 무관