6. Boards and access

Implement SpaceCatalog and AccessController for Agora — let a creator pick a board, record it as a space, add and remove members as grants, announce access, reconcile, and mark the one step the SDK cannot finish yet.

A board is the place we sell access to. Two ports: SpaceCatalog for how a creator picks a board and whether our bot can act in it, AccessController for adding and removing members.

Picking a board

Agora lets the API list a forum's boards, so we do not need a chat picker like Telegram's: requestLink() sends the creator a private message with a link to a small page of ours where they pick from the boards the bot user moderates, and the page posts our own board.picked event back through the webhook route so it goes through the core's gate like everything else.

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Acme\Connectors\Agora\Agora;
use Acme\Connectors\Agora\Models\LinkRequest as ParkedRequest;
use Subscriby\Connector\Contracts\Ports\SpaceCatalog;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\IdentityRef;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\LinkRequest;
use Subscriby\Connector\Data\Message;
use Subscriby\Connector\Data\MessageAction;
use Subscriby\Connector\Data\Recipient;
use Subscriby\Connector\Data\SpaceAccess;
use Subscriby\Connector\Data\SpaceRef;
use Subscriby\Connector\Data\SpaceSummary;
use Subscriby\Connector\Enums\LinkPurpose;
use Subscriby\Connector\Exceptions\UnsupportedByConnector;

final class AgoraSpaceCatalog implements SpaceCatalog
{
    public function __construct(
        private readonly Agora $agora,
        private readonly AgoraMessenger $messenger,
        private readonly AgoraFailureClassifier $failures,
    ) {}

    public function requestLink(InstallationRef $installation, CredentialBag $credentials, IdentityRef $creator, LinkRequest $request): void
    {
        if ($request->purpose !== LinkPurpose::Resource) {
            throw UnsupportedByConnector::facet('agora', 'linking a place for '.$request->purpose->value);
        }

        $parked = ParkedRequest::query()->updateOrCreate(
            ['installation_id' => $installation->id, 'creator_external_id' => $creator->externalId, 'purpose' => $request->purpose->value],
            ['kind' => $request->kind, 'subject_id' => $request->subjectId, 'subject_title' => $request->subjectTitle, 'token' => bin2hex(random_bytes(16))],
        );

        $this->messenger->send($installation, $credentials, new Recipient($creator), new Message(
            __('Pick the board to sell access to for <b>:project</b>.', ['project' => e((string) $request->subjectTitle)]),
            [MessageAction::url(__('Choose a board'), route('connector-agora.pick', ['token' => $parked->token]))],
        ));
    }

    public function withdrawLinkRequest(InstallationRef $installation, CredentialBag $credentials, IdentityRef $creator, LinkPurpose $purpose): void
    {
        ParkedRequest::query()->where('installation_id', $installation->id)->where('creator_external_id', $creator->externalId)->where('purpose', $purpose->value)->delete();
    }

    public function pendingLinkRequest(InstallationRef $installation, CredentialBag $credentials, IdentityRef $creator, LinkPurpose $purpose): ?string
    {
        return ParkedRequest::query()->where('installation_id', $installation->id)->where('creator_external_id', $creator->externalId)->where('purpose', $purpose->value)->value('subject_id');
    }

    public function linkInstructions(LinkPurpose $purpose): string
    {
        return __('Our bot sent you a private message on your forum with a link. Open it and pick the board.');
    }

    public function describe(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceSummary
    {
        $board = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId)->json('data', []);

        return new SpaceSummary($space->externalId, 'board', (string) ($board['title'] ?? 'Board '.$space->externalId));
    }

    public function diagnose(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceAccess
    {
        $response = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId.'/members/'.$installation->externalId);

        if ($response->status() === 404) {
            $board = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId);

            return $board->status() === 404
                ? new SpaceAccess(false, SpaceAccess::GONE, __('The board no longer exists on the forum.'), creatorActionable: true)
                : new SpaceAccess(false, SpaceAccess::NOT_MEMBER, __('The bot user is not a member of this board.'), creatorActionable: true);
        }

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

        return $response->json('data.role') === 'moderator'
            ? SpaceAccess::ready()
            : new SpaceAccess(false, SpaceAccess::INSUFFICIENT_ROLE, __('Make the bot user a moderator of this board so it can add and remove members.'), creatorActionable: true);
    }
}

diagnose() tells the four things apart the core acts on differently: a board that is gone, a board the bot is not in, a board where the bot is a plain member, and a board where it is a moderator. Each carries the sentence the creator will read and whether it is theirs to fix.

