Lingara Lingara 문서 학습 가이드 API 라이브러리 앱 만들기 임베드 웹 앱
언어: 한국어

서버 키트

이 페이지는 영어에서 번역되었습니다. 두 내용이 다를 경우 영어 페이지가 정확합니다. 영어 페이지 읽기

여러분의 서버는 임베드에서 시크릿을 보관하는 쪽입니다. 서버 키트는 여러분이 쓰는 언어의 Lingara 라이브러리를 감싸므로, OAuth 클라이언트로 로그인해 플레이어를 위한 토큰을 발급하고, Lingara가 보내는 웹훅을 검증하고, 플레이어가 떠나면 그 플레이어를 삭제합니다. 아래 샘플은 각 언어에서 같은 단계를 따릅니다.

사용량 기반 결제 클라이언트 만들기

콘솔에서 OAuth 클라이언트를 만들고 사용량 기반 결제 요금제로 설정합니다. 토큰을 발급하고 플레이어를 삭제하는 스코프인 embed:mint를 부여하세요. 다른 요금제의 클라이언트에는 이 스코프가 거부됩니다. 또한 플레이어의 토큰에 담고 싶은 모든 스코프도 클라이언트에 부여하세요. 토큰은 클라이언트가 가진 스코프만 가질 수 있기 때문입니다. embed:mint 자체는 플레이어에게 절대 넘겨지지 않습니다.

클라이언트의 ID와 시크릿은 서버의 환경에 보관하고, 게임이나 페이지에는 절대 넣지 마세요.

웹 오리진 등록하기

웹사이트 위젯에는 페이지의 오리진에 묶인 토큰이 필요합니다. 콘솔에서 클라이언트 설정에 https://www.example.com처럼 위젯이 실행되는 모든 오리진을 나열하세요. 최대 10개까지 나열할 수 있습니다. 오리진은 스킴, 호스트, 선택적인 포트로 이루어지며 경로는 포함하지 않습니다. 개발용으로는 http://localhost:5173처럼 직접 고른 포트의 루프백 오리진도 허용됩니다. 토큰은 발급 대상이었던 바로 그 오리진에서만 받아들여지므로, 다른 오리진용으로 발급된 토큰은 API가 거부합니다. 게임에는 오리진이 필요하지 않습니다.

토큰 발급하기

발급은 여러분 자체의 로그인 뒤에서 이루어집니다. 토큰 라우트는 먼저 자체 세션을 확인하고, 그다음에야 그 플레이어를 위해 발급합니다. 라우트에는 세션별 속도 제한을 거세요. 발급 요청은 세 개의 필드를 받습니다.

  • player_ref: 플레이어에 대한 여러분 자체의 참조로, UTF-8 기준 1~128바이트입니다. Lingara는 그 해시만 저장합니다.
  • scopes: 최대 5개로, 플레이어의 토큰이 가질 수 있는 events:read, events:write, lesson_plans:read, lesson_plans:write, embed:play 중에서 고릅니다. 생략하면 토큰은 이 중 클라이언트가 가진 모든 스코프를 가집니다.
  • origin: 웹사이트의 경우 페이지의 오리진으로, 등록한 오리진이어야 합니다. 게임에서는 생략합니다.

응답에는 token, expires_at, expires_in(같은 만료를 초 단위로 나타낸 값으로, 시계를 신뢰할 수 없는 기기를 위한 것), subject, 토큰의 scopes, 그리고 플레이어가 자신의 Lingara 계정을 연결했는지 알려 주는 account_linked가 들어 있습니다. subject는 여러분의 클라이언트에 대해 플레이어를 식별하며, 그 플레이어에 관한 모든 이벤트에 담깁니다.

키트의 핸드오프가 작성하는 그대로 플레이어에게 토큰과 그 만료 시각을 넘기세요. 자체 세션을 통해 전달하고 URL에는 절대 담지 말며, 응답은 Cache-Control: no-store와 함께 보냅니다. 플레이어 키트는 토큰의 만료가 가까워지면 다시 요청합니다. 토큰은 절대 갱신되지 않습니다.

