Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 43 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ reload.

Startup and reload reject unsafe combinations of the resolved server-name byte
length `S`, nickname limit `N`, and channel byte limit `Cmax`: both
`S + 2N + Cmax + 15 <= 512` (353) and `S + N + Cmax + 29 <= 512` (366)
must hold. Validation names the keys, computed reply sizes, and available channel
`S + 2N + Cmax + 15 <= 512` (353, with the two-symbol `multi-prefix` status
installed) and `S + N + Cmax + 29 <= 512` (366) must hold. Validation names the keys, computed reply sizes, and available channel
byte budget. Reduce the configured limits or use a shorter server name to migrate
an incompatible configuration; a failed reload keeps the previous settings.
Replies that carry a nick!user@host are budgeted too, with a username of up to 27
Expand Down Expand Up @@ -321,38 +321,61 @@ release), use `distZip` or `distTar`, which land in
archives — pushing a `vX.Y.Z` tag builds and publishes these automatically
(see [`.github/workflows/release.yml`](.github/workflows/release.yml)).

### Writing a capability provider (SPI, in transition)
### Writing a capability provider (SPI)

A capability module (`jircd-capabilities/*`) is a `CapabilityExtension` that owns its behaviour
through typed registrations instead of core branching on its name. Override
`CapabilityExtension.register(CapabilityRegistrar)` to stage hooks; nothing is visible until the
whole descriptor validates and is published, and a failed enable leaves nothing published.

| Registration | Purpose |
| --- | --- |
| `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound |
| `onEvent`, `onInboundTags`, `onRecipients` | Committed-event delivery, client-tag selection, sender-copy roles |
| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots |
| `onOutputGroup` | Framing of a finite group, falling back to plain members |
| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn |
whole descriptor validates and is published, and a failed enable leaves nothing published. Core
keeps CAP negotiation and the generic mechanisms (authorized delivery, committed events, reply
layouts and budgets, whole-frame queue admission); all six bundled capabilities now own their
behaviour, so removing a module from the classpath removes its offer and its behaviour together.

| Registration | Purpose | Used by |
| --- | --- | --- |
| `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound | `server-time` (`time`), `message-tags` (`msgid`) |
| `onInboundTags` | Selects the client-only (`+`) tags to forward and provider-scoped request context; a recipient that negotiated the owner sees the forwarded tags | `message-tags` |
| `onRecipients` | Asks for a `SENDER_COPY` role for the sender of a permitted `PRIVMSG`/`NOTICE`/`TAGMSG`; core merges roles so a recipient gets the message once | `echo-message` |
| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `KICKED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` |
| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots (the status of a NAMES token or channel WHO entry, the NAMES identity) | `multi-prefix` (status) |
| `onOutputGroup(prerequisites, hook)` | Framing of a finite group; the group is validated and queued whole, or sent plain when the owner or a prerequisite was not negotiated | `batch` |
| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | `message-tags` (`TAGMSG`) |

Hook results are untrusted. An exception, a `null` or an out-of-bounds result is dropped whole,
reported by provider id, hook and category only, and replaced by the documented default; committed
core state and other providers are unaffected. Hooks must be pure, nonblocking and thread-safe. Core
applies the owner's enabled state and the declared negotiation prerequisites, so a hook never reads
a session's capability set.

**Status.** Only `server-time` is migrated. The other five providers still use the temporary
`contributeTags` / `includeSenderInFanOut` bridge, which is removed once the last one migrates;
core routes a provider's tags through exactly one path. Runtime FAILED withdrawal of a faulty
provider, event publication and the remaining migrations are later changes in the same feature
(`specs/014-capability-plugin-spi/`). `0.1.0-SNAPSHOT` is unreleased, so the SPI changes carry no
version bump until the first release.
Core publishes committed events (`CommittedEvents`) after the state change and with no registry or
channel monitor held; a kind nobody listens for costs nothing, and a faulty listener never undoes
the transition. Client messages go through `MessageDelivery`, which owns target resolution, the
send gates, the envelope (one id and send time for every recipient) and role selection.