Only LinkPurpose::Resource is supported: Agora has no standby or replacement facets (no recovery_resource_standby), and no support relay. Throwing UnsupportedByConnector for the others is the contract.

The picker page

routes/web.php gets one route behind the application's web middleware, so the creator's browser session is what authorises it:

Route::get('connectors/agora/pick/{token}', [BoardPickerController::class, 'show'])->name('connector-agora.pick');
Route::post('connectors/agora/pick/{token}', [BoardPickerController::class, 'store']);

show() lists the boards the bot user moderates (GET /boards?moderated=1) for the parked request's forum; store() records the choice and sells it. The controller takes Core\Resources beside Spaces and Installations:

public function store(Request $request, string $token): RedirectResponse
{
    $parked = ParkedRequest::query()->where('token', $token)->firstOrFail();
    $installation = $this->installations->find($parked->installation_id);
    $board = $this->agora->for($installation, $this->credentialsFor($installation))->get('/boards/'.$request->string('board'))->json('data');

    $space = $this->spaces->record(new SpaceRecord(
        connector: 'agora',
        installationId: $installation->id,
        externalId: (string) $board['id'],
        kind: 'board',
        title: (string) $board['title'],
    ));

    $this->resources->create(
        new ProjectRef($parked->subject_id),
        $space,
        ResourceKind::for('agora', 'board'),
        title: (string) $board['title'],
        description: __('Access to the :board board while your membership is active.', ['board' => $board['title']]),
    );

    $parked->delete();

    return redirect()->to($this->returnUrl($parked))->with('status', __('Board linked. Add it to a plan from the Resources page.'));
}

Two things the core does for us here. The write acts for the creator: the route sits behind the application's web middleware, so the signed-in creator is the actor, and create() is refused for anyone who may not add resources to that project, exactly as the dashboard's own button is. And it is idempotent on the project and the board: a creator who submits the form twice, or a browser that replays it, gets the one resource, not two.

What the core does with the call

Resources::create() writes the resource under the project with connector: agora, kind: agora:board and the space bound in the same step, sets it active, broadcasts it to the creator's open dashboard and emits project.resource.linked with the connector, the kind and the space in the payload. The creator adds the new resource to a plan from the Resources page; a connector never touches plans. Refusals arrive as ResourceRefused with a stable reason (kind_outside_place, place_unknown, project_unknown, not_permitted), so the controller can show a sentence for each.

The access controller

Agora adds a user to a board directly, so a grant is a membership and the reference is our own board:user handle:

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Acme\Connectors\Agora\Agora;
use Subscriby\Connector\Contracts\Ports\AccessController;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\GrantAnnouncement;
use Subscriby\Connector\Data\GrantRef;
use Subscriby\Connector\Data\GrantRequest;
use Subscriby\Connector\Data\GrantResult;
use Subscriby\Connector\Data\GrantSnapshot;
use Subscriby\Connector\Data\IdentityRef;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\Membership;
use Subscriby\Connector\Data\Message;
use Subscriby\Connector\Data\Recipient;
use Subscriby\Connector\Data\ReconcileReport;
use Subscriby\Connector\Data\RevokeResult;
use Subscriby\Connector\Data\SpaceRef;
use Subscriby\Connector\Enums\GrantState;
use Subscriby\Connector\Enums\MembershipStatus;
use Subscriby\Connector\Exceptions\UnsupportedByConnector;

final class AgoraAccessController implements AccessController
{
    public function __construct(
        private readonly Agora $agora,
        private readonly AgoraMessenger $messenger,
        private readonly AgoraFailureClassifier $failures,
    ) {}

    public function grant(InstallationRef $installation, CredentialBag $credentials, GrantRequest $request): GrantResult
    {
        $response = $this->agora->for($installation, $credentials)->post('/boards/'.$request->space->externalId.'/members', ['user_id' => $request->identity->externalId]);

        if ($response->json('error') === 'already_member' || ! $this->agora->refused($response)) {
            return GrantResult::granted($request->mode, 'board:'.$request->space->externalId.':'.$request->identity->externalId);
        }

        return GrantResult::failed($request->mode, $this->failures->classify($response));
    }

    public function revoke(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space, IdentityRef $identity): RevokeResult
    {
        $response = $this->agora->for($installation, $credentials)->delete('/boards/'.$space->externalId.'/members/'.$identity->externalId);

        if ($response->status() === 404 || ! $this->agora->refused($response)) {
            return RevokeResult::revoked();
        }

        return RevokeResult::failed($this->failures->classify($response));
    }

    public function revokeReference(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space): RevokeResult
    {
        return RevokeResult::revoked();
    }