웹훅을 플레이어에게 라우팅하기

Lingara는 보내는 모든 웹훅에 서명합니다. 서명은 요청 본문의 바이트를 대상으로 하므로, JSON 파싱을 하기 전의 원본 요청 본문을 키트에 넘기세요. 키트는 서명을 검증하고, 이벤트의 subject가 가리키는 플레이어를 여러분 자체의 테이블에서 찾습니다. 이 테이블은 플레이어 정보 옆에 두고, 각 서브젝트를 여러분의 player_ref에 매핑합니다. 계정을 연결한 플레이어는 두 개의 서브젝트를 가지므로, 서브젝트 열은 고유하게 유지하고 하나의 player_ref가 두 번 나타나도록 허용하세요.

전달은 최소 한 번 이루어집니다. 이미 처리한 이벤트 ID는 건너뛰세요. 10초 안에 응답하고, 오래 걸리는 작업은 여러분 자체의 큐에 넣으세요.

플레이어 삭제하기

여러분 자체의 계정 삭제 경로에서 player_ref로 플레이어를 삭제합니다. Lingara는 그 플레이어가 가진 모든 토큰을 취소하고, 그 플레이어에 대해 저장한 모든 것을 삭제합니다. 알 수 없는 플레이어를 삭제해도 성공하므로, 이 호출은 반복해도 안전합니다. 여러분 자체의 데이터를 삭제하는 일은 여전히 여러분의 몫입니다.

자신의 Lingara 계정을 가져오는 플레이어

자신의 Lingara 계정이 있는 플레이어는 그 계정을 연결해, 연습과 계획을 그 계정에 둘 수 있습니다. 아직 이를 감싸는 키트는 없으며, 여러분의 서버에서 하는 두 번의 API 호출입니다.

여러분의 서버는 발급에 쓰는 것과 같은 클라이언트로 PKCE를 사용하는 인가 코드 흐름을 실행합니다. 플레이어는 게임 안이 아니라 반드시 브라우저에서 로그인하고 동의하며, 여러분의 서버는 자신의 시크릿으로 코드를 교환합니다. 그 동의에서 embed:play와 발급에 쓰는 모든 스코프를 요청하고, events:read도 포함하세요. 그러지 않으면 플레이어가 연결한 뒤에는 그 플레이어에 대해 아무것도 받지 못합니다.

그런 다음 그 교환이 돌려준 위임 토큰으로 linkEmbedPlayer를 호출합니다. 클라이언트 자체의 토큰은 절대 쓰지 않습니다. 응답에는 다음이 들어 있습니다.

  • status: 이 호출로 연결이 만들어졌으면 linked, 이미 활성 상태였으면 existing
  • subject: 계정의 서브젝트로, 이제부터 플레이어의 이벤트에 담깁니다
  • previous_subject: 게스트의 서브젝트로, 이전 이벤트는 이것을 유지합니다
  • linked_at: 연결이 만들어진 시각
  • scopes: 이 플레이어의 토큰이 이제 가질 수 있는 최대 스코프
  • carried: 이 호출이 계정으로 옮긴 행(테이블별)
  • kept: 아직 작성 중인 계획처럼 게스트에게 남겨 둔 행

연결은 반복해도 안전하며, 반복하면 kept가 보고한 것의 이동이 마무리됩니다. 어느 서브젝트를 담은 이벤트든 같은 player_ref로 라우팅하세요. 그 플레이어에 대한 다음 발급에서는 account_linked가 true로 응답되며, 플레이어가 하는 일에 대한 비용은 여전히 여러분이 냅니다.

연결을 끝내려면 클라이언트 자체의 토큰으로 unlinkEmbedPlayer를 호출합니다. 게스트에게 되돌아가는 것은 없습니다. 학습자도 연결된 앱에서 여러분의 앱을 제거해 직접 연결을 끝낼 수 있습니다.

