Lingara Lingara Documentação Guias API Bibliotecas Aplicações Criar Incorporar Aplicação web
Idioma: Português

Kits de servidor

Esta página é uma tradução do inglês. Se as duas diferirem, a página em inglês é a correta. Ler a página em inglês

O seu servidor é a metade da integração que guarda os segredos. O kit de servidor envolve a biblioteca do Lingara da sua linguagem, pelo que inicia sessão com o seu cliente OAuth e emite tokens para os seus jogadores, verifica os webhooks que o Lingara lhe envia e elimina jogadores quando estes saem. Os exemplos abaixo seguem os mesmos passos em cada linguagem.

Criar um cliente por utilização

Crie um cliente OAuth na consola e coloque-o no plano por utilização. Atribua-lhe embed:mint, o scope que emite tokens e elimina jogadores. Um cliente em qualquer outro plano é recusado para esse scope. Atribua-lhe também todos os scopes que quer que os tokens dos seus jogadores transportem, porque um token só pode conter scopes que o seu cliente tem. O próprio embed:mint nunca é entregue a um jogador.

Guarde o ID e o segredo do cliente no ambiente do seu servidor, nunca num jogo nem numa página.

Registar as suas origens web

Um widget para sites precisa de um token associado à origem da página. Na consola, indique nas definições do seu cliente cada origem em que o seu widget é executado, como https://www.example.com. Pode indicar no máximo 10. Uma origem é um esquema, um anfitrião e uma porta opcional, sem caminho. Para desenvolvimento, também é permitida uma origem de loopback numa porta à sua escolha, como http://localhost:5173. Um token só é aceite a partir da origem exata para a qual foi emitido, pelo que um token emitido para qualquer outra origem é recusado pela API. Um jogo não precisa de origem.

Emitir um token

Emita por trás do seu próprio início de sessão: a sua rota de tokens verifica primeiro a sua própria sessão e só depois emite para esse jogador. Limite a taxa de pedidos da rota por sessão. Um pedido de emissão aceita três campos:

  • player_ref: a sua própria referência para o jogador, de 1 a 128 bytes de UTF-8. O Lingara guarda apenas um hash dela.
  • scopes: no máximo 5, de entre os que um token de jogador pode conter: events:read, events:write, lesson_plans:read, lesson_plans:write e embed:play. Se o omitir, o token contém todos os que o seu cliente tem.
  • origin: para um site, a origem da página, que tem de ser uma das que registou. Omita-o para um jogo.

A resposta contém token, expires_at, expires_in (a mesma validade em segundos, para um dispositivo em cujo relógio não pode confiar), subject, os scopes do token e account_linked, que indica se o jogador ligou a sua própria conta Lingara. subject identifica o jogador perante o seu cliente, e todos os eventos sobre ele o transportam.

Entregue ao jogador o token e a sua validade exatamente como a função de entrega do kit os escreve, através da sua própria sessão e nunca num URL, e envie a resposta com Cache-Control: no-store. O kit de jogador volta a pedir quando o token se aproxima da sua validade; um token nunca é renovado.

Encaminhar um webhook para o seu jogador

O Lingara assina cada webhook que lhe envia. Entregue ao kit o corpo em bruto do pedido, antes de qualquer análise de JSON, porque a assinatura abrange os seus bytes. O kit verifica a assinatura e procura o jogador que o subject do evento identifica numa tabela sua, junto aos seus jogadores, que associa cada subject ao seu player_ref. Um jogador que liga uma conta tem dois subjects, por isso mantenha a coluna de subject única e deixe que um mesmo player_ref apareça duas vezes.

A entrega é feita pelo menos uma vez: ignore um ID de evento que já tenha tratado. Responda no prazo de 10 segundos e coloque qualquer trabalho lento numa fila sua.

Eliminar um jogador

A partir do seu próprio fluxo de eliminação de contas, elimine o jogador pelo seu player_ref. O Lingara revoga todos os tokens que o jogador tem e remove tudo o que guarda sobre ele. Eliminar um jogador desconhecido também é bem-sucedido, pelo que a chamada pode ser repetida em segurança. Eliminar os seus próprios dados continua a ser tarefa sua.

Um jogador que traz a sua própria conta Lingara

Um jogador com a sua própria conta Lingara pode ligá-la, para que a sua prática e os seus planos fiquem nessa conta. Nenhum kit envolve isto ainda; são duas chamadas à API a partir do seu servidor.

O seu servidor executa o fluxo de código de autorização com o mesmo cliente que emite, usando PKCE: o jogador inicia sessão e dá o seu consentimento no navegador, nunca dentro do seu jogo, e o seu servidor troca o código com o seu segredo. Peça nesse consentimento embed:play e todos os scopes com que emite, e inclua events:read, caso contrário deixa de saber o que quer que seja sobre o jogador depois de ele ligar a conta.

Em seguida, chame linkEmbedPlayer com o token delegado que essa troca devolveu, nunca com o token do próprio cliente. A resposta contém:

  • status: linked quando esta chamada criou a ligação, existing quando ela já estava ativa;
  • subject, o subject da conta, que os eventos do jogador passam a transportar;
  • previous_subject, o subject do convidado, que os eventos anteriores dele mantêm;
  • linked_at, quando a ligação foi criada;
  • scopes, o máximo que um token para este jogador pode agora conter;
  • carried, as linhas que esta chamada moveu para a conta, por tabela;
  • kept, as linhas que deixou com o convidado, como um plano que ainda está a ser escrito.

Uma ligação pode ser repetida em segurança, e uma repetição termina de mover o que kept indica. Encaminhe os eventos que transportem qualquer um dos subjects para o mesmo player_ref. A sua próxima emissão para o jogador devolve account_linked como true, e continua a pagar pelo que o jogador faz.

Para terminar uma ligação, chame unlinkEmbedPlayer com o token do próprio cliente. Nada volta para o convidado. Um aprendente também pode terminar a ligação por si, removendo a sua aplicação em Aplicações ligadas.

Linguagem do exemplo de código

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" />
Linguagem do exemplo de código

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");
Linguagem do exemplo de código

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();
Linguagem do exemplo de código

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;
    }
}

Ver também