    public function admit(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space, IdentityRef $identity): GrantResult
    {
        throw UnsupportedByConnector::facet('agora', 'early admission');
    }

    public function membership(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space, IdentityRef $identity): Membership
    {
        $response = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId.'/members/'.$identity->externalId);

        if ($response->status() === 404) {
            return new Membership(MembershipStatus::Left);
        }

        if ($this->agora->refused($response)) {
            return new Membership(MembershipStatus::Unknown);
        }

        return new Membership(match ($response->json('data.role')) {
            'owner' => MembershipStatus::Owner,
            'moderator' => MembershipStatus::Administrator,
            'banned' => MembershipStatus::Banned,
            default => MembershipStatus::Member,
        });
    }

    public function announce(InstallationRef $installation, CredentialBag $credentials, IdentityRef $holder, GrantAnnouncement $announcement): void
    {
        $this->messenger->send($installation, $credentials, new Recipient($holder), new Message(
            __('You now have access to <b>:count</b> private board(s). Open the forum and they are waiting for you.', ['count' => count($announcement->grants)]),
        ));
    }

    public function reconcile(InstallationRef $installation, CredentialBag $credentials, iterable $grants): ReconcileReport
    {
        $checked = $reasserted = $revoked = 0;
        $failures = [];

        foreach ($grants as $snapshot) {
            $checked++;
            $inBoard = $this->membership($installation, $credentials, $snapshot->space, $snapshot->identity)->status === MembershipStatus::Member;

            if ($snapshot->state === GrantState::Granted && ! $inBoard) {
                $result = $this->grant($installation, $credentials, new GrantRequest($snapshot->space, $snapshot->identity, $snapshot->grant->mode, $snapshot->grant));
                $result->granted ? $reasserted++ : $failures[] = $result->failure;
            } elseif ($snapshot->state === GrantState::Revoked && $inBoard) {
                $result = $this->revoke($installation, $credentials, $snapshot->grant, $snapshot->space, $snapshot->identity);
                $result->revoked ? $revoked++ : $failures[] = $result->failure;
            }
        }

        return new ReconcileReport($checked, $reasserted, $revoked, $failures);
    }
}

Reading it

  • grant() is idempotent because Agora's already_member answer is treated as success. Granting twice is one membership.
  • revoke() is idempotent because a 404 (already gone) is revoked. Revoking a stranger is not an error.
  • revokeReference() does nothing and reports revoked: a membership carries no reference apart from itself, so when another grant keeps the holder in the board there is nothing to withdraw.
  • admit() throws because the manifest declares no early_admission_hold; the core never calls it.
  • announce() is one private message saying how many boards opened. Agora members see the boards appear in their sidebar, so the message is short.
  • reconcile() asserts a state rather than replaying events: a live grant whose holder is not in the board is re-granted, a revoked grant whose holder is still in the board is revoked, everything else is left alone. The core runs it every fifteen minutes per resource.

When Agora sends board.member_left (a member left a private board on their own), chapter 4's dispatcher can queue a targeted reconcile for that board, so the ledger and the board agree within seconds rather than minutes.

Test it

it('grants a membership and treats already_member as granted', function (): void {
    Http::fake(['forum.example.com/api/boards/12/members' => Http::sequence()
        ->push(['ok' => true])
        ->push(['ok' => false, 'error' => 'already_member'], 409)]);

    $first = $access->grant($installation, $credentials, $request);
    $second = $access->grant($installation, $credentials, $request);

    expect($first->granted)->toBeTrue()->and($first->reference)->toBe('board:12:17')
        ->and($second->granted)->toBeTrue()->and($second->reference)->toBe($first->reference);
});

it('reports a stranger as revoked', function (): void {
    Http::fake(['forum.example.com/api/boards/12/members/17' => Http::response(['ok' => false, 'error' => 'user_not_found'], 404)]);

    expect($access->revoke($installation, $credentials, $grant, $space, $member)->revoked)->toBeTrue();
});

it('diagnoses a board the bot is not a moderator of as creator-actionable', function (): void {
    Http::fake(['forum.example.com/api/boards/12/members/u_bot' => Http::response(['ok' => true, 'data' => ['role' => 'member']])]);

    $access = $catalog->diagnose($installation, $credentials, $space);

    expect($access->ready)->toBeFalse()->and($access->state)->toBe(SpaceAccess::INSUFFICIENT_ROLE)->and($access->creatorActionable)->toBeTrue();
});

Bind AccessController::class and SpaceCatalog::class in AgoraConnector::register(): the first because the manifest declares access_control, the second because the core asks for it only when it is bound.

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