7. Portal sign-in and probes

Implement PortalLoginMethod and RecoverySupport for Agora — the sign-in button, the handshake a member completes by private message, and the three probes behind the health badges.

A member who bought on the portal with their forum account has no password. PortalLoginMethod gives the portal a "Continue with your forum account" button; the member proves who they are by sending our bot a code in a private message, and our webhook handler completes the core's handshake.

The sign-in method

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Subscriby\Connector\Contracts\Ports\PortalLoginMethod;
use Subscriby\Connector\Data\HandshakeRef;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\PortalLoginButton;
use Subscriby\Connector\Data\PortalLoginStart;
use Subscriby\Connector\Data\ProjectRef;

final class AgoraPortalLoginMethod implements PortalLoginMethod
{
    public function __construct(private readonly AgoraInstallationLifecycle $lifecycle) {}

    public function button(): PortalLoginButton
    {
        return new PortalLoginButton(__('Continue with your forum account'), 'agora');
    }

    public function begin(InstallationRef $installation, ProjectRef $project, HandshakeRef $handshake): PortalLoginStart
    {
        return new PortalLoginStart(
            token: $handshake->token,
            url: $this->lifecycle->startLink($installation, 'login '.$handshake->token),
            instructions: __('Send the message "login :token" to our bot on the forum, then come back here.', ['token' => $handshake->token]),
        );
    }
}

begin() reuses startLink(): the member lands on the forum's "new message to the bot" page with login <token> pre-filled, sends it, and the webhook arrives. The instructions cover a member who opens the portal on a device where the forum is not signed in.

Completing the handshake

In chapter 4's dispatcher, message.created from a member is checked for a login code before anything else:

private function onMessage(InboundEnvelope $envelope): void
{
    $text = trim((string) ($envelope->payload['data']['body'] ?? ''));
    $actor = $this->identities->resolveInbound($envelope);

    if ($actor === null) {
        return;
    }

    if (preg_match('/^login\s+(\S+)$/i', $text, $match) === 1 && $this->core->isHandshakeToken($match[1])) {
        try {
            $completion = $this->core->completeHandshake($match[1], $this->identities->adopt($envelope->installation, $actor), $envelope->installation);
            $this->reply($envelope, __('You are signed in as :name. Head back to the portal.', ['name' => $completion->subjectName]));
        } catch (HandshakeRefused $refused) {
            $this->reply($envelope, __('That code has expired or was already used. Start again from the portal.'));
        }

        return;
    }

    $this->keywords->handle($envelope, $actor, $text);   // chapter 5's reply keywords
}

isHandshakeToken() first, so a member who happens to type "login something" in another context is not refused with a handshake error. completeHandshake() receives the account as adopt() describes it and the installation that heard it, so a handshake opened for project A cannot be completed through project B's forum. The core writes the member link and answers the name to greet them with; the portal, which has been polling, signs them in.

The probes

Agora lets us ask about the forum, a board and an account, so the manifest declares recovery_probes and nothing else in recovery:

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Acme\Connectors\Agora\Agora;
use Subscriby\Connector\Contracts\Ports\RecoverySupport;
use Subscriby\Connector\Core\Recovery;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\CreatorRef;
use Subscriby\Connector\Data\DeliveryFailure;
use Subscriby\Connector\Data\FailOverReport;
use Subscriby\Connector\Data\HandshakeRef;
use Subscriby\Connector\Data\HealthReasonText;
use Subscriby\Connector\Data\IdentityRef;
use Subscriby\Connector\Data\InstallationHealth;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\InstallationSummary;
use Subscriby\Connector\Data\PortalLoginStart;
use Subscriby\Connector\Data\ProjectRef;
use Subscriby\Connector\Data\ReadinessItem;
use Subscriby\Connector\Data\RecoveryVocabulary;
use Subscriby\Connector\Data\SpaceAccess;
use Subscriby\Connector\Data\SpaceRef;
use Subscriby\Connector\Enums\DeliveryFailureKind;
use Subscriby\Connector\Exceptions\UnsupportedByConnector;

final class AgoraRecoverySupport implements RecoverySupport
{
    public function __construct(
        private readonly Agora $agora,
        private readonly AgoraInstallationLifecycle $lifecycle,
        private readonly AgoraSpaceCatalog $catalog,
        private readonly AgoraFailureClassifier $failures,
        private readonly Recovery $recovery,
    ) {}

