@ixo/oracle-runtime-workers 0.41.3 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.41.3",
3
+ "version": "0.42.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,9 +67,9 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
+ "@ixo/common": "1.8.0",
70
71
  "@ixo/ixo-transaction": "0.2.0",
71
72
  "@ixo/ucan": "2.3.0",
72
- "@ixo/common": "1.8.0",
73
73
  "@ixo/action-interactions": "0.1.1"
74
74
  },
75
75
  "devDependencies": {
@@ -168,7 +168,7 @@ export class HomeserverUnauthorizedError extends Error {
168
168
  }
169
169
 
170
170
  /** `mxc://<server>/<media id>`, the parts checked before they go into a URL. */
171
- const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
171
+ export const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
172
172
 
173
173
  /**
174
174
  * Open the media behind `mxc` on the homeserver as the account behind
@@ -95,7 +95,7 @@ export interface MatrixAdapter {
95
95
  getRoomState(roomId: string): Promise<RoomStateSnapshot>;
96
96
  getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
97
97
  /** See `MatrixGatewayObject.botCredentials`. */
98
- botCredentials(): Promise<BotCredentials>;
98
+ botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
99
99
  /** Group rooms: room kind and channel memory (see `plugin-api/types.ts`). Hosts without a gateway omit them. */
100
100
  roomInfo?: RuntimeContext['matrix']['roomInfo'];
101
101
  channelMemory?: RuntimeContext['matrix']['channelMemory'];
@@ -497,7 +497,7 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
497
497
  matrix: {
498
498
  postToRoom: (roomId, content, opts) =>
499
499
  ambient.matrix.postToRoom(roomId, content, opts),
500
- botCredentials: () => ambient.matrix.botCredentials(),
500
+ botCredentials: (opts) => ambient.matrix.botCredentials(opts),
501
501
  postEvent: (roomId, eventType, content, opts) =>
502
502
  ambient.matrix.postEvent(roomId, eventType, content, opts),
503
503
  getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
package/src/do/ambient.ts CHANGED
@@ -183,7 +183,8 @@ export function createMatrixAdapter(
183
183
  ),
184
184
  postEvent: (roomId, eventType, content, opts) =>
185
185
  gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
186
- botCredentials: () => gateway.botCredentials(),
186
+ botCredentials: (opts?: { rejected?: string }) =>
187
+ gateway.botCredentials(opts?.rejected ? { rejected: opts.rejected } : {}),
187
188
  roomInfo: (roomId) => gateway.groupChatRoomInfo(roomId),
188
189
  channelMemory: {
189
190
  recall: async (roomId, limit) =>
@@ -818,8 +818,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
818
818
  /**
819
819
  * Credentials of the oracle's own crypto-less device for plugins that run
820
820
  * their own Matrix client (editor, flows). Minted and kept by the gateway.
821
+ * A caller whose request the homeserver answered 401 passes the token it
822
+ * used as `rejected`: the host then re-validates the device (or logs it
823
+ * in again) and answers with fresh credentials, so one retry can succeed.
821
824
  */
822
- botCredentials: () => Promise<{
825
+ botCredentials: (opts?: { rejected?: string }) => Promise<{
823
826
  baseUrl: string;
824
827
  userId: string;
825
828
  accessToken: string;
@@ -14,7 +14,7 @@ import { createDomainIndexerTools } from './domain-indexer-tools';
14
14
  * the core `NETWORK` env var. Operators can override with the optional
15
15
  * `DOMAIN_INDEXER_URL` env var (useful for local/staging deployments).
16
16
  */
17
- const DOMAIN_INDEXER_URLS = {
17
+ export const DOMAIN_INDEXER_URLS = {
18
18
  mainnet: 'https://domain-indexer.ixo.earth',
19
19
  testnet: 'https://domain-indexer.testnet.ixo.earth',
20
20
  devnet: 'https://domain-indexer.devnet.ixo.earth',
@@ -151,6 +151,8 @@ export interface ChainCall {
151
151
  deadline: number;
152
152
  signal: AbortSignal;
153
153
  fetch: typeof fetch;
154
+ /** How the call waits before a retry; {@link sleep} unless a test injects its own clock. */
155
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
154
156
  }
155
157
 
156
158
  /**
@@ -192,7 +194,7 @@ export type Outcome<T> =
192
194
  | { ok: false; failure: Failure };
193
195
 
194
196
  /** The turn's abort reason as an error (a non-error reason becomes an `AbortError`). */
195
- function abortReason(signal: AbortSignal): Error {
197
+ export function abortReason(signal: AbortSignal): Error {
196
198
  return signal.reason instanceof Error
197
199
  ? signal.reason
198
200
  : new DOMException('Aborted', 'AbortError');
@@ -288,7 +290,7 @@ export class ReadsInFlight<T> {
288
290
  }
289
291
 
290
292
  /** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
291
- function sleep(ms: number, signal: AbortSignal): Promise<void> {
293
+ export function sleep(ms: number, signal: AbortSignal): Promise<void> {
292
294
  return new Promise((resolve, reject) => {
293
295
  if (signal.aborted) {
294
296
  reject(abortReason(signal));
@@ -394,7 +396,7 @@ export async function withRetry<T>(
394
396
  : { ok: false, failure: first.failure };
395
397
  if (!first.retry || Date.now() + wait + MIN_ATTEMPT_MS > call.deadline)
396
398
  return giveUp();
397
- await sleep(wait, call.signal);
399
+ await (call.sleep ?? sleep)(wait, call.signal);
398
400
  const second = await attempt();
399
401
  recordHealth(read.upstream, second);
400
402
  if (second.ok) {
@@ -518,7 +520,9 @@ const outOfBudget = (note: string): Attempt<never> => ({
518
520
  * The body of an error answer as text, or undefined when it is longer than
519
521
  * {@link ERROR_BODY_MAX_BYTES} (the rest is not read).
520
522
  */
521
- async function errorBodyOf(response: Response): Promise<string | undefined> {
523
+ export async function errorBodyOf(
524
+ response: Response,
525
+ ): Promise<string | undefined> {
522
526
  const reader = response.body?.getReader();
523
527
  if (!reader) return '';
524
528
  const decoder = new TextDecoder();
@@ -79,12 +79,25 @@ function networkOf(config: MergedConfig): Network {
79
79
 
80
80
  /** The REST base URL: `CHAIN_REST_URL` when set, else the network's default. */
81
81
  export function chainRestUrl(config: MergedConfig): string {
82
+ return chainRestUrlFor(networkOf(config), config);
83
+ }
84
+
85
+ /**
86
+ * The REST base URL for `network`: `CHAIN_REST_URL` when set (the
87
+ * operator's node for the oracle's network), else that network's default.
88
+ * For a caller that already knows its network rather than reading
89
+ * `NETWORK` itself.
90
+ */
91
+ export function chainRestUrlFor(
92
+ network: Network,
93
+ config: MergedConfig,
94
+ ): string {
82
95
  const override = configSchema.shape.CHAIN_REST_URL.safeParse(
83
96
  config.CHAIN_REST_URL,
84
97
  );
85
98
  return override.success && override.data
86
99
  ? override.data
87
- : CHAIN_REST_URLS[networkOf(config)];
100
+ : CHAIN_REST_URLS[network];
88
101
  }
89
102
 
90
103
  /** The names worker's origin: `IXO_NAMES_URL` when set, else the network's default. */
@@ -221,6 +221,32 @@ export function isBech32(address: string, prefix: string): boolean {
221
221
  return padding < 5 && (last & ((1 << padding) - 1)) === 0;
222
222
  }
223
223
 
224
+ /**
225
+ * The bytes a valid bech32 `address` with the prefix `prefix` carries (for
226
+ * an account, the 20-byte hash of its public key); null when
227
+ * {@link isBech32} refuses it.
228
+ */
229
+ export function bech32Bytes(
230
+ address: string,
231
+ prefix: string,
232
+ ): Uint8Array | null {
233
+ if (!isBech32(address, prefix)) return null;
234
+ const data = address.slice(prefix.length + 1, -BECH32_CHECKSUM);
235
+ const bytes: number[] = [];
236
+ let buffer = 0;
237
+ let bits = 0;
238
+ for (const char of data) {
239
+ buffer = (buffer << 5) | BECH32_CHARSET.indexOf(char);
240
+ bits += 5;
241
+ if (bits >= 8) {
242
+ bits -= 8;
243
+ bytes.push((buffer >>> bits) & 0xff);
244
+ }
245
+ }
246
+ // The leftover bits are the zero padding isBech32 checked.
247
+ return Uint8Array.from(bytes);
248
+ }
249
+
224
250
  /**
225
251
  * The account an account field names: the user's own address when the
226
252
  * field is omitted, else an `ixo1…` address or the address a
@@ -9,24 +9,87 @@ import type { ServicePodBlueprint } from './blueprint-types';
9
9
  */
10
10
  export interface PreparedPodBatch {
11
11
  /**
12
- * The messages, in execution order: `MsgCreateEntity` first, then the
13
- * claim collection(s), entity accounts and grants (`POD_BATCH_TYPE_URLS`).
14
- * Each is `{ typeUrl, value }` in proto-JSON (camelCase fields, integer
15
- * amounts as strings, bytes as base64) with exactly its catalog fields.
12
+ * The messages, in execution order: `MsgCreateEntity` first, then only
13
+ * `POD_BATCH_TYPE_URLS` (the bundled gateway sends `MsgCreateEntity`
14
+ * alone). Each is `{ typeUrl, value }` in proto-JSON (camelCase fields,
15
+ * integer amounts as strings, bytes as base64, timestamps as RFC 3339
16
+ * strings) with exactly its catalog fields.
16
17
  */
17
18
  messages: ITrxMsg[];
18
19
  /** Human-readable summary of what the batch creates (at most 1000 characters). */
19
20
  summary: string;
20
21
  /** Estimated cost, when the gateway can compute it. */
21
22
  estimatedCost?: string;
23
+ /**
24
+ * Public documents the messages name (by URL and SHA-256 proof) that are
25
+ * not published yet: `publishDocuments` publishes them once the user
26
+ * approved, before the wallet is asked to sign. Empty when every document
27
+ * the messages name is already public.
28
+ */
29
+ documents: PendingDocument[];
30
+ }
31
+
32
+ /**
33
+ * A public document a batch names (the POD's profile, its domain card),
34
+ * reserved on the oracle's homeserver when the batch is prepared and
35
+ * uploaded only after the user approved it.
36
+ */
37
+ export interface PendingDocument {
38
+ /** The reserved media it is uploaded as: `mxc://<server>/<media id>`. */
39
+ mxc: string;
40
+ /** Its media type, e.g. `application/ld+json`. */
41
+ contentType: string;
42
+ /** The file name the homeserver records. */
43
+ filename: string;
44
+ /** The exact text: the message carries the SHA-256 of these bytes as its proof. */
45
+ text: string;
46
+ }
47
+
48
+ /**
49
+ * An expected refusal (a missing identity, a homeserver that is down), which
50
+ * the tools report to the model as a result instead of throwing.
51
+ */
52
+ export interface GatewayRefusal {
53
+ ok: false;
54
+ message: string;
55
+ /** Whether the same call can succeed later unchanged (something was down). */
56
+ retryable: boolean;
57
+ }
58
+
59
+ /** A gateway answer: the value, or why there is none. */
60
+ export type GatewayResult<T extends object> =
61
+ | ({ ok: true } & T)
62
+ | GatewayRefusal;
63
+
64
+ /**
65
+ * What a broadcast transaction says about the POD. `pending`: not on chain
66
+ * (or not indexed) within the lookup's time, ask again later with the same
67
+ * hash; `failed`: it failed, or it is not the POD this conversation sent, so
68
+ * asking again cannot help; `unavailable`: the chain could not be read.
69
+ */
70
+ export type PodConfirmation =
71
+ | { status: 'created'; podDid: string; summary: string }
72
+ | {
73
+ status: 'pending' | 'failed' | 'unavailable';
74
+ message: string;
75
+ retryable: boolean;
76
+ };
77
+
78
+ /** What the user's wallet was asked to sign, as the create session records it. */
79
+ export interface SentPodBatch {
80
+ /** The messages exactly as sent (proto-JSON, catalog-validated). */
81
+ messages: ITrxMsg[];
82
+ /** The profile the POD was prepared with (for its domain card). */
83
+ profile: PodProfile;
22
84
  }
23
85
 
24
86
  /**
25
87
  * The message types a POD creation batch may carry. The chain runs the
26
88
  * messages of one transaction in order, all or nothing, so a later message
27
89
  * can name what an earlier one creates — the entity DID, its admin account,
28
- * the collection id — as long as the gateway predicts those values right
29
- * (otherwise the whole transaction fails and nothing is created).
90
+ * the collection id — only by predicting those values (otherwise the whole
91
+ * transaction fails and nothing is created). The bundled gateway does not
92
+ * predict: it sends `MsgCreateEntity` alone.
30
93
  */
31
94
  export const POD_BATCH_TYPE_URLS = [
32
95
  '/ixo.entity.v1beta1.MsgCreateEntity',
@@ -62,49 +125,78 @@ export function podBatchProblem(
62
125
  return null;
63
126
  }
64
127
 
65
- /** The created POD, resolved from the broadcast transaction. */
66
- export interface CreatedPod {
67
- podDid: string;
68
- summary: string;
128
+ /** What the user agreed to call the POD, for its on-chain profile. */
129
+ export interface PodProfile {
130
+ /** The POD's name: plain text, 1 to 100 characters. */
131
+ name: string;
132
+ /** What the POD does, in plain text (at most 600 characters). */
133
+ description?: string;
69
134
  }
70
135
 
71
136
  /**
72
- * The chain-facing seam for the create path. The planned concrete implementation
73
- * calls the IXO MCP server (hosted on Cloudflare) to compose the POD-creation
74
- * batch (`MsgCreateEntity` + the claim collection + authz grants) and to read a
75
- * broadcast transaction back. The oracle never signs creation — the user's
76
- * wallet does — so the gateway only ever returns proto-JSON messages, which
77
- * the plugin validates against the `@ixo/ixo-transaction` catalog before
78
- * storing them, and resolves results.
137
+ * The chain-facing seam for the create path. The oracle never signs
138
+ * creation — the user's wallet does — so a gateway only ever returns
139
+ * proto-JSON messages, which the plugin validates against the
140
+ * `@ixo/ixo-transaction` catalog before storing them, and resolves the
141
+ * broadcast transaction afterwards.
79
142
  *
80
- * Both methods receive the request `RuntimeContext` because reaching the IXO MCP
81
- * server follows the runtime's remote-MCP pattern (see the sandbox plugin):
82
- * resolve the server DID via did:web and mint a per-user `ixo:*` UCAN invocation
83
- * through `ctx.ucan` for the `Authorization` header, with the MCP URL read from
84
- * `ctx.config`. The seam is injected so the create path stays unit-testable
85
- * without a live server or chain.
143
+ * The bundled implementation is `createIxoChainGateway()`
144
+ * (`ixo-chain-gateway.ts`): one `MsgCreateEntity` owned by the user, its
145
+ * profile reserved on the oracle's homeserver and published after approval,
146
+ * read back from chain REST; then the POD's domain card. Every method
147
+ * receives the request `RuntimeContext` (the user, the config, the turn's
148
+ * abort signal, the oracle's Matrix device) and answers an expected refusal
149
+ * as a result; a throw is a programming error. The seam is injected so the
150
+ * create path stays unit-testable without a live homeserver or chain, and
151
+ * so a fork can compose a different batch.
86
152
  */
87
153
  export interface ChainGateway {
88
154
  preparePodBatch(
89
- input: { blueprint: ServicePodBlueprint; network: Network },
155
+ input: {
156
+ blueprint: ServicePodBlueprint;
157
+ network: Network;
158
+ profile: PodProfile;
159
+ },
90
160
  ctx: RuntimeContext,
91
- ): Promise<PreparedPodBatch>;
161
+ ): Promise<GatewayResult<PreparedPodBatch>>;
162
+ /** Publish a prepared batch's pending documents; publishing one twice is a success. */
163
+ publishDocuments(
164
+ input: { documents: readonly PendingDocument[] },
165
+ ctx: RuntimeContext,
166
+ ): Promise<GatewayResult<object>>;
167
+ /** Read the transaction back and check it is the POD `sent` describes. */
92
168
  confirmPodCreation(
93
- input: { txHash: string; network: Network },
169
+ input: { txHash: string; network: Network; sent: SentPodBatch },
170
+ ctx: RuntimeContext,
171
+ ): Promise<PodConfirmation>;
172
+ /** The batch that adds a confirmed POD's public domain card (one message). */
173
+ prepareDomainCard(
174
+ input: { network: Network; podDid: string; profile: PodProfile },
175
+ ctx: RuntimeContext,
176
+ ): Promise<GatewayResult<PreparedPodBatch>>;
177
+ /** Ask the domain indexer to index the POD now; best effort, never throws. */
178
+ requestIndexing(
179
+ input: { network: Network; podDid: string },
94
180
  ctx: RuntimeContext,
95
- ): Promise<CreatedPod>;
181
+ ): Promise<void>;
96
182
  }
97
183
 
98
184
  const notConfigured = (): never => {
99
185
  throw new Error(
100
- 'ChainGateway not configured: the POD create path needs a chain gateway ' +
101
- 'wired to the IXO MCP server before it can prepare or confirm ' +
102
- 'transactions.',
186
+ 'ChainGateway not configured: this oracle was built without a chain ' +
187
+ 'gateway, so it cannot prepare or confirm POD creation transactions.',
103
188
  );
104
189
  };
105
190
 
106
- /** Default gateway that errors until a real one is wired. */
191
+ /**
192
+ * A gateway that refuses every call, for a fork that wants the design
193
+ * pipeline without on-chain creation: the create tools report creation as
194
+ * unavailable without calling it.
195
+ */
107
196
  export const notConfiguredChainGateway: ChainGateway = {
108
197
  preparePodBatch: async () => notConfigured(),
198
+ publishDocuments: async () => notConfigured(),
109
199
  confirmPodCreation: async () => notConfigured(),
200
+ prepareDomainCard: async () => notConfigured(),
201
+ requestIndexing: async () => notConfigured(),
110
202
  };
@@ -5,6 +5,7 @@ import {
5
5
  } from '@ixo/ixo-transaction';
6
6
  import { z } from 'zod';
7
7
  import type { RuntimeContext } from '../../plugin-api/types';
8
+ import { DOMAIN_INDEXER_URLS } from '../domain-indexer/domain-indexer.plugin';
8
9
  import {
9
10
  chainIdConfigSchema,
10
11
  chainIdsFromConfig,
@@ -18,6 +19,9 @@ import {
18
19
  */
19
20
  export const DEFAULT_NETWORK: Network = 'testnet';
20
21
 
22
+ /** An IXO entity DID as the chain derives it. */
23
+ const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
24
+
21
25
  /**
22
26
  * Plugin-owned env vars. The capsules registry URL and `NETWORK` are read as
23
27
  * siblings from the merged config (owned by the skills plugin / base schema),
@@ -42,14 +46,49 @@ export const podCreatorConfigSchema = z.object({
42
46
  .default(false),
43
47
  /** The chain id each network's signing request names. */
44
48
  ...chainIdConfigSchema.shape,
49
+ /**
50
+ * The relayer node a created POD names, instead of the network's default
51
+ * ({@link POD_RELAYER_NODE_DIDS}).
52
+ */
53
+ POD_CREATOR_RELAYER_NODE_DID: z
54
+ .string()
55
+ .regex(ENTITY_DID, 'expected an entity DID: did:ixo:entity:<32 hex>')
56
+ .optional(),
57
+ /**
58
+ * The Portal a created POD is linked to, instead of the network's default
59
+ * ({@link PORTAL_URLS}).
60
+ */
61
+ POD_CREATOR_PORTAL_URL: z.url().optional(),
45
62
  });
46
63
 
64
+ /**
65
+ * The relayer node each network's Portal names on the domains (and PODs) it
66
+ * creates: its `NEXT_PUBLIC_DAO_DID` (ixo-portal `lib/entity/createEntity.ts`),
67
+ * as read from the Portal's recent "Create domain:" transactions. The
68
+ * mainnet Portal lists only entities of this relayer that are verified.
69
+ * Not the oracles' relayer (`lib/oracle/networkConfig.ts`), which only
70
+ * oracle entities name.
71
+ */
72
+ export const POD_RELAYER_NODE_DIDS: Readonly<Record<Network, string>> = {
73
+ mainnet: 'did:ixo:entity:47d9c64891e77cb8e588d33b83ab3042',
74
+ testnet: 'did:ixo:entity:3d079ebc0b332aad3305bb4a51c72edb',
75
+ devnet: 'did:ixo:entity:ac12c6f8f299071eb24f2a10591d7ab7',
76
+ };
77
+
78
+ /** The Portal each network's PODs are opened in. */
79
+ export const PORTAL_URLS: Readonly<Record<Network, string>> = {
80
+ mainnet: 'https://portal.qi.space',
81
+ testnet: 'https://test.portal.qi.space',
82
+ devnet: 'https://dev.portal.qi.space',
83
+ };
84
+
47
85
  /** Sibling env + plugin flags the tools read at request time. */
48
86
  const configReadSchema = z.object({
49
87
  NETWORK: NetworkSchema.optional(),
50
88
  POD_CREATOR_ALLOW_MAINNET:
51
89
  podCreatorConfigSchema.shape.POD_CREATOR_ALLOW_MAINNET.optional(),
52
90
  ...chainIdConfigSchema.shape,
91
+ POD_CREATOR_PORTAL_URL: podCreatorConfigSchema.shape.POD_CREATOR_PORTAL_URL,
53
92
  });
54
93
 
55
94
  export interface PodCreatorConfig {
@@ -57,8 +96,12 @@ export interface PodCreatorConfig {
57
96
  mainnetAllowed: boolean;
58
97
  /** The chain id each network's signing request names. */
59
98
  chainIds: Readonly<Record<Network, string>>;
99
+ /** The Portal of `network`, without a trailing slash. */
100
+ portalUrl: string;
60
101
  }
61
102
 
103
+ const withoutSlash = (url: string): string => url.replace(/\/+$/, '');
104
+
62
105
  /**
63
106
  * Network routing + the mainnet opt-in, read from the merged config. Falls
64
107
  * back to testnet with mainnet disabled when the config is unreadable — and
@@ -75,11 +118,48 @@ export function readPodCreatorConfig(ctx: RuntimeContext): PodCreatorConfig {
75
118
  network: DEFAULT_NETWORK,
76
119
  mainnetAllowed: false,
77
120
  chainIds: DEFAULT_CHAIN_IDS,
121
+ portalUrl: PORTAL_URLS[DEFAULT_NETWORK],
78
122
  };
79
123
  }
124
+ const network = parsed.data.NETWORK ?? DEFAULT_NETWORK;
80
125
  return {
81
- network: parsed.data.NETWORK ?? DEFAULT_NETWORK,
126
+ network,
82
127
  mainnetAllowed: parsed.data.POD_CREATOR_ALLOW_MAINNET ?? false,
83
128
  chainIds: chainIdsFromConfig(parsed.data),
129
+ portalUrl: withoutSlash(
130
+ parsed.data.POD_CREATOR_PORTAL_URL ?? PORTAL_URLS[network],
131
+ ),
84
132
  };
85
133
  }
134
+
135
+ const relayerReadSchema = z.object({
136
+ POD_CREATOR_RELAYER_NODE_DID:
137
+ podCreatorConfigSchema.shape.POD_CREATOR_RELAYER_NODE_DID,
138
+ });
139
+
140
+ /** The relayer node a POD created on `network` names: the operator's, else the network's Portal's. */
141
+ export function relayerNodeDidFor(
142
+ ctx: RuntimeContext,
143
+ network: Network,
144
+ ): string {
145
+ const parsed = relayerReadSchema.safeParse(ctx.config);
146
+ return (
147
+ (parsed.success ? parsed.data.POD_CREATOR_RELAYER_NODE_DID : undefined) ??
148
+ POD_RELAYER_NODE_DIDS[network]
149
+ );
150
+ }
151
+
152
+ /** `DOMAIN_INDEXER_URL` is the domain-indexer plugin's; read here as a sibling. */
153
+ const indexerReadSchema = z.object({ DOMAIN_INDEXER_URL: z.url().optional() });
154
+
155
+ /** The domain indexer of `network`, without a trailing slash. */
156
+ export function domainIndexerUrlFor(
157
+ ctx: RuntimeContext,
158
+ network: Network,
159
+ ): string {
160
+ const parsed = indexerReadSchema.safeParse(ctx.config);
161
+ return withoutSlash(
162
+ (parsed.success ? parsed.data.DOMAIN_INDEXER_URL : undefined) ??
163
+ DOMAIN_INDEXER_URLS[network],
164
+ );
165
+ }