코드 예제 언어

TypeScript

npm i @lingara/embed@0.1.0-alpha.6 @lingara/api

Rust

cargo add lingara-embed@0.1.0-alpha.6 --features server
cargo add lingara tokio --features tokio/full

Go

go get github.com/Spinning-Cat-Studios/lingara_embeddable_sdk/go@v0.1.0-alpha.6

Java

<dependency>
  <groupId>com.getlingara</groupId>
  <artifactId>lingara-embed-server-java</artifactId>
  <version>0.1.0-alpha.6</version>
</dependency>

Kotlin

implementation("com.getlingara:lingara-embed-server-kotlin:0.1.0-alpha.6")

Ruby

gem install lingara-embed -v 0.1.0-alpha.6
# or, in a Gemfile:
# gem "lingara-embed", "0.1.0-alpha.6"

PHP

composer require spinningcatstudios/lingara-embed:0.1.0-alpha.6 spinningcatstudios/lingara:@alpha
# The lingara library sends through Symfony HttpClient (or Guzzle 7):
composer require symfony/http-client nyholm/psr7

C#

<PackageReference Include="Lingara.Embed.Server" Version="0.1.0-alpha.6" />
코드 예제 언어

TypeScript

const lingara = new Lingara({ clientId: process.env.LINGARA_CLIENT_ID, clientSecret: process.env.LINGARA_CLIENT_SECRET })
const server = new EmbedServer(lingara, { directory })

