diff --git a/CHANGELOG.md b/CHANGELOG.md index 7726f3e..01a5e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ to v0.3.4 are described in their annotated git tag messages. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-10-05 + +### Added + +- UCANs (macula's D7): `NodeKey::ucan($audience, $caps, $expS, nbf:, nnc:, + fct:, prf:)` mints a token for the node that will present it, and + `Ucan::proofId($token)` names a parent in a delegated token's `prf`. + `Pool::call` and `Pool::openStream` take `ucan:` and `proofs:` (the chain's + parents); proofs without a token are refused before anything is sent. +- Gated serving: `Pool::serve` and `Pool::serveStream` take `policy:`, a + `ServePolicy::ucanRequired($issuerNodeId)` or + `ServePolicy::realmMemberRequired($keyId, $can)`. libmacula checks each call + and open before it reaches PHP; a refused call is a `ProviderError` of code + `unauthorized`. +- The seal report: `Pool::callReport(...)` returns a `ReportedCall` (`result` + and `report`), and `Stream::report()` a caller stream's `SealReport` + (`sealed`, `provider`, `sealKeyId`) once it settled. + ## [0.7.0] - 2026-10-05 ### Changed diff --git a/README.md b/README.md index 668831f..d14276b 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ > and ML-KEM hybrid key exchange. Calls and streams by direct dial, serving > (under an org or in a node's own namespace), publish/subscribe, the DHT and > node-served content are tested against in-process stations on every -> `composer test`. UCAN-gated calls are not in the PHP API yet; see [Not yet -> implemented](#not-yet-implemented). Releases before 0.7.0 bind macula-go -> v0.12.0 and cannot call a provider that advertises a KEM key. +> `composer test`, as are UCAN-gated calls and serving and the seal report. +> Releases before 0.7.0 bind macula-go v0.12.0 and cannot call a provider +> that advertises a KEM key. ## What is this? @@ -128,6 +128,8 @@ compatibility layer. | Node keys (`NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only; `sign` and `verify`, pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.7.0 | | Pool of station links (`Pool::connect`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed | | Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Errors arrive as `ProviderError` / `RelayError`; every other failure is a `MaculaException` whose `kind` is the library's error kind | +| UCANs (`NodeKey::ucan`, `Ucan::proofId`, `ucan:`/`proofs:`, `ServePolicy`) | ✅ | ✅ | A token minted for the node that presents it, delegated by `prf`; a procedure served under `ServePolicy::ucanRequired` or `realmMemberRequired` reaches its handler only with a chain the policy accepts, refused otherwise as `ProviderError` `unauthorized` | +| The seal report (`callReport`, `Stream::report`) | ✅ | — | Whether the exchange behind a result was sealed, to which provider and key | | End-to-end sealing (`confidential:`, `kemAdvertise:`) | ✅ | ✅ | A call or stream is sealed whenever the provider's advertisement names a KEM key; `Confidentiality::Required` fails with `ConfidentialityError` rather than go in the clear; a served `Request` says whether it came `sealed` | | A node's own namespace (`ownProcedure`) | ✅ | ✅ | `~/`: served and called with no org and no realm key; the node's signature authorizes it | | Streams (`openStream`, `serveStream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path | @@ -186,10 +188,7 @@ a bounded inbox in the library until a `*_next` function takes it. ## Not yet implemented -- **UCAN-gated calls and serving.** libmacula has them - (`macula_pool_call_opts` with a "ucan", `macula_pool_serve_opts` with a - policy); the PHP API does not expose them yet. -- **The seal report** (`report`, `macula_stream_report`): not in the PHP API. +Nothing that libmacula offers a PHP node is missing today. ## Testing @@ -204,7 +203,9 @@ composer test:live # one live station, see below the same tag, which runs two in-process stations sharing a DHT, with a test realm that admits the test's provider nodes. It exercises keys, calls by direct dial and their errors, providers, server and client streams and a provider that aborts one (and that no stream is left -unreleased), a sealed call and a required call refused in the clear, pubsub, node-served content and the DHT, through the real library. +unreleased), a sealed call and a required call refused in the clear, a gated +procedure refusing a missing, misaddressed or foreign token and serving a +granted and a delegated one, the seal report of a call and a stream, pubsub, node-served content and the DHT, through the real library. A provider a test calls runs in a PHP process of its own (`tests/fixtures/provider.php`). No network is needed. The suite needs PHP ≥ 8.3 (PHPUnit 12's floor); the library itself runs on PHP ≥ 8.1. diff --git a/src/Binding.php b/src/Binding.php index b46c07e..578a5fe 100644 --- a/src/Binding.php +++ b/src/Binding.php @@ -39,6 +39,10 @@ final class Binding const uint8_t *public_key, size_t public_key_len, const char *profile, char **err_out); void macula_key_free(uintptr_t key); + char *macula_ucan_create(uintptr_t key, const uint8_t *audience_node_id, const char *caps_json, int64_t exp_s, + const char *options_json, char **err_out); + char *macula_ucan_proof_id(const char *token, char **err_out); + uintptr_t macula_pool_connect(uintptr_t key, const char *seeds_json, const char *options_json, uintptr_t cancel, char **err_out); void macula_pool_close(uintptr_t pool); @@ -71,6 +75,7 @@ final class Binding const char *payload_json, const char *options_json, int64_t deadline_ms, int64_t timeout_ms, uintptr_t cancel, char **err_out); char *macula_stream_request(uintptr_t stream, char **err_out); + char *macula_stream_report(uintptr_t stream, char **err_out); void macula_stream_send_bytes(uintptr_t stream, const uint8_t *data, size_t data_len, char **err_out); void macula_stream_send_json(uintptr_t stream, const char *value_json, char **err_out); void macula_stream_close_send(uintptr_t stream, char **err_out); diff --git a/src/NodeKey.php b/src/NodeKey.php index 8235d65..37c64ac 100644 --- a/src/NodeKey.php +++ b/src/NodeKey.php @@ -98,6 +98,37 @@ public static function verify(string $message, string $signature, string $public strlen($publicKey), $profile->value, $err)) === 1; } + /** + * This identity key's UCAN for the node audience names (64 hex characters + * or 32 bytes), granting caps, a list of ['with' => , 'can' => + * ...] (`mri:realm:`, `mri:org:/`, + * `mri:proc://`), until expS (Unix seconds). A delegated + * token names its parent in prf by Ucan::proofId; the audience is always + * the node that will present it. fct, when given, is null or an object + * (new \stdClass() or a decoded JSON object), never a PHP array. + * + * @param list $caps + * @param list $prf + */ + public function ucan( + string $audience, + array $caps, + int $expS, + ?int $nbf = null, + ?string $nnc = null, + mixed $fct = null, + array $prf = [], + ): string { + $h = $this->live(); + $audience32 = Binding::buffer(Wire::id32($audience, 'the audience')); + $capsJson = Wire::encode($caps); + $options = array_filter(['nbf' => $nbf, 'nnc' => $nnc, 'fct' => $fct, 'prf' => $prf === [] ? null : $prf], + static fn (mixed $v) => $v !== null); + $optionsJson = $options === [] ? null : Wire::encode($options); + return Binding::takeString(Binding::call(fn ($err) => Binding::ffi()->macula_ucan_create($h, $audience32, + $capsJson, $expS, $optionsJson, $err))); + } + /** Frees the native key. The NodeKey is unusable after. */ public function free(): void { diff --git a/src/Pool.php b/src/Pool.php index 78dad50..7ed4a89 100644 --- a/src/Pool.php +++ b/src/Pool.php @@ -99,7 +99,11 @@ public function status(): array * thrown as a ProviderError, a station's relay error as a RelayError. It * is sealed whenever the provider's advertisement names a KEM key; under * Confidentiality::Required one that cannot be sealed is a - * ConfidentialityError, never a call in the clear. + * ConfidentialityError, never a call in the clear. A gated procedure + * takes a UCAN for this node and its chain's proofs; it refuses one with + * a ProviderError of code "unauthorized". + * + * @param list $proofs */ public function call( string $realm, @@ -109,23 +113,78 @@ public function call( int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS, BytesOutput $bytes = BytesOutput::Hex, Confidentiality $confidential = Confidentiality::Preferred, + ?string $ucan = null, + array $proofs = [], ): mixed { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); $json = Wire::encode($payload); - $opts = self::callOptions($provider, $confidential); + $opts = self::callOptions($provider, $confidential, $ucan, $proofs); $result = Binding::call(fn ($err) => Binding::ffi()->macula_pool_call_opts($h, $realm32, $procedure, $json, $opts, $timeoutMs, 0, $err)); return Wire::decodeOutput(Binding::takeString($result), $bytes); } - /** A call's or an open's options as the library takes them. */ - private static function callOptions(?string $provider, Confidentiality $confidential): string - { + /** + * call, with its seal report: whether the exchange behind the result was + * sealed, to which provider and key. An error is thrown as call throws it, + * with no report. + * + * @param list $proofs + */ + public function callReport( + string $realm, + string $procedure, + mixed $payload = new \stdClass(), + ?string $provider = null, + int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS, + BytesOutput $bytes = BytesOutput::Hex, + Confidentiality $confidential = Confidentiality::Preferred, + ?string $ucan = null, + array $proofs = [], + ): ReportedCall { + $h = $this->live(); + $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); + $json = Wire::encode($payload); + $opts = self::callOptions($provider, $confidential, $ucan, $proofs, report: true); + $reply = Wire::decode(Binding::takeString(Binding::call(fn ($err) => Binding::ffi()->macula_pool_call_opts($h, + $realm32, $procedure, $json, $opts, $timeoutMs, 0, $err)))); + return new ReportedCall(Wire::output($reply['result'], $bytes), SealReport::fromArray($reply)); + } + + /** + * A call's or an open's options as the library takes them: the provider, + * how it is kept, a UCAN and its chain's proofs (the parents' text), and + * whether a call reports. + * + * @param list $proofs + */ + private static function callOptions( + ?string $provider, + Confidentiality $confidential, + ?string $ucan, + array $proofs, + bool $report = false, + ): string { $opts = ['confidential' => $confidential->value]; if ($provider !== null) { $opts['provider'] = bin2hex(Wire::id32($provider, 'provider')); } + if ($ucan === '') { + throw new \InvalidArgumentException('macula-php: a UCAN is never empty; pass null for none'); + } + if ($ucan !== null) { + $opts['ucan'] = $ucan; + } + if ($proofs !== []) { + if ($ucan === null) { + throw new \InvalidArgumentException('macula-php: proofs go with the UCAN they prove'); + } + $opts['proofs'] = array_values($proofs); + } + if ($report) { + $opts['report'] = 1; + } return json_encode($opts, JSON_THROW_ON_ERROR); } @@ -167,16 +226,19 @@ public function subscribe(string $realm, string $topic, BytesOutput $bytes = Byt * namespace (ownProcedure) needs neither, and another node's namespace is * refused. Under Confidentiality::Required a call in the clear is refused; * a pool connected with kemAdvertise names its KEM key so callers seal. + * Under a ServePolicy only callers presenting a UCAN it accepts reach the + * handler; libmacula refuses the rest before they do. */ public function serve( string $realm, string $procedure, BytesOutput $bytes = BytesOutput::Hex, Confidentiality $confidential = Confidentiality::Preferred, + ?ServePolicy $policy = null, ): Served { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - $opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR); + $opts = self::serveOptions($confidential, $policy); return new Served(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_opts($h, $realm32, $procedure, $opts, $err)), $bytes); } @@ -189,10 +251,11 @@ public function serveStream( StreamMode $mode, BytesOutput $bytes = BytesOutput::Hex, Confidentiality $confidential = Confidentiality::Preferred, + ?ServePolicy $policy = null, ): ServedStream { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); - $opts = json_encode(['confidential' => $confidential->value], JSON_THROW_ON_ERROR); + $opts = self::serveOptions($confidential, $policy); return new ServedStream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_serve_stream_opts($h, $realm32, $procedure, $mode->value, $opts, $err)), $bytes); } @@ -209,15 +272,27 @@ public function openStream( int $timeoutMs = Wire::DEFAULT_CALL_TIMEOUT_MS, BytesOutput $bytes = BytesOutput::Hex, Confidentiality $confidential = Confidentiality::Preferred, + ?string $ucan = null, + array $proofs = [], ): Stream { $h = $this->live(); $realm32 = Binding::buffer(Wire::id32($realm, 'realm')); $json = Wire::encode($payload); - $opts = self::callOptions($provider, $confidential); + $opts = self::callOptions($provider, $confidential, $ucan, $proofs); return new Stream(Binding::call(fn ($err) => Binding::ffi()->macula_pool_open_stream_opts($h, $realm32, $procedure, $mode->value, $json, $opts, $deadlineMs, $timeoutMs, 0, $err)), $bytes); } + /** A served procedure's options as the library takes them. */ + private static function serveOptions(Confidentiality $confidential, ?ServePolicy $policy): string + { + $opts = ['confidential' => $confidential->value]; + if ($policy !== null) { + $opts['policy'] = $policy->toArray(); + } + return json_encode($opts, JSON_THROW_ON_ERROR); + } + /** The verified record under key, or null when there is none. */ public function findRecord( string $key, diff --git a/src/ReportedCall.php b/src/ReportedCall.php new file mode 100644 index 0000000..0f43e0b --- /dev/null +++ b/src/ReportedCall.php @@ -0,0 +1,13 @@ + $fields */ + private function __construct(private readonly array $fields) + { + } + + /** A chain rooted at the identity key of the node issuerNodeId names + * (64 hex characters or 32 bytes). */ + public static function ucanRequired(string $issuerNodeId): self + { + return new self(['kind' => 'ucan_required', 'issuer' => bin2hex(Wire::id32($issuerNodeId, 'the issuer'))]); + } + + /** A chain rooted at the realm key whose id keyId names (64 hex + * characters or 32 bytes), granting can. */ + public static function realmMemberRequired(string $keyId, string $can): self + { + if ($can === '') { + throw new \InvalidArgumentException('macula-php: a realm member policy names a can'); + } + return new self(['kind' => 'realm_member_required', 'key_id' => bin2hex(Wire::id32($keyId, 'the realm key id')), 'can' => $can]); + } + + /** @internal The policy as libmacula's policy_json takes it. @return array */ + public function toArray(): array + { + return $this->fields; + } +} diff --git a/src/Stream.php b/src/Stream.php index 3eee797..689e2a6 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -29,6 +29,17 @@ public function request(): Request Binding::ffi()->macula_stream_request($h, $err))), $this->bytes); } + /** A caller stream's seal report, once it settled on the provider's first + * data or reply (on a clear stream, its first data, reply or end). Before + * that, a MaculaException of kind "not_settled"; on a served stream, + * "not_a_caller". */ + public function report(): SealReport + { + $h = $this->live(); + return SealReport::fromArray(Wire::decode(Binding::takeString(Binding::call(fn ($err) => + Binding::ffi()->macula_stream_report($h, $err))))); + } + /** Sends a raw chunk. */ public function send(string $chunk): void { diff --git a/src/Ucan.php b/src/Ucan.php new file mode 100644 index 0000000..97ddf9e --- /dev/null +++ b/src/Ucan.php @@ -0,0 +1,16 @@ + Binding::ffi()->macula_ucan_proof_id($token, $err))); + } +} diff --git a/tests/PoolTest.php b/tests/PoolTest.php index 73d1c23..f0edc9e 100644 --- a/tests/PoolTest.php +++ b/tests/PoolTest.php @@ -13,6 +13,7 @@ use Macula\Pool; use Macula\Profile; use Macula\ProviderError; +use Macula\Ucan; use Macula\RecordType; use Macula\StreamData; use Macula\StreamEnd; @@ -145,6 +146,78 @@ public function testARequiredCallToAProviderThatNamesNoKeyIsAConfidentialityErro } } + public function testAGatedProcedureServesOnlyACallerItsRootGranted(): void + { + $root = NodeKey::generate(Profile::PqPure); + $alice = NodeKey::generate(Profile::PqPure); + $provider = ProviderProcess::start(self::$env, 'gated', 0, admitted: true, issuer: $root->nodeIdHex()); + $pool = $this->node(1); + $realm = self::$env->realmId; + $caps = [['with' => 'mri:org:' . self::$env->realmName . '/' . self::$env->org, 'can' => 'invoke']]; + $exp = time() + 300; + try { + $this->eventually('the gated provider is advertised', fn (): bool => + $pool->providers($realm, $provider->procedure) !== []); + $refused = [ + 'no token' => [null, []], + 'a token for another node' => [$root->ucan($alice->nodeIdHex(), $caps, $exp), []], + 'a token from another root' => [$alice->ucan($pool->nodeId(), $caps, $exp), []], + ]; + foreach ($refused as $name => [$token, $proofs]) { + try { + $pool->call($realm, $provider->procedure, ucan: $token, proofs: $proofs); + self::fail("{$name}: served"); + } catch (ProviderError $e) { + self::assertSame('unauthorized', $e->errorCode, $name); + } + } + $granted = $root->ucan($pool->nodeId(), $caps, $exp); + self::assertSame(['served' => $pool->nodeId()], + $pool->call($realm, $provider->procedure, ucan: $granted)); + $toAlice = $root->ucan($alice->nodeIdHex(), $caps, $exp); + $delegated = $alice->ucan($pool->nodeId(), $caps, $exp, prf: [Ucan::proofId($toAlice)]); + self::assertSame(['served' => $pool->nodeId()], + $pool->call($realm, $provider->procedure, ucan: $delegated, proofs: [$toAlice])); + } finally { + $pool->close(); + $provider->stop(); + } + } + + public function testACallReportSaysWhetherTheExchangeWasSealedAndToWhichKey(): void + { + $sealed = ProviderProcess::start(self::$env, 'sealed', 0, admitted: true); + $clear = ProviderProcess::start(self::$env, 'echo', 0, admitted: true); + $pool = $this->node(1); + $realm = self::$env->realmId; + try { + $reported = null; + $this->eventually('the sealed provider answers', function () use ($pool, $realm, $sealed, &$reported): bool { + try { + $reported = $pool->callReport($realm, $sealed->procedure, 'secret', + confidential: Confidentiality::Required); + return true; + } catch (\Macula\MaculaException) { + return false; + } + }); + self::assertEquals(['echo' => 'secret', 'sealed' => 1], $reported->result); + self::assertTrue($reported->report->sealed); + self::assertSame($sealed->nodeId, $reported->report->provider); + self::assertMatchesRegularExpression('/^[0-9a-f]{16}$/', (string) $reported->report->sealKeyId); + + $plain = $pool->callReport($realm, $clear->procedure, 'hello'); + self::assertEquals(['echo' => 'hello', 'caller' => $pool->nodeId()], $plain->result); + self::assertFalse($plain->report->sealed); + self::assertSame($clear->nodeId, $plain->report->provider); + self::assertNull($plain->report->sealKeyId); + } finally { + $pool->close(); + $sealed->stop(); + $clear->stop(); + } + } + public function testACallIsRefusedForAnUnpinnedRealmAndFindsNoProviderForWhatNobodyServes(): void { $caller = $this->node(0); @@ -173,6 +246,26 @@ public function testABooleanPayloadIsRefusedBeforeItReachesTheWire(): void } } + public function testAnEmptyUcanIsRefusedBeforeItReachesTheWire(): void + { + $caller = $this->node(0); + try { + $this->expectExceptionMessageMatches('/never empty/'); + $caller->call(self::$env->realmId, self::$env->org . '/echo', ucan: ''); + } finally { + $caller->close(); + } + } + + public function testARealmMemberPolicyTakesItsKeyIdAsAnyIdIsTaken(): void + { + $id = str_repeat('ab', 32); + self::assertSame($id, \Macula\ServePolicy::realmMemberRequired(strtoupper($id), 'call')->toArray()['key_id']); + self::assertSame($id, \Macula\ServePolicy::realmMemberRequired(hex2bin($id), 'call')->toArray()['key_id']); + $this->expectException(\InvalidArgumentException::class); + \Macula\ServePolicy::realmMemberRequired('abcd', 'call'); + } + public function testANodesOwnNamespaceIsServedAndCalledWithNoRealmKeyPinned(): void { $provider = ProviderProcess::start(self::$env, 'ring', 0, admitted: false, trusted: false); @@ -230,6 +323,10 @@ public function testAServerStreamDeliversItsChunksAndEndsAndLeavesNothingRelayed } } self::assertSame(['one', 'two', 'three'], $got); + $report = $stream->report(); + self::assertFalse($report->sealed, 'a clear stream settles clear on its first data'); + self::assertSame($provider->nodeId, $report->provider); + self::assertNull($report->sealKeyId); $stream->free(); $this->eventually('every stream released', fn () => self::$env->relayed() === 0); } finally { diff --git a/tests/ProviderProcess.php b/tests/ProviderProcess.php index 20a1af2..545f440 100644 --- a/tests/ProviderProcess.php +++ b/tests/ProviderProcess.php @@ -25,7 +25,14 @@ private function __construct(private $process, private array $pipes, TestStation $this->procedure = $this->line(); } - public static function start(TestStations $env, string $kind, int $station, bool $admitted, bool $trusted = true): self + public static function start( + TestStations $env, + string $kind, + int $station, + bool $admitted, + bool $trusted = true, + ?string $issuer = null, + ): self { $s = $env->stations[$station]; $config = json_encode([ @@ -35,6 +42,7 @@ public static function start(TestStations $env, string $kind, int $station, bool 'org' => $env->org, 'kind' => $kind, 'admitted' => $admitted, + 'issuer' => $issuer, ], JSON_THROW_ON_ERROR); $process = proc_open([PHP_BINARY, __DIR__ . '/fixtures/provider.php', $config], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR], $pipes); diff --git a/tests/TestStations.php b/tests/TestStations.php index 0bc3bee..5af9f73 100644 --- a/tests/TestStations.php +++ b/tests/TestStations.php @@ -14,6 +14,7 @@ final class TestStations /** @var list */ public readonly array $stations; public readonly string $realmId; + public readonly string $realmName; public readonly string $realmKey; public readonly string $org; @@ -23,6 +24,7 @@ private function __construct(private $process, private array $pipes) $info = json_decode($this->line(), true, flags: JSON_THROW_ON_ERROR); $this->stations = $info['stations']; $this->realmId = $info['realm_id']; + $this->realmName = $info['realm_name']; $this->realmKey = $info['realm_key']; $this->org = $info['org']; } diff --git a/tests/fixtures/provider.php b/tests/fixtures/provider.php index 4261d01..fc06661 100644 --- a/tests/fixtures/provider.php +++ b/tests/fixtures/provider.php @@ -5,7 +5,8 @@ // A provider in a process of its own, for the tests: PHP runs one thing at a // time, so a call from the test blocks until its answer, and the answer has to // come from another process. argv[1] is JSON {seed: {host, port, node_id}, -// realm_id, realm_key (null for none), org, kind, admitted}. It prints its +// realm_id, realm_key (null for none), org, kind, admitted, issuer (the +// UCAN root a gated kind requires)}. It prints its // node_id; when admitted it waits for a line on stdin (the test admits it // meanwhile); then it connects, serves the procedure `kind` names, prints that // procedure, and answers until stdin closes. @@ -18,6 +19,7 @@ use Macula\Profile; use Macula\Request; use Macula\Seed; +use Macula\ServePolicy; use Macula\Stream; use Macula\StreamData; use Macula\StreamEnd; @@ -46,6 +48,11 @@ function (Request $r): mixed { return ['echo' => $r->payload, 'caller' => $r->caller]; }, ], + 'gated' => [ + "{$org}/gated", + $pool->serve($realm, "{$org}/gated", policy: ServePolicy::ucanRequired($config['issuer'])), + fn (Request $r): mixed => ['served' => $r->caller], + ], 'sealed' => [ "{$org}/sealed", $pool->serve($realm, "{$org}/sealed", confidential: Confidentiality::Required),