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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,39 @@ 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)

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 |

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.

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.

### Outbound queue (SendQ) policy

Each connection has a bounded outbound queue, measured in bytes of queued wire data (escaped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Set;
import net.jircd.core.extension.AbstractCapabilityExtension;
import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.session.OutboundMessage;
import net.jircd.protocol.CapabilityName;

Expand All @@ -31,6 +33,8 @@ public final class ServerTimeExtension extends AbstractCapabilityExtension {

public static final String ID = CapabilityName.SERVER_TIME;

private static final String TIME_TAG = "time";

/** IRCv3 server-time's required format: millisecond-precision, UTC, {@code Z}-suffixed. */
private static final DateTimeFormatter TIME_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC);
Expand All @@ -39,8 +43,16 @@ public ServerTimeExtension() {
super(ID);
}

/** {@code time=} plus the 24-byte timestamp; no character in it needs tag escaping. */
static final long MAX_TAG_BYTES = 29;

@Override
public Map<String, String> contributeTags(OutboundMessage message) {
return Map.of("time", TIME_FORMAT.format(message.sentAt()));
public void register(CapabilityRegistrar registrar) {
registrar.onOutboundTags(Set.of(TIME_TAG), MAX_TAG_BYTES, ServerTimeExtension::timeTag);
}

/** Pure in the message's send time; shared by every recipient of the envelope. */
static Map<String, String> timeTag(OutboundMessage message) {
return Map.of(TIME_TAG, TIME_FORMAT.format(message.sentAt()));
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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.servertime;

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

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.jircd.core.extension.ExtensionRegistry;
import net.jircd.core.extension.capability.CapabilityDescriptor;
import net.jircd.core.extension.capability.CapabilityRegistrar;
import net.jircd.core.extension.capability.FaultCategory;
import net.jircd.core.extension.capability.HookDispatcher;
import net.jircd.core.extension.capability.HookKind;
import net.jircd.core.extension.capability.HookRegistration;
import net.jircd.core.session.CapabilityTagRenderer;
import net.jircd.core.session.ClientSession;
import net.jircd.core.session.OutboundMessage;
import net.jircd.core.session.RateLimitBucket;
import net.jircd.protocol.CapabilityName;
import net.jircd.protocol.MessageSerializer;
import org.junit.jupiter.api.Test;

class ServerTimeExtensionTest {
private final List<String> faults = new ArrayList<>();
private final HookDispatcher dispatcher =
new HookDispatcher(
(provider, kind, category) -> faults.add(provider + "/" + kind + "/" + category));

private static OutboundMessage message(Instant sentAt) {
return new OutboundMessage(
"n!u@h",
"PRIVMSG",
"#c",
"hello",
Map.of("+client", "kept"),
sentAt,
UUID.fromString("00000000-0000-0000-0000-000000000123"));
}

private static HookRegistration hook() {
CapabilityRegistrar registrar = new CapabilityRegistrar("server-time", "server-time");
new ServerTimeExtension().register(registrar);
CapabilityDescriptor descriptor = registrar.build();
assertThat(descriptor.registrations()).hasSize(1);
return descriptor.registrations().getFirst();
}

@Test
void registersOneOwnedTimeTagWithADeclaredBound() {
HookRegistration hook = hook();
assertThat(hook.kind()).isEqualTo(HookKind.OUTBOUND_TAGS);
assertThat(hook.ownedKeys()).containsExactly("time");
assertThat(hook.requiresNegotiated()).isEmpty();
}

@Test
void contributesTheEnvelopeSendTimeAsAnExactTimestampTag() {
Map<String, String> tags =
dispatcher.outboundTags(hook(), message(Instant.parse("2020-01-02T03:04:05.006Z")));
assertThat(tags).containsExactly(Map.entry("time", "2020-01-02T03:04:05.006Z"));
assertThat(faults).isEmpty();
}

@Test
void contributionIsPureAndIndependentOfRecipientAndWallClock() {
OutboundMessage first = message(Instant.parse("2020-01-02T03:04:05.006Z"));
OutboundMessage equal = message(Instant.parse("2020-01-02T03:04:05.006Z"));
HookRegistration hook = hook();
for (int i = 0; i < 10; i++) {
assertThat(dispatcher.outboundTags(hook, first))
.isEqualTo(dispatcher.outboundTags(hook(), equal))
.containsEntry("time", "2020-01-02T03:04:05.006Z");
}
}

@Test
void contributionIsImmutableAndWithinItsDeclaredBoundAtTheExtremes() {
HookRegistration hook = hook();
for (Instant extreme :
List.of(
Instant.EPOCH,
Instant.parse("2000-01-01T00:00:00.000Z"),
Instant.parse("9999-12-31T23:59:59.999Z"))) {
Map<String, String> tags = dispatcher.outboundTags(hook, message(extreme));
assertThat(MessageSerializer.tagDataBytes(tags)).isLessThanOrEqualTo((int) hook.maxBytes());
assertThat(tags).hasSize(1);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> tags.put("x", "y"))
.isInstanceOf(UnsupportedOperationException.class);
}
assertThat(faults).isEmpty();
}

@Test
void aFailureIsContainedAndYieldsNoTagInsteadOfPropagating() {
assertThat(dispatcher.outboundTags(hook(), message(null))).isEmpty();
assertThat(faults).containsExactly("server-time/OUTBOUND_TAGS/" + FaultCategory.EXCEPTION);
}

@Test
void legacyHookIsNoLongerUsedByTheProvider() {
assertThat(new ServerTimeExtension().contributeTags(message(Instant.EPOCH))).isEmpty();
}

@Test
void decoratesWithoutMessageTagsNegotiatedOrEnabled() {
ExtensionRegistry registry = new ExtensionRegistry();
registry.register(new ServerTimeExtension());
registry.enable(ServerTimeExtension.ID);
ClientSession recipient = new ClientSession("r", "host", RateLimitBucket.withDefaults());
recipient.replaceNegotiatedCapabilities(List.of(CapabilityName.SERVER_TIME));
Map<String, String> tags =
new CapabilityTagRenderer(registry)
.render(recipient, message(Instant.parse("2021-05-06T07:08:09.010Z")));
assertThat(tags).containsExactly(Map.entry("time", "2021-05-06T07:08:09.010Z"));
ClientSession without = new ClientSession("w", "host", RateLimitBucket.withDefaults());
assertThat(new CapabilityTagRenderer(registry).render(without, message(Instant.EPOCH)))
.isEmpty();
}
}
Loading
Loading