// POST /lingara/token, behind your own sign-in. Rate-limit it per session.
export async function token(request: Request): Promise<Response> {
  const user = await signedInUser(request)
  if (user === null) return new Response(null, { status: 401 })
  const minted = await server.mintToken(user.id, { origin: 'https://www.example.com' })
  return new Response(tokenHandoff(minted), { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
}

Rust

/// `POST /lingara/token`: your own route, behind your own session, and
/// rate-limited per session. The widget calls it with same-origin cookies.
pub async fn token_route(State(app): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    let Some(player_ref) = current_player(&headers) else {
        return (StatusCode::UNAUTHORIZED, [(header::CACHE_CONTROL, "no-store")], String::new());
    };
    // This page's origin, one of your client's embed origins. A game's own
    // server mints without one.
    let options = MintOptions { origin: Some("https://game.example".into()), ..MintOptions::default() };
    match app.embed.mint_token(&player_ref, options).await {
        // {"token":"lgr_et_…","expires_at":"…"}
        Ok(minted) => (StatusCode::OK, [(header::CACHE_CONTROL, "no-store")], token_handoff(&minted)),
        Err(_) => (StatusCode::BAD_GATEWAY, [(header::CACHE_CONTROL, "no-store")], String::new()),
    }
}

Go

// POST /lingara/token: your own route, behind your own session, and
// rate-limited per session. The widget calls it with same-origin cookies.
return func(w http.ResponseWriter, r *http.Request) {
	playerRef, ok := currentPlayer(r) // your user, never a value from the request body
	if !ok {
		http.Error(w, "sign in first", http.StatusUnauthorized)
		return
	}
	minted, err := embed.MintToken(r.Context(), playerRef, lingaraembed.MintOptions{
		Origin: "https://game.example", // this page's origin, one of your client's embed origins
	})
	if err != nil {
		http.Error(w, "no token", http.StatusBadGateway)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	_, _ = w.Write(lingaraembed.TokenHandoff(minted)) // {"token":"lgr_et_…","expires_at":"…"}
}

Java

import com.getlingara.client.LingaraClient;
import com.getlingara.client.MintedToken;
import com.getlingara.embed.server.EmbedServer;
import com.getlingara.embed.server.MintOptions;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.TokenHandoff;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
LingaraClient client =
    LingaraClient.builder()
        .clientCredentials(
            System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
        .build();
EmbedServer embed = EmbedServer.of(client, players);

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
String playerId = currentPlayer(exchange);
if (playerId == null) {
  exchange.sendResponseHeaders(401, -1);
  return;
}
MintedToken minted =
    embed.mintToken(playerId, MintOptions.none().withOrigin("https://game.example"));
byte[] body = TokenHandoff.body(minted).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(200, body.length); // {"token":"lgr_et_…","expires_at":"…"}
exchange.getResponseBody().write(body);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.EmbedServer
import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.tokenHandoff
import com.getlingara.kotlin.LingaraClient
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
val client =
    LingaraClient {
        clientCredentials(System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
    }
val embed = EmbedServer(client, players)

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
post("/lingara/token") {
    val playerId = call.request.headers["X-Example-Player"] ?: return@post call.respond(HttpStatusCode.Unauthorized)
    val minted = embed.mintToken(playerId, origin = "https://game.example")
    call.response.header(HttpHeaders.CacheControl, "no-store")
    call.respondText(tokenHandoff(minted), ContentType.Application.Json) // {"token":"lgr_et_…","expires_at":"…"}
}

Ruby

# POST /lingara/token, behind your own sign-in. Rate-limit it per session.
def self.token(env, server)
  player = env["warden"]&.user or return [401, {}, []]
  minted = server.mint_token(player.id.to_s, origin: "https://game.example")
  [200, {"content-type" => "application/json", "cache-control" => "no-store"}, [Lingara::Embed.token_handoff(minted)]]
end

PHP

use Lingara\Client;
use Lingara\Embed\EmbedServer;
use Lingara\Embed\TokenHandoff;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
function embedServer(\PDO $db): EmbedServer
{
    $client = new Client(
        clientId: getenv('LINGARA_CLIENT_ID') ?: null,
        clientSecret: getenv('LINGARA_CLIENT_SECRET') ?: null,
    );
    return new EmbedServer($client, new PdoPlayerDirectory($db));
}

// POST /lingara/token: your own route, behind your own login. Rate-limit it
// per session; every call is one mint.
function tokenRoute(EmbedServer $embed): void
{
    session_start();
    $playerId = $_SESSION['player_id'] ?? null;
    if (!is_string($playerId)) {
        http_response_code(401);
        return;
    }
    $minted = $embed->mintToken($playerId, origin: 'https://game.example');
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo TokenHandoff::body($minted); // {"token":"lgr_et_…","expires_at":"…"}
}

C#

// One EmbedServer for the process: it caches the client-credentials token.
var lingara = new EmbedServer(new EmbedServerOptions
{
    ClientId = Environment.GetEnvironmentVariable("LINGARA_CLIENT_ID")!,         // lgr_cid_…
    ClientSecret = Environment.GetEnvironmentVariable("LINGARA_CLIENT_SECRET")!, // lgr_cs_…, from your secret store
}, new SqlPlayerDirectory(db));

// Your own session says who the player is. Rate-limit this route per session.
app.MapPost("/lingara/token", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier);
    if (playerRef is null)
    {
        return Results.Unauthorized();
    }

    // This page's origin, one of your client's embed origins. A game's server omits it.
    var minted = await lingara.MintTokenAsync(playerRef, new MintOptions { Origin = "https://game.example" }, ct);
    return Results.Text(TokenHandoff.Body(minted), "application/json"); // {"token":"lgr_et_…","expires_at":"…"}
}).RequireAuthorization().RequireRateLimiting("lingara-token");
코드 예제 언어

TypeScript

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their rows. Deleting your own data stays yours.
export async function deleteAccount(userId: string): Promise<void> {
  await server.deletePlayer(userId)
}

Rust

// Revokes every token the player holds, then forgets their subjects.
// An unknown player is a success; on a failure the row is kept.
app.embed.delete_player(player_ref).await?;

Go

// From your own account-deletion path: revokes every token the player
// holds and forgets their subjects. An unknown player is a success.
if err := embed.DeletePlayer(r.Context(), playerRef); err != nil {
	var apiErr *lingara.APIError
	if errors.As(err, &apiErr) {
		return apiErr // the library's own error: Status, Code, RetryAfter
	}
	return err
}

Java

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId);

Kotlin

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId)

Ruby

# From your account-deletion path: revokes the player's tokens at Lingara
# and forgets their rows. An unknown player is a success.
def self.delete_account(server, player)
  server.delete_player(player.id.to_s)
end

PHP

use Lingara\Embed\EmbedServer;

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success too,
// so a retry is safe; on a failure the rows are kept.
function deleteAccount(EmbedServer $embed, \PDO $db, string $playerId): void
{
    $embed->deletePlayer($playerId);
    $delete = $db->prepare('DELETE FROM players WHERE id = ?') ?: throw new \RuntimeException('cannot prepare');
    $delete->execute([$playerId]);
}

C#

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success.
app.MapDelete("/account", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    await lingara.DeletePlayerAsync(playerRef, ct);
    // …then delete your own rows for this player.
    return Results.NoContent();
}).RequireAuthorization();
코드 예제 언어

