diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 64f7d53..3b65cbb 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -35,12 +35,13 @@ const ( ) // Confirmation purposes recorded on a pending confirm-code row. They select what -// verifying the code or the deeplink token does: sign in (login) or link/confirm the -// address on the current account (link). Email change and account deletion add -// further purposes in later stages. +// verifying the code or the deeplink token does: sign in (login), link/confirm the +// address on the current account (link), or replace the account's confirmed email with +// a new address (change). Account deletion adds a further purpose in a later stage. const ( - purposeLogin = "login" - purposeLink = "link" + purposeLogin = "login" + purposeLink = "link" + purposeChange = "change" ) // Errors returned by the email confirm-code flow. @@ -326,6 +327,26 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo return LinkConfirmation{}, err } return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil + case purposeChange: + owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email) + if err != nil { + return LinkConfirmation{}, err + } + if ok && owner != pend.accountID { + // The new address is confirmed by a different account: refuse without + // disclosing it (anti-enumeration). Unlike a link, a change never merges. + return LinkConfirmation{}, ErrEmailTaken + } + if ok && owner == pend.accountID { + if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil + } + if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil { + return LinkConfirmation{}, err + } + return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil default: return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose) } @@ -506,6 +527,50 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun return nil } +// replaceEmailIdentity consumes the confirmation, deletes the account's existing email +// identity (freeing the old address) and inserts newEmail as its confirmed email, inside +// one transaction. It backs the change-email flow. A unique-constraint violation — the +// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When +// the account holds no email identity yet the delete is a no-op, so this doubles as an +// attach. +func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error { + identityID, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new identity id: %w", err) + } + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + upd := table.EmailConfirmations. + UPDATE(table.EmailConfirmations.ConsumedAt). + SET(postgres.TimestampzT(now)). + WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("consume confirmation: %w", err) + } + del := table.Identities.DELETE().WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))), + ) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("delete old email identity: %w", err) + } + ins := table.Identities.INSERT( + table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind, + table.Identities.ExternalID, table.Identities.Confirmed, + ).VALUES(identityID, accountID, KindEmail, newEmail, true) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return err + } + return nil + }) + if err != nil { + if isUniqueViolation(err) { + return ErrEmailTaken + } + return fmt.Errorf("account: replace email identity: %w", err) + } + return nil +} + // confirmEmailLogin consumes the login code and marks the existing email // identity confirmed, inside one transaction. The identity already exists (a // login provisioned it), so this updates rather than inserts and is idempotent diff --git a/backend/internal/account/emailtemplate.go b/backend/internal/account/emailtemplate.go index 58a88ab..9222c2c 100644 --- a/backend/internal/account/emailtemplate.go +++ b/backend/internal/account/emailtemplate.go @@ -80,6 +80,24 @@ var confirmEmailCopy = map[string]map[string]emailCopy{ FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.", }, }, + purposeChange: { + "en": { + Subject: "Confirm your new Erudit e-mail", + Preheader: "Confirm your new address", + Heading: "Confirm your new e-mail", + Intro: "Enter this code to switch your account to this address:", + CTALabel: "Confirm with one tap", + FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.", + }, + "ru": { + Subject: "Подтвердите новый e-mail в Эрудит", + Preheader: "Подтвердите новый адрес", + Heading: "Смена e-mail", + Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:", + CTALabel: "Подтвердить одним нажатием", + FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.", + }, + }, } // emailBrand is the brand wordmark per locale. diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 9fa1b31..fe21dc7 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -119,6 +119,57 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema return accountID, true, nil } +// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated +// email change on accountID, replacing any prior pending code. Like RequestLinkCode it +// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the +// authorization, and a conflict with another account is revealed only at confirm — as a +// non-disclosing refusal, never a merge. +func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error { + addr, err := normalizeEmail(newEmail) + if err != nil { + return err + } + if !s.allowSend(addr) { + return ErrTooManyRequests + } + return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID)) +} + +// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the +// account's confirmed email with newEmail, freeing the old address. When newEmail is +// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the +// user as a non-disclosing "check the address or contact support"), never merging; when +// the account already owns newEmail it is an idempotent no-op. It returns the usual +// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, +// ErrCodeMismatch) and the updated account on success. +func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) { + addr, err := normalizeEmail(newEmail) + if err != nil { + return Account{}, err + } + conf, err := s.verifyPendingCode(ctx, accountID, addr, code) + if err != nil { + return Account{}, err + } + owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) + if err != nil { + return Account{}, err + } + if ok && owner != accountID { + return Account{}, ErrEmailTaken + } + if ok && owner == accountID { + if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil { + return Account{}, err + } + return s.store.GetByID(ctx, accountID) + } + if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { + return Account{}, err + } + return s.store.GetByID(ctx, accountID) +} + // verifyPendingCode loads and checks the pending confirm-code for (accountID, // addr), counting a wrong attempt. It returns the confirmation on success. func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) { diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 8fd71da..dc442a6 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -74,6 +74,12 @@ func (s *Server) registerRoutes() { u.POST("/link/email/merge", s.handleLinkEmailMerge) u.POST("/link/telegram", s.handleLinkTelegram) u.POST("/link/telegram/merge", s.handleLinkTelegramMerge) + u.POST("/link/unlink", s.handleUnlink) + // Change the account's confirmed email: mail a code to the new address, then + // atomically switch on confirm (a new address owned by another account is + // refused without disclosure, never merged). + u.POST("/link/email/change/request", s.handleChangeEmailRequest) + u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm) } if s.games != nil { u.GET("/games", s.handleListGames) @@ -231,6 +237,8 @@ func statusForError(err error) (int, string) { return http.StatusUnprocessableEntity, "illegal_play" case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken): return http.StatusConflict, "email_taken" + case errors.Is(err, account.ErrLastIdentity): + return http.StatusConflict, "last_identity" case errors.Is(err, accountmerge.ErrActiveGameConflict): return http.StatusConflict, "merge_active_game_conflict" case errors.Is(err, account.ErrInvalidEmail): diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index de37628..efbe667 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "scrabble/backend/internal/account" "scrabble/backend/internal/link" ) @@ -66,6 +67,89 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) { c.JSON(http.StatusOK, okResponse{OK: true}) } +// unlinkBody carries the provider kind to detach. +type unlinkBody struct { + Kind string `json:"kind"` +} + +// handleUnlink detaches a platform identity (telegram or vk) from the caller's +// account, refusing to remove the last identity (ErrLastIdentity). Email is never +// unlinked — it is replaced through the change-email flow — so an email kind is +// rejected. It returns the refreshed profile so the client updates its controls. +func (s *Server) handleUnlink(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req unlinkBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + if req.Kind != account.KindTelegram && req.Kind != account.KindVK { + abortBadRequest(c, "only telegram or vk can be unlinked") + return + } + ctx := c.Request.Context() + if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil { + s.abortErr(c, err) + return + } + acc, err := s.accounts.GetByID(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + r := s.profileResponse(ctx, acc) + c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r}) +} + +// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated +// email change. Like the link request it never signals "taken" up front — a conflict is +// only revealed (non-disclosingly) at confirm. +func (s *Server) handleChangeEmailRequest(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkEmailRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleChangeEmailConfirm verifies the code and atomically switches the account to the +// new address, returning the refreshed profile. A new address confirmed by another +// account is refused (ErrEmailTaken → the non-disclosing message), never merged. +func (s *Server) handleChangeEmailConfirm(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkEmailConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + ctx := c.Request.Context() + acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code) + if err != nil { + s.abortErr(c, err) + return + } + r := s.profileResponse(ctx, acc) + c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r}) +} + // handleLinkEmailConfirm verifies the code and binds a free email or reports a // required merge. func (s *Server) handleLinkEmailConfirm(c *gin.Context) { diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 65044ba..164edf0 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -335,6 +335,31 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin return out, err } +// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an +// authenticated email change. +func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/request", userID, "", + map[string]string{"email": email}, nil) +} + +// ChangeEmailConfirm verifies the code and atomically switches the account's email, +// returning the refreshed profile in the result. +func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/confirm", userID, "", + map[string]string{"email": email, "code": code}, &out) + return out, err +} + +// LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller +// and returns the refreshed profile in the result. +func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/unlink", userID, "", + map[string]string{"kind": kind}, &out) + return out, err +} + // Stats returns the caller's lifetime statistics. func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) { var out StatsResp diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index 546c279..5d6d3af 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -13,11 +13,14 @@ import ( // authenticated. The merge ops are the explicit irreversible step, gated in the UI // after a merge_required confirm. const ( - MsgLinkEmailRequest = "link.email.request" - MsgLinkEmailConfirm = "link.email.confirm" - MsgLinkEmailMerge = "link.email.merge" - MsgLinkTelegram = "link.telegram.confirm" - MsgLinkTelegramMerge = "link.telegram.merge" + MsgLinkEmailRequest = "link.email.request" + MsgLinkEmailConfirm = "link.email.confirm" + MsgLinkEmailMerge = "link.email.merge" + MsgLinkTelegram = "link.telegram.confirm" + MsgLinkTelegramMerge = "link.telegram.merge" + MsgLinkUnlink = "link.unlink" + MsgEmailChangeRequest = "link.email.change.request" + MsgEmailChangeConfirm = "link.email.change.confirm" ) // registerLinkOps adds the linking & merge operations. The telegram ops need the @@ -26,6 +29,9 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true} + r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true} + r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true} if tg != nil { r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} @@ -64,6 +70,43 @@ func linkEmailMergeHandler(backend *backendclient.Client) Handler { } } +// changeEmailRequestHandler mails a confirm-code to a new address for an email change. +func changeEmailRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailRequest(req.Payload, 0) + if err := backend.ChangeEmailRequest(ctx, req.UserID, string(in.Email())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +// changeEmailConfirmHandler verifies the code and switches the account's email, returning +// the refreshed link result (status "changed" + the updated profile). +func changeEmailConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) + res, err := backend.ChangeEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + +// linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and +// returns the refreshed link result (status "unlinked" + the updated profile). +func linkUnlinkHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkUnlinkRequest(req.Payload, 0) + res, err := backend.LinkUnlink(ctx, req.UserID, string(in.Kind())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + // linkTelegramHandler validates Login Widget data via the connector and then calls // the backend's link or merge endpoint with the trusted Telegram external id. func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 8ca031d..758a212 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -501,6 +501,12 @@ table LinkTelegramRequest { data:string; } +// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the +// caller's account; email is never unlinked (it is changed). +table LinkUnlinkRequest { + kind:string; +} + // LinkResult is the unified result of a confirm or merge step. status is "linked" // (bound to the caller), "merge_required" (the identity belongs to another account — // the secondary_* fields summarise it for the irreversible confirmation), or diff --git a/pkg/fbs/scrabblefb/LinkUnlinkRequest.go b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go new file mode 100644 index 0000000..1c05dd0 --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkUnlinkRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkUnlinkRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkUnlinkRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkUnlinkRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkUnlinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkUnlinkRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkUnlinkRequest) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func LinkUnlinkRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func LinkUnlinkRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0) +} +func LinkUnlinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index f8b81e2..ce02ba1 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -51,6 +51,7 @@ export { LinkEmailConfirm } from './scrabblefb/link-email-confirm.js'; export { LinkEmailRequest } from './scrabblefb/link-email-request.js'; export { LinkResult } from './scrabblefb/link-result.js'; export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js'; +export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js'; export { MatchFoundEvent } from './scrabblefb/match-found-event.js'; export { MatchResult } from './scrabblefb/match-result.js'; export { MoveRecord } from './scrabblefb/move-record.js'; diff --git a/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts b/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts new file mode 100644 index 0000000..6604578 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/link-unlink-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class LinkUnlinkRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LinkUnlinkRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest { + return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +kind():string|null +kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +kind(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startLinkUnlinkRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, kindOffset, 0); +} + +static endLinkUnlinkRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLinkUnlinkRequest(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset { + LinkUnlinkRequest.startLinkUnlinkRequest(builder); + LinkUnlinkRequest.addKind(builder, kindOffset); + return LinkUnlinkRequest.endLinkUnlinkRequest(builder); +} +}