**Status.** Runtime FAILED withdrawal of a faulty provider, atomic runtime enable/disable with
`CAP NEW`/`DEL`, and the dedicated load measurements are later changes in the same feature
(`specs/014-capability-plugin-spi/`); this change does not provide them.

**Migrating code that used the earlier API.** `0.1.0-SNAPSHOT` is unreleased, so these changes
carry no version bump until the first release.

| Removed | Use instead |
| --- | --- |
| `CapabilityExtension.contributeTags` | `CapabilityRegistrar.onOutboundTags` |
| `CapabilityExtension.includeSenderInFanOut` | `CapabilityRegistrar.onRecipients` returning `DeliveryRole.SENDER_COPY` |
| `Channel.memberListing(boolean)`, `Channel.statusPrefixes(member, boolean)` | `Channel.memberFacts()` / `heldStatuses(member)` for unformatted facts; `MemberReplies` renders them for a requester. `memberListing()` is the highest-status form |
| `JoinResult.memberListing()` | `JoinResult.members()` (unformatted `MemberFacts`) |
| `JoinCommandHandler.sendNamesReply(requester, channel, server)` and `sendNamesLine(requester, channel, server)` | The overloads that take the `ExtensionRegistry` first. `sendNamesReply(requester, channel, server, List<String> tokens)` still sends tokens you rendered |
| `SessionWriter.enqueueBatch` | `SessionWriter.enqueueGroup`; framing comes from the owning provider |
| `MessageCommandHandler.resolveRecipients`, `deliveryTargets`, `includeSenderInFanOut` | `MessageDelivery.authorize` and `deliver` |
| `TagmsgCommandHandler` in core | Owned by the `message-tags` module |
| `ReplyBudgetCatalog.legacyCompatible()` | `ReplyBudgetCatalog.empty()` plus the providers' own declared bounds |

Reply decorations are budgeted by generic layouts (`ReplyLayout`) rather than a per-capability
width: startup, `REHASH` and runtime enable reserve every installed provider's declared slot bytes
with the same arithmetic. Until `multi-prefix` declares its own bound the server keeps reserving the
two-symbol status width, so accepted configurations stay accepted.
with the same arithmetic. `multi-prefix` declares the two operator and voice symbols of
`PREFIX=(ov)@+`, so with the module installed (as in every shipped build) the reserved envelope is
unchanged; a server without it reserves only core's one-byte highest status.

### Outbound queue (SendQ) policy

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,25 @@
*/
package net.jircd.capabilities.awaynotify;

import java.util.List;
import java.util.Map;
import net.jircd.core.extension.AbstractCapabilityExtension;
import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.extension.capability.CommittedEvent;
import net.jircd.core.extension.capability.EventIntent;
import net.jircd.core.extension.capability.EventKind;
import net.jircd.core.session.ClientSession;
import net.jircd.protocol.CapabilityName;
import net.jircd.protocol.Command;
import net.jircd.protocol.Message;