TypeScript

// Your own table, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE);
//   CREATE INDEX ON lingara_players (player_ref);
class PlayerTable implements PlayerDirectory {
  async remember(playerRef: string, subject: string): Promise<void> {
    await db.query('INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1', [playerRef, subject])
  }
  async playerRefFor(subject: string): Promise<string | undefined> {
    const rows = await db.query<{ player_ref: string }>('SELECT player_ref FROM lingara_players WHERE subject = $1', [subject])
    return rows[0]?.player_ref
  }
  async forget(playerRef: string): Promise<void> {
    await db.query('DELETE FROM lingara_players WHERE player_ref = $1', [playerRef])
  }
}

const directory = new PlayerTable()
const router = new PlayerRouter({ secrets: process.env.LINGARA_WEBHOOK_SECRET ?? '', directory })

// POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
export async function webhook(request: Request): Promise<Response> {
  const body = new Uint8Array(await request.arrayBuffer())
  const status = await router.handle(body, request.headers, async ({ event, playerRef }) => {
    // Delivery is at least once: skip an event.id you have already seen.
    // Answer within 10 s; enqueue anything slow and return.
    if (playerRef !== null && event.type === 'practice.completed') await practiceJobs.enqueue(playerRef, event.id)
  })
  return new Response(null, { status })
}

Rust

// Every live secret: two during a rotation, so either one verifies.
let webhook = lingara::events::Webhook::with_secrets(env("LINGARA_WEBHOOK_SECRETS").split(','))?;
let router = PlayerRouter::new(webhook, players);

/// `POST /lingara/webhook`: the **raw** body, before any JSON parsing.
/// 204 handled, 401 a bad signature, 400 a malformed payload, 500 your
/// handler failed. Lingara redelivers every non-2xx; dedupe on the event id.
pub async fn webhook_route(State(app): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
    let status = app
        .router
        .handle(&body, &headers, |routed| async move {
            match routed.player_ref {
                Some(player) => println!("{} for {player}", routed.event.event_type()),
                None => println!("{} for no player of ours", routed.event.event_type()),
            }
            Ok(()) // a slow handler enqueues and returns: delivery expects a 2xx within 10 s
        })
        .await;
    StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}

/// Your own store, beside your player rows: `player_ref`, and `subject`
/// with a unique index (a player who links an account owns two subjects).
pub struct SqlPlayerDirectory {
    pub pool: Arc<tokio::sync::Mutex<Vec<(String, String)>>>, // stands in for your database pool
}