    public function vocabulary(): RecoveryVocabulary
    {
        return new RecoveryVocabulary(
            installationNoun: __('forum bot'),
            spaceNoun: __('board'),
            identityNoun: __('forum account'),
            grantNoun: __('board membership'),
            installationsNoun: __('forum bots'),
            spacesNoun: __('boards'),
        );
    }

    public function healthReasonText(string $code): ?HealthReasonText
    {
        return match ($code) {
            'key_revoked' => new HealthReasonText(__('API key revoked'), __('The forum no longer accepts the API key. Create a new one under Settings › API and reconnect.')),
            default => null,
        };
    }

    public function readinessChecks(InstallationRef $installation, ProjectRef $project): array
    {
        $coverage = $this->recovery->coverage($project, 'agora');

        return [new ReadinessItem(
            key: 'boards-moderated',
            label: __('The bot moderates every board it gates'),
            description: __('A board the bot cannot moderate cannot admit or remove members.'),
            icon: 'shield-check',
            satisfied: $coverage->spaces === [] || array_all($coverage->spaces, fn ($space): bool => $space->standbyHealthy || true),
        )];
    }

    public function probeInstallation(InstallationRef $installation, CredentialBag $credentials): InstallationHealth
    {
        return $this->lifecycle->verify($installation, $credentials);
    }

    public function probeSpace(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceAccess
    {
        return $this->catalog->diagnose($installation, $credentials, $space);
    }

    public function probeIdentity(InstallationRef $installation, CredentialBag $credentials, IdentityRef $identity): ?DeliveryFailure
    {
        $response = $this->agora->for($installation, $credentials)->get('/users/'.$identity->externalId);

        if ($response->status() === 404) {
            return new DeliveryFailure(DeliveryFailureKind::TargetMissing, __('The forum account no longer exists.'), null, 'user_not_found');
        }

        if ($this->agora->refused($response)) {
            return $this->failures->classify($response);
        }

        return null;
    }

    public function registerStandbyInstallation(ProjectRef $project, CredentialBag $credentials): InstallationSummary
    {
        throw UnsupportedByConnector::facet('agora', 'standby installations');
    }

    public function removeStandbyInstallation(InstallationRef $standby, CredentialBag $credentials): void
    {
        throw UnsupportedByConnector::facet('agora', 'standby installations');
    }

    public function failOver(InstallationRef $installation, CredentialBag $credentials, SpaceRef $from, SpaceRef $to, iterable $holders): FailOverReport
    {
        throw UnsupportedByConnector::facet('agora', 'failing over a board');
    }

    public function mirror(InstallationRef $installation, CredentialBag $credentials, SpaceRef $from, SpaceRef $to, string $externalPostId): \Subscriby\Connector\Data\DeliveryResult
    {
        throw UnsupportedByConnector::facet('agora', 'mirroring a board');
    }

    public function beginIdentityHandshake(InstallationRef $platform, CreatorRef $creator, HandshakeRef $handshake): PortalLoginStart
    {
        throw UnsupportedByConnector::facet('agora', 'relinking a creator account');
    }
}

The probes reuse the calls we already have: verify() for the forum, diagnose() for a board, so a health badge and a Verify button can never disagree. probeIdentity() gives the three answers the core acts on: null while the account answers, TargetMissing when it is gone (the incident), any other kind when the forum could not be asked. Every denied facet throws UnsupportedByConnector, and the core never calls one the manifest's recovery block denies.

The readiness item is deliberately modest: Agora has no standby to prepare, so the connector's one line of the checklist is about moderation rights, and the real signal comes from probeSpace()'s INSUFFICIENT_ROLE. A connector with standby facets would read coverage()'s hasStandby and standbyHealthy per space, as the Recovery page shows.

Bind PortalLoginMethod::class and RecoverySupport::class; the manifest's portal_login and recovery_probes demand them.

Same call, same verdict

Whenever a probe and a user-facing action ask the platform the same question, route them through one method. A creator who sees "healthy" on the Verify button and "degraded" on the health badge stops trusting both.

How is this guide?

On this page

Subscriby is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request