/**
* The {@code away-notify} capability (011-away-notify-batch FR-001 through FR-006): unlike {@code
* message-tags}/{@code server-time}/{@code echo-message}, this doesn't decorate an existing fan-out
* — it gates a wholly new one. {@link net.jircd.core.session.command.AwayCommandHandler} checks
* this capability's id directly against each channel-mate's negotiated set when broadcasting an
* away-status change, via the shared {@link CapabilityName#AWAY_NOTIFY} constant rather than
* importing this class (jircd-core can't depend on jircd-capabilities).
* — it gates a wholly new one. It listens for the committed {@code AWAY_CHANGED} event and answers
* with one {@code AWAY} notice for the peers who share a channel with the user, deduplicated and
* without the user itself by the event's audience. Core narrows the notice to peers that negotiated
* this capability; it keeps the away state and the confirmations to the user.
*/
public final class AwayNotifyExtension extends AbstractCapabilityExtension {

Expand All @@ -33,4 +42,20 @@ public final class AwayNotifyExtension extends AbstractCapabilityExtension {
public AwayNotifyExtension() {
super(ID);
}

@Override
public void register(CapabilityRegistrar registrar) {
registrar.onEvent(EventKind.AWAY_CHANGED, AwayNotifyExtension::awayNotice);
}

/** {@code :<presented identity> AWAY [:<reason>]}; no parameter means the user is back. */
static List<EventIntent> awayNotice(CommittedEvent event) {
List<ClientSession> peers = event.audience().peers();
if (peers.isEmpty()) {
return List.of();
}
List<String> params = event.after().isEmpty() ? List.of() : List.of(event.after());
Message notice = new Message(Map.of(), event.subject(), Command.AWAY, "AWAY", params);
return List.of(new EventIntent(notice, peers));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
* Copyright 2026 Guillermo Castro
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package net.jircd.capabilities.awaynotify;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.jircd.core.extension.ExtensionRegistry;
import net.jircd.core.extension.HostnameDisplayExtension;
import net.jircd.core.extension.ServerContext;
import net.jircd.core.extension.ServerExtension;
import net.jircd.core.extension.capability.Audience;
import net.jircd.core.extension.capability.CapabilityDescriptor;
import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.extension.capability.CommittedEvent;
import net.jircd.core.extension.capability.EventIntent;
import net.jircd.core.extension.capability.EventKind;
import net.jircd.core.extension.capability.HookKind;
import net.jircd.core.session.ChannelRegistry;
import net.jircd.core.session.ClientSession;
import net.jircd.core.session.NicknameRegistry;
import net.jircd.core.session.RateLimitBucket;
import net.jircd.core.session.SessionWriter;
import net.jircd.core.session.TagRenderer;
import net.jircd.core.session.command.AwayCommandHandler;
import net.jircd.protocol.MessageParser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

class AwayNotifyExtensionTest {
private final NicknameRegistry nicknames = new NicknameRegistry();
private final ChannelRegistry channels = new ChannelRegistry();
private final ExtensionRegistry extensions = new ExtensionRegistry();
private final Map<ClientSession, ByteArrayOutputStream> outputs = new HashMap<>();
private final List<SessionWriter> writers = new ArrayList<>();
private final AwayCommandHandler away = new AwayCommandHandler(() -> "server", extensions);

@AfterEach
void closeWriters() {
writers.forEach(SessionWriter::close);
}

private void enable() {
extensions.register(new AwayNotifyExtension());
extensions.enable(AwayNotifyExtension.ID);
}

private ClientSession user(String nick, boolean awayNotify) {
ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults());
session.setNickname(nick);
session.setIdent("ident");
if (awayNotify) session.replaceNegotiatedCapabilities(List.of(AwayNotifyExtension.ID));
nicknames.claim(nick, session);
ByteArrayOutputStream out = new ByteArrayOutputStream();
outputs.put(session, out);
@SuppressWarnings("PMD.CloseResource") // closed by closeWriters()
SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE);
writers.add(writer);
session.attachWriter(writer);
return session;
}

private String received(ClientSession session) {
writers.forEach(SessionWriter::close);
return outputs.get(session).toString(StandardCharsets.UTF_8);
}

@Test
void registersOnlyAnAwayChangedEventHook() {
CapabilityRegistrar registrar = new CapabilityRegistrar("away-notify", "away-notify");
new AwayNotifyExtension().register(registrar);
CapabilityDescriptor descriptor = registrar.build();
assertThat(descriptor.registrations()).hasSize(1);
assertThat(descriptor.registrations().getFirst().kind()).isEqualTo(HookKind.EVENT);
assertThat(descriptor.registrations().getFirst().eventKind()).isEqualTo(EventKind.AWAY_CHANGED);
}

@Test
void theNoticeCarriesTheSubjectAndReasonAndAClearHasNoParameter() {
ClientSession peer = user("peer", true);
Audience audience = new Audience(() -> List.of(peer, peer));

List<EventIntent> set =
AwayNotifyExtension.awayNotice(
new CommittedEvent(EventKind.AWAY_CHANGED, 1, "a!i@h", "", "lunch", audience));
List<EventIntent> cleared =
AwayNotifyExtension.awayNotice(
new CommittedEvent(EventKind.AWAY_CHANGED, 2, "a!i@h", "lunch", "", audience));

assertThat(set).hasSize(1);
assertThat(set.getFirst().recipients()).containsExactly(peer);
assertThat(set.getFirst().message().prefix()).isEqualTo("a!i@h");
assertThat(set.getFirst().message().params()).containsExactly("lunch");
assertThat(cleared.getFirst().message().params()).isEmpty();
}

@Test
void noPeersMeansNoIntent() {
assertThat(
AwayNotifyExtension.awayNotice(
new CommittedEvent(
EventKind.AWAY_CHANGED, 1, "a!i@h", "", "x", new Audience(List::of))))
.isEmpty();
}

@Test
void onlyPeersThatNegotiatedItSeeTheNoticeOnceAndNeverTheUser() throws Exception {
enable();
ClientSession alice = user("alice", true);
ClientSession bob = user("bob", true);
ClientSession carol = user("carol", false);
for (String channel : List.of("#a", "#b")) {
for (ClientSession member : List.of(alice, bob, carol)) {
channels.join(channel, member, ch -> true);
}
}

away.handle(alice, MessageParser.parse("AWAY :at lunch"));

assertThat(received(bob)).isEqualTo(":alice!ident@host.example AWAY :at lunch\r\n");
assertThat(received(carol)).isEmpty();
assertThat(received(alice)).contains(" 306 ").doesNotContain(" AWAY ");
}

@Test
void aClearIsSentWithoutParametersAndARepeatedReasonIsSentAgain() throws Exception {
enable();
ClientSession alice = user("alice", false);
ClientSession bob = user("bob", true);
channels.join("#c", alice, ch -> true);
channels.join("#c", bob, ch -> true);

away.handle(alice, MessageParser.parse("AWAY :x"));
away.handle(alice, MessageParser.parse("AWAY :x"));
away.handle(alice, MessageParser.parse("AWAY"));

assertThat(received(bob))
.isEqualTo(
String.join(
"\r\n",
":alice!ident@host.example AWAY x",
":alice!ident@host.example AWAY x",
":alice!ident@host.example AWAY",
""));
}

@Test
void theNoticeUsesThePresentedIdentity() throws Exception {
extensions.register(new Cloak());
extensions.enable("test-cloak");
enable();
ClientSession alice = user("alice", false);
ClientSession bob = user("bob", true);
channels.join("#c", alice, ch -> true);
channels.join("#c", bob, ch -> true);

away.handle(alice, MessageParser.parse("AWAY :x"));

assertThat(received(bob)).isEqualTo(":alice!ident@cloak.example AWAY x\r\n");
}

@Test
void withoutTheProviderAwayStillWorksButNobodyIsNotified() throws Exception {
ClientSession alice = user("alice", false);
ClientSession bob = user("bob", true);
channels.join("#c", alice, ch -> true);
channels.join("#c", bob, ch -> true);

away.handle(alice, MessageParser.parse("AWAY :x"));

assertThat(alice.awayReason()).isEqualTo("x");
assertThat(received(alice)).contains(" 306 ");
assertThat(received(bob)).isEmpty();
}

private static final class Cloak implements ServerExtension, HostnameDisplayExtension {
@Override
public String id() {
return "test-cloak";
}

@Override
public String extensionPoint() {
return "hostname-display";
}

@Override
public String display(String hostname) {
return "cloak.example";
}

@Override
public void start(ServerContext context) {
// Stateless test cloak: no resources to initialize.
}

@Override
public void stop() {
// Stateless test cloak: no resources to release.
}
}
}
Loading
Loading