impl PlayerDirectory for SqlPlayerDirectory {
    fn remember<'a>(&'a self, player_ref: &'a str, subject: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
        //   ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref
        Box::pin(async move {
            let mut rows = self.pool.lock().await;
            rows.retain(|(_, s)| s != subject);
            rows.push((player_ref.to_string(), subject.to_string()));
            Ok(())
        })
    }

    fn player_ref_for<'a>(&'a self, subject: &'a str) -> BoxFuture<'a, Result<Option<String>, DirectoryError>> {
        // SELECT player_ref FROM lingara_players WHERE subject = $1
        Box::pin(async move { Ok(self.pool.lock().await.iter().find(|(_, s)| s == subject).map(|(p, _)| p.clone())) })
    }

    fn forget<'a>(&'a self, player_ref: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // DELETE FROM lingara_players WHERE player_ref = $1
        Box::pin(async move {
            self.pool.lock().await.retain(|(p, _)| p != player_ref);
            Ok(())
        })
    }
}

Go

// lingara_players(player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE),
// with a plain index on player_ref: one player may own several subjects.
type sqlPlayers struct{ db *sql.DB }

func (p sqlPlayers) Remember(ctx context.Context, playerRef, subject string) error {
	_, err := p.db.ExecContext(ctx, `INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
		ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref`, playerRef, subject)
	return err
}

func (p sqlPlayers) PlayerRefFor(ctx context.Context, subject string) (string, bool, error) {
	var playerRef string
	err := p.db.QueryRowContext(ctx, `SELECT player_ref FROM lingara_players WHERE subject = $1`, subject).Scan(&playerRef)
	if errors.Is(err, sql.ErrNoRows) {
		return "", false, nil
	}
	return playerRef, err == nil, err
}

func (p sqlPlayers) Forget(ctx context.Context, playerRef string) error {
	_, err := p.db.ExecContext(ctx, `DELETE FROM lingara_players WHERE player_ref = $1`, playerRef)
	return err
}

// WebhookRoute is the webhook example.
func WebhookRoute(db *sql.DB) (http.HandlerFunc, error) {
	router, err := lingaraembed.NewPlayerRouter(sqlPlayers{db}, os.Getenv("LINGARA_WEBHOOK_SECRET"))
	if err != nil {
		return nil, err // a malformed lgr_whsec_… fails here, at startup
	}
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body) // the raw body: verify before parsing
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(router.Handle(r.Context(), body, r.Header, onEvent))
	}, nil
}

func onEvent(ctx context.Context, routed lingaraembed.RoutedEvent) error {
	// Delivery is at least once: skip an event.Meta().ID you have seen.
	if e, ok := routed.Event.(lingara.PracticeCompleted); ok && routed.Known {
		slog.InfoContext(ctx, "practice completed", "player", routed.PlayerRef, "plan", e.Data.PlanID)
	}
	return nil // a returned error answers 500, and Lingara redelivers
}

Java

import com.getlingara.client.events.PracticeCompleted;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.PlayerRouter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import javax.sql.DataSource;

/**
 * Your player directory, beside your player rows:
 *
 * <pre>
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * </pre>
 */
public record JdbcPlayerDirectory(DataSource db) implements PlayerDirectory {
  @Override
  public void remember(String playerRef, String subject) {
    update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?)"
            + " ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject);
  }

  @Override
  public Optional<String> playerRefFor(String subject) {
    try (Connection c = db.getConnection();
        PreparedStatement q =
            c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?")) {
      q.setString(1, subject);
      try (ResultSet row = q.executeQuery()) {
        return row.next() ? Optional.of(row.getString(1)) : Optional.empty();
      }
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }

  @Override
  public void forget(String playerRef) {
    update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef);
  }

  private void update(String sql, String... values) {
    try (Connection c = db.getConnection();
        PreparedStatement q = c.prepareStatement(sql)) {
      for (int i = 0; i < values.length; i++) {
        q.setString(i + 1, values[i]);
      }
      q.executeUpdate();
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
PlayerRouter router =
    PlayerRouter.of(new JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"));

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
byte[] body = exchange.getRequestBody().readAllBytes();
int status =
    router.handle(
        body,
        exchange.getRequestHeaders(),
        routed -> {
          // Delivery is at least once: skip an event.id you have seen.
          if (routed.event() instanceof PracticeCompleted done
              && routed.playerRef().isPresent()) {
            award(routed.playerRef().get(), done);
          }
        });
exchange.sendResponseHeaders(status, -1);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.PlayerRouter
import com.getlingara.kotlin.events.PracticeCompleted
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.sql.DataSource

/**
 * Your player directory, beside your player rows:
 *
 * ```sql
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * ```
 */
class JdbcPlayerDirectory(
    private val db: DataSource,
) : PlayerDirectory {
    override suspend fun remember(
        playerRef: String,
        subject: String,
    ) = update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) " +
            "ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject,
    )

    override suspend fun playerRefFor(subject: String): String? =
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?").use { q ->
                    q.setString(1, subject)
                    q.executeQuery().use { row -> if (row.next()) row.getString(1) else null }
                }
            }
        }

    override suspend fun forget(playerRef: String) = update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef)

    private suspend fun update(
        sql: String,
        vararg values: String,
    ) {
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement(sql).use { q ->
                    values.forEachIndexed { i, v -> q.setString(i + 1, v) }
                    q.executeUpdate()
                }
            }
        }
    }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
val router = PlayerRouter(JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"))

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
post("/lingara/webhook") {
    val headers =
        call.request.headers
            .entries()
            .associate { it.key to it.value }
    val status =
        router.handle(call.receive<ByteArray>(), headers) { routed ->
            // Delivery is at least once: skip an event.id you have seen.
            val event = routed.event
            val playerId = routed.playerRef
            if (event is PracticeCompleted && playerId != null) award(playerId, event)
        }
    call.respond(HttpStatusCode.fromValue(status))
}

Ruby

# Your own table: CREATE TABLE lingara_players (player_ref TEXT NOT NULL,
# subject TEXT NOT NULL UNIQUE); CREATE INDEX ON lingara_players (player_ref).
# Any object answering these three methods is a directory; this one is over
# Sequel (`db = Sequel.connect(ENV.fetch("DATABASE_URL"))`).
class PlayerTable
  def initialize(db)
    @rows = db[:lingara_players]
  end

  def remember(player_ref, subject)
    @rows.insert_conflict(target: :subject, update: {player_ref: player_ref})
      .insert(player_ref: player_ref, subject: subject)
  end

  def player_ref_for(subject)
    @rows.where(subject: subject).get(:player_ref)
  end

  def forget(player_ref)
    @rows.where(player_ref: player_ref).delete
  end
end

# POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
def self.webhook(env, router)
  status = router.handle(env["rack.input"].read, env) do |routed|
    # Delivery is at least once: skip an event.id you have already seen.
    # Answer within 10 s; enqueue anything slow and return.
    PracticeJobs.enqueue(routed.player_ref, routed.event) if routed.player_ref
  end
  [status, {}, []]
end

PHP

use Lingara\Embed\PlayerDirectory;
use Lingara\Embed\PlayerRouter;
use Lingara\Embed\RoutedEvent;
use Lingara\Events\Generated\PracticeCompleted;
use Psr\Http\Message\ServerRequestInterface;

// POST /lingara/webhook, from any PSR-7 stack (or pass getallheaders()).
// Read the body exactly as received. handle() returns the status to send,
// with an empty body: 204, 401 a bad signature, 400 a malformed payload, 500
// your handler or directory threw. Lingara redelivers every non-2xx, and
// expects the 2xx within 10 s: enqueue slow work and return.
function webhookRoute(ServerRequestInterface $request, \PDO $db): int
{
    $router = new PlayerRouter(new PdoPlayerDirectory($db), getenv('LINGARA_WEBHOOK_SECRET') ?: '');
    return $router->handle((string) $request->getBody(), $request, static function (RoutedEvent $routed) use ($db): void {
        $event = $routed->event;
        if (!$event instanceof PracticeCompleted || $routed->playerRef === null) {
            return; // your own subject, a deleted player, or another type
        }
        // Delivery is at least once: deduplicate on the event's id.
        $insert = $db->prepare('INSERT INTO practice (event_id, player_id, plan_id) VALUES (?, ?, ?) ON CONFLICT (event_id) DO NOTHING')
            ?: throw new \RuntimeException('cannot prepare');
        $insert->execute([$event->id, $routed->playerRef, $event->data->getPlanId()]);
    });
}

// The directory, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
//   CREATE UNIQUE INDEX ON lingara_players (subject);
//   CREATE INDEX ON lingara_players (player_ref);
final class PdoPlayerDirectory implements PlayerDirectory
{
    public function __construct(
        private readonly \PDO $db,
    ) {}

    public function remember(string $playerRef, string $subject): void
    {
        $this->run('INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) '
            . 'ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref', [$playerRef, $subject]);
    }

    public function playerRefFor(string $subject): ?string
    {
        $ref = $this->run('SELECT player_ref FROM lingara_players WHERE subject = ?', [$subject])->fetchColumn();
        return is_string($ref) ? $ref : null;
    }

    public function forget(string $playerRef): void
    {
        $this->run('DELETE FROM lingara_players WHERE player_ref = ?', [$playerRef]);
    }

    /** @param list<string> $args */
    private function run(string $sql, array $args): \PDOStatement
    {
        $statement = $this->db->prepare($sql) ?: throw new \RuntimeException('cannot prepare a statement');
        $statement->execute($args);
        return $statement;
    }
}

C#

public static partial class LingaraWebhook
{
    public static void MapLingaraWebhook(this WebApplication app, DbDataSource db)
    {
        // lgr_whsec_… from your secret store; pass both during a rotation.
        var router = new PlayerRouter(new SqlPlayerDirectory(db), Environment.GetEnvironmentVariable("LINGARA_WEBHOOK_SECRET")!);

        app.MapPost("/lingara/webhook", async (HttpRequest request, CancellationToken ct) =>
        {
            using var body = new MemoryStream();
            await request.Body.CopyToAsync(body, ct); // the raw bytes: never a re-serialised object
            var status = await router.HandleAsync(body.ToArray(), name => request.Headers[name].FirstOrDefault(), async (routed, token) =>
            {
                // Delivery is at least once: skip an Event.Id you have seen. Enqueue
                // slow work and return, since Lingara expects a 2xx within 10 s.
                if (routed.PlayerRef is { } player && routed.Event.Type == "practice.completed")
                {
                    await SaveProgressAsync(db, player, routed.Event.Id, routed.Event.Data.GetRawText(), token);
                }
            }, ct);
            return Results.StatusCode(status); // 204, 400, 401 or 500, always with an empty body
        });
    }
}

/// <summary>
/// Your player directory over your own database (PostgreSQL here):
/// <c>CREATE TABLE lingara_players (player_ref text NOT NULL, subject text PRIMARY KEY)</c>
/// with <c>CREATE INDEX ON lingara_players (player_ref)</c>. Unique on
/// <c>subject</c> only: a player who links an account owns two subjects.
/// </summary>
public sealed class SqlPlayerDirectory(DbDataSource db) : IPlayerDirectory
{
    public async ValueTask RememberAsync(string playerRef, string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1", playerRef, subject);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async ValueTask<string?> PlayerRefForAsync(string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "SELECT player_ref FROM lingara_players WHERE subject = $1", subject);
        return await cmd.ExecuteScalarAsync(ct) as string;
    }

    public async ValueTask ForgetAsync(string playerRef, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "DELETE FROM lingara_players WHERE player_ref = $1", playerRef);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    internal static DbCommand Command(DbDataSource db, string sql, params string[] values)
    {
        var cmd = db.CreateCommand(sql);
        foreach (var value in values)
        {
            var p = cmd.CreateParameter();
            p.Value = value;
            cmd.Parameters.Add(p);
        }

        return cmd;
    }
}

함께 보기