@aster-rpc/aster 0.1.2

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.
Files changed (233) hide show
  1. package/dist/capabilities.d.ts +26 -0
  2. package/dist/capabilities.d.ts.map +1 -0
  3. package/dist/capabilities.js +29 -0
  4. package/dist/capabilities.js.map +1 -0
  5. package/dist/client.d.ts +65 -0
  6. package/dist/client.d.ts.map +1 -0
  7. package/dist/client.js +108 -0
  8. package/dist/client.js.map +1 -0
  9. package/dist/codec.d.ts +156 -0
  10. package/dist/codec.d.ts.map +1 -0
  11. package/dist/codec.js +477 -0
  12. package/dist/codec.js.map +1 -0
  13. package/dist/config.d.ts +102 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +454 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/contract/identity.d.ts +115 -0
  18. package/dist/contract/identity.d.ts.map +1 -0
  19. package/dist/contract/identity.js +188 -0
  20. package/dist/contract/identity.js.map +1 -0
  21. package/dist/contract/manifest.d.ts +77 -0
  22. package/dist/contract/manifest.d.ts.map +1 -0
  23. package/dist/contract/manifest.js +127 -0
  24. package/dist/contract/manifest.js.map +1 -0
  25. package/dist/contract/publication.d.ts +71 -0
  26. package/dist/contract/publication.d.ts.map +1 -0
  27. package/dist/contract/publication.js +85 -0
  28. package/dist/contract/publication.js.map +1 -0
  29. package/dist/decorators.d.ts +139 -0
  30. package/dist/decorators.d.ts.map +1 -0
  31. package/dist/decorators.js +175 -0
  32. package/dist/decorators.js.map +1 -0
  33. package/dist/dynamic.d.ts +61 -0
  34. package/dist/dynamic.d.ts.map +1 -0
  35. package/dist/dynamic.js +147 -0
  36. package/dist/dynamic.js.map +1 -0
  37. package/dist/framing.d.ts +74 -0
  38. package/dist/framing.d.ts.map +1 -0
  39. package/dist/framing.js +162 -0
  40. package/dist/framing.js.map +1 -0
  41. package/dist/health.d.ts +127 -0
  42. package/dist/health.d.ts.map +1 -0
  43. package/dist/health.js +236 -0
  44. package/dist/health.js.map +1 -0
  45. package/dist/index.d.ts +67 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +101 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/interceptors/audit.d.ts +25 -0
  50. package/dist/interceptors/audit.d.ts.map +1 -0
  51. package/dist/interceptors/audit.js +46 -0
  52. package/dist/interceptors/audit.js.map +1 -0
  53. package/dist/interceptors/auth.d.ts +13 -0
  54. package/dist/interceptors/auth.d.ts.map +1 -0
  55. package/dist/interceptors/auth.js +34 -0
  56. package/dist/interceptors/auth.js.map +1 -0
  57. package/dist/interceptors/base.d.ts +74 -0
  58. package/dist/interceptors/base.d.ts.map +1 -0
  59. package/dist/interceptors/base.js +103 -0
  60. package/dist/interceptors/base.js.map +1 -0
  61. package/dist/interceptors/capability.d.ts +16 -0
  62. package/dist/interceptors/capability.d.ts.map +1 -0
  63. package/dist/interceptors/capability.js +63 -0
  64. package/dist/interceptors/capability.js.map +1 -0
  65. package/dist/interceptors/circuit-breaker.d.ts +40 -0
  66. package/dist/interceptors/circuit-breaker.d.ts.map +1 -0
  67. package/dist/interceptors/circuit-breaker.js +91 -0
  68. package/dist/interceptors/circuit-breaker.js.map +1 -0
  69. package/dist/interceptors/compression.d.ts +11 -0
  70. package/dist/interceptors/compression.d.ts.map +1 -0
  71. package/dist/interceptors/compression.js +12 -0
  72. package/dist/interceptors/compression.js.map +1 -0
  73. package/dist/interceptors/deadline.d.ts +12 -0
  74. package/dist/interceptors/deadline.d.ts.map +1 -0
  75. package/dist/interceptors/deadline.js +28 -0
  76. package/dist/interceptors/deadline.js.map +1 -0
  77. package/dist/interceptors/metrics.d.ts +43 -0
  78. package/dist/interceptors/metrics.d.ts.map +1 -0
  79. package/dist/interceptors/metrics.js +132 -0
  80. package/dist/interceptors/metrics.js.map +1 -0
  81. package/dist/interceptors/rate-limit.d.ts +24 -0
  82. package/dist/interceptors/rate-limit.d.ts.map +1 -0
  83. package/dist/interceptors/rate-limit.js +84 -0
  84. package/dist/interceptors/rate-limit.js.map +1 -0
  85. package/dist/interceptors/retry.d.ts +25 -0
  86. package/dist/interceptors/retry.d.ts.map +1 -0
  87. package/dist/interceptors/retry.js +55 -0
  88. package/dist/interceptors/retry.js.map +1 -0
  89. package/dist/limits.d.ts +77 -0
  90. package/dist/limits.d.ts.map +1 -0
  91. package/dist/limits.js +137 -0
  92. package/dist/limits.js.map +1 -0
  93. package/dist/logging.d.ts +40 -0
  94. package/dist/logging.d.ts.map +1 -0
  95. package/dist/logging.js +92 -0
  96. package/dist/logging.js.map +1 -0
  97. package/dist/metadata.d.ts +14 -0
  98. package/dist/metadata.d.ts.map +1 -0
  99. package/dist/metadata.js +68 -0
  100. package/dist/metadata.js.map +1 -0
  101. package/dist/metrics.d.ts +40 -0
  102. package/dist/metrics.d.ts.map +1 -0
  103. package/dist/metrics.js +92 -0
  104. package/dist/metrics.js.map +1 -0
  105. package/dist/peer-store.d.ts +53 -0
  106. package/dist/peer-store.d.ts.map +1 -0
  107. package/dist/peer-store.js +105 -0
  108. package/dist/peer-store.js.map +1 -0
  109. package/dist/protocol.d.ts +44 -0
  110. package/dist/protocol.d.ts.map +1 -0
  111. package/dist/protocol.js +59 -0
  112. package/dist/protocol.js.map +1 -0
  113. package/dist/registration.d.ts +81 -0
  114. package/dist/registration.d.ts.map +1 -0
  115. package/dist/registration.js +161 -0
  116. package/dist/registration.js.map +1 -0
  117. package/dist/registry/acl.d.ts +57 -0
  118. package/dist/registry/acl.d.ts.map +1 -0
  119. package/dist/registry/acl.js +104 -0
  120. package/dist/registry/acl.js.map +1 -0
  121. package/dist/registry/client.d.ts +70 -0
  122. package/dist/registry/client.d.ts.map +1 -0
  123. package/dist/registry/client.js +115 -0
  124. package/dist/registry/client.js.map +1 -0
  125. package/dist/registry/gossip.d.ts +43 -0
  126. package/dist/registry/gossip.d.ts.map +1 -0
  127. package/dist/registry/gossip.js +102 -0
  128. package/dist/registry/gossip.js.map +1 -0
  129. package/dist/registry/keys.d.ts +25 -0
  130. package/dist/registry/keys.d.ts.map +1 -0
  131. package/dist/registry/keys.js +47 -0
  132. package/dist/registry/keys.js.map +1 -0
  133. package/dist/registry/models.d.ts +80 -0
  134. package/dist/registry/models.d.ts.map +1 -0
  135. package/dist/registry/models.js +35 -0
  136. package/dist/registry/models.js.map +1 -0
  137. package/dist/registry/publisher.d.ts +65 -0
  138. package/dist/registry/publisher.d.ts.map +1 -0
  139. package/dist/registry/publisher.js +164 -0
  140. package/dist/registry/publisher.js.map +1 -0
  141. package/dist/runtime.d.ts +267 -0
  142. package/dist/runtime.d.ts.map +1 -0
  143. package/dist/runtime.js +1366 -0
  144. package/dist/runtime.js.map +1 -0
  145. package/dist/server.d.ts +100 -0
  146. package/dist/server.d.ts.map +1 -0
  147. package/dist/server.js +511 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/service.d.ts +72 -0
  150. package/dist/service.d.ts.map +1 -0
  151. package/dist/service.js +98 -0
  152. package/dist/service.js.map +1 -0
  153. package/dist/session.d.ts +64 -0
  154. package/dist/session.d.ts.map +1 -0
  155. package/dist/session.js +350 -0
  156. package/dist/session.js.map +1 -0
  157. package/dist/status.d.ts +113 -0
  158. package/dist/status.d.ts.map +1 -0
  159. package/dist/status.js +206 -0
  160. package/dist/status.js.map +1 -0
  161. package/dist/transport/base.d.ts +46 -0
  162. package/dist/transport/base.d.ts.map +1 -0
  163. package/dist/transport/base.js +10 -0
  164. package/dist/transport/base.js.map +1 -0
  165. package/dist/transport/iroh.d.ts +45 -0
  166. package/dist/transport/iroh.d.ts.map +1 -0
  167. package/dist/transport/iroh.js +225 -0
  168. package/dist/transport/iroh.js.map +1 -0
  169. package/dist/transport/local.d.ts +48 -0
  170. package/dist/transport/local.d.ts.map +1 -0
  171. package/dist/transport/local.js +139 -0
  172. package/dist/transport/local.js.map +1 -0
  173. package/dist/trust/admission.d.ts +60 -0
  174. package/dist/trust/admission.d.ts.map +1 -0
  175. package/dist/trust/admission.js +149 -0
  176. package/dist/trust/admission.js.map +1 -0
  177. package/dist/trust/bootstrap.d.ts +109 -0
  178. package/dist/trust/bootstrap.d.ts.map +1 -0
  179. package/dist/trust/bootstrap.js +311 -0
  180. package/dist/trust/bootstrap.js.map +1 -0
  181. package/dist/trust/clock.d.ts +93 -0
  182. package/dist/trust/clock.d.ts.map +1 -0
  183. package/dist/trust/clock.js +154 -0
  184. package/dist/trust/clock.js.map +1 -0
  185. package/dist/trust/consumer.d.ts +139 -0
  186. package/dist/trust/consumer.d.ts.map +1 -0
  187. package/dist/trust/consumer.js +323 -0
  188. package/dist/trust/consumer.js.map +1 -0
  189. package/dist/trust/credentials.d.ts +98 -0
  190. package/dist/trust/credentials.d.ts.map +1 -0
  191. package/dist/trust/credentials.js +250 -0
  192. package/dist/trust/credentials.js.map +1 -0
  193. package/dist/trust/delegated.d.ts +118 -0
  194. package/dist/trust/delegated.d.ts.map +1 -0
  195. package/dist/trust/delegated.js +292 -0
  196. package/dist/trust/delegated.js.map +1 -0
  197. package/dist/trust/gossip.d.ts +146 -0
  198. package/dist/trust/gossip.d.ts.map +1 -0
  199. package/dist/trust/gossip.js +334 -0
  200. package/dist/trust/gossip.js.map +1 -0
  201. package/dist/trust/hooks.d.ts +84 -0
  202. package/dist/trust/hooks.d.ts.map +1 -0
  203. package/dist/trust/hooks.js +125 -0
  204. package/dist/trust/hooks.js.map +1 -0
  205. package/dist/trust/iid.d.ts +65 -0
  206. package/dist/trust/iid.d.ts.map +1 -0
  207. package/dist/trust/iid.js +104 -0
  208. package/dist/trust/iid.js.map +1 -0
  209. package/dist/trust/mesh.d.ts +43 -0
  210. package/dist/trust/mesh.d.ts.map +1 -0
  211. package/dist/trust/mesh.js +105 -0
  212. package/dist/trust/mesh.js.map +1 -0
  213. package/dist/trust/nonce.d.ts +39 -0
  214. package/dist/trust/nonce.d.ts.map +1 -0
  215. package/dist/trust/nonce.js +46 -0
  216. package/dist/trust/nonce.js.map +1 -0
  217. package/dist/trust/producer.d.ts +80 -0
  218. package/dist/trust/producer.d.ts.map +1 -0
  219. package/dist/trust/producer.js +151 -0
  220. package/dist/trust/producer.js.map +1 -0
  221. package/dist/trust/rcan.d.ts +29 -0
  222. package/dist/trust/rcan.d.ts.map +1 -0
  223. package/dist/trust/rcan.js +57 -0
  224. package/dist/trust/rcan.js.map +1 -0
  225. package/dist/types.d.ts +57 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +50 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/xlang.d.ts +26 -0
  230. package/dist/xlang.d.ts.map +1 -0
  231. package/dist/xlang.js +55 -0
  232. package/dist/xlang.js.map +1 -0
  233. package/package.json +59 -0
@@ -0,0 +1,1366 @@
1
+ /**
2
+ * High-level AsterServer and AsterClient wrappers.
3
+ *
4
+ * These provide one-liner setup for the common case, hiding the
5
+ * details of endpoint creation, admission, and transport wiring.
6
+ */
7
+ import { ServiceRegistry } from './service.js';
8
+ import { LocalTransport } from './transport/local.js';
9
+ import { createClient } from './client.js';
10
+ import { configFromEnv } from './config.js';
11
+ import { createLogger } from './logging.js';
12
+ import { HealthServer } from './health.js';
13
+ import { DEFAULT_BACKOFF, RpcPattern, RpcScope } from './types.js';
14
+ import { JsonCodec } from './codec.js';
15
+ import { RpcServer } from './server.js';
16
+ import { handleConsumerAdmissionConnection, performAdmission } from './trust/consumer.js';
17
+ import { loadIdentity, parseSimpleToml } from './config.js';
18
+ import { handleDelegatedAdmissionConnection } from './trust/delegated.js';
19
+ import { MeshEndpointHook } from './trust/hooks.js';
20
+ import { PeerAttributeStore } from './peer-store.js';
21
+ import { CapabilityInterceptor } from './interceptors/capability.js';
22
+ import { canonicalXlangBytes, contractIdFromContract, fromServiceInfo } from './contract/identity.js';
23
+ import { writeFrame, readFrame, HEADER, TRAILER, CALL } from './framing.js';
24
+ import { StreamHeader, CallHeader } from './protocol.js';
25
+ import { StatusCode, RpcError } from './status.js';
26
+ // ── Constants ────────────────────────────────────────────────────────────────
27
+ const ALPN_CONSUMER_ADMISSION = 'aster.consumer_admission';
28
+ const ALPN_PRODUCER_ADMISSION = 'aster.producer_admission';
29
+ const ALPN_DELEGATED_ADMISSION = 'aster.admission';
30
+ const RPC_ALPN = 'aster/1';
31
+ // ── Errors ───────────────────────────────────────────────────────────────────
32
+ /**
33
+ * Raised when a consumer is refused by the server's admission check.
34
+ *
35
+ * The server never reveals *why* admission failed (no oracle leak), so this
36
+ * error enumerates the common causes as a hint to the user rather than a
37
+ * precise diagnosis. Its `message` is a multi-line actionable hint suitable
38
+ * for direct CLI output.
39
+ */
40
+ export class AdmissionDeniedError extends Error {
41
+ hadCredential;
42
+ credentialFile;
43
+ ourEndpointId;
44
+ serverAddress;
45
+ constructor(opts) {
46
+ const shortId = opts.ourEndpointId
47
+ ? opts.ourEndpointId.slice(0, 16) + '...'
48
+ : '<unknown>';
49
+ let message;
50
+ if (!opts.hadCredential) {
51
+ message =
52
+ 'consumer admission denied -- this server requires a credential.\n' +
53
+ ' - Get an enrollment credential file (.cred) from the server\'s operator.\n' +
54
+ ' - Then retry with: --rcan <path/to/file.cred>\n' +
55
+ ' (or set ASTER_ENROLLMENT_CREDENTIAL=<path> in the environment)';
56
+ }
57
+ else {
58
+ const credLabel = opts.credentialFile ?? '<credential>';
59
+ message =
60
+ `consumer admission denied -- the server rejected your credential.\n` +
61
+ ` credential: ${credLabel}\n` +
62
+ ` your node: ${shortId}\n` +
63
+ ' Common causes:\n' +
64
+ ' 1. The credential expired (check the \'Expires\' field on the file).\n' +
65
+ ' 2. The credential was issued to a DIFFERENT node. Credentials are\n' +
66
+ ' bound to a single endpoint id: if you copied this file from\n' +
67
+ ' another machine/process, the server sees a different node id\n' +
68
+ ' and refuses admission. Ask the operator to re-issue it for\n' +
69
+ ` endpointId=${shortId}.\n` +
70
+ ' 3. The server trusts a different root key than the one that signed\n' +
71
+ ' this credential.\n' +
72
+ ' 4. The credential\'s role/capabilities don\'t match this server\'s\n' +
73
+ ' policy (the server may reject unknown capabilities outright).';
74
+ }
75
+ super(message);
76
+ this.name = 'AdmissionDeniedError';
77
+ this.hadCredential = opts.hadCredential;
78
+ this.credentialFile = opts.credentialFile;
79
+ this.ourEndpointId = opts.ourEndpointId;
80
+ this.serverAddress = opts.serverAddress;
81
+ }
82
+ }
83
+ /**
84
+ * High-level Aster RPC server.
85
+ *
86
+ * Creates an IrohNode, serves RPC over QUIC, handles consumer admission,
87
+ * and prints a startup banner.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const server = new AsterServer({
92
+ * services: [new MissionControl()],
93
+ * });
94
+ * await server.start();
95
+ * console.log(server.address);
96
+ * await server.serve();
97
+ * ```
98
+ */
99
+ export class AsterServer {
100
+ registry;
101
+ config;
102
+ logger;
103
+ health;
104
+ _node = null;
105
+ _rpcServer = null;
106
+ _hook;
107
+ _peerStore;
108
+ _delegationPolicies = new Map();
109
+ _running = false;
110
+ _closed = false;
111
+ _allowAllConsumers;
112
+ _userInterceptors = [];
113
+ _signalHandlers = [];
114
+ _serviceSummaries = [];
115
+ _registryNamespace = '';
116
+ _servePromise = null;
117
+ _admissionAbort = null;
118
+ constructor(opts) {
119
+ this.config = { ...configFromEnv(), ...opts.config };
120
+ if (opts.identity) {
121
+ this.config.identityFile = opts.identity;
122
+ }
123
+ this.logger = createLogger({
124
+ format: this.config.logFormat,
125
+ level: this.config.logLevel,
126
+ mask: this.config.logMask,
127
+ });
128
+ this.registry = new ServiceRegistry();
129
+ this.health = new HealthServer({
130
+ port: this.config.healthPort,
131
+ host: this.config.healthHost,
132
+ });
133
+ this._peerStore = new PeerAttributeStore();
134
+ this._allowAllConsumers = opts.allowAllConsumers ?? true;
135
+ // In dev mode the hook must allow unenrolled peers, otherwise post-admission
136
+ // RPC connections would be denied (they reach Gate 0 before the peer-store
137
+ // entry from admission is checked, and the peer wouldn't be there for
138
+ // ephemeral consumers).
139
+ this._hook = new MeshEndpointHook(this._allowAllConsumers, this._peerStore);
140
+ this._userInterceptors = opts.interceptors ?? [];
141
+ for (const svc of opts.services) {
142
+ this.registry.register(svc);
143
+ }
144
+ }
145
+ /**
146
+ * Create the IrohNode and prepare for serving. Idempotent.
147
+ */
148
+ async start() {
149
+ if (this._node)
150
+ return;
151
+ // Load native addon
152
+ const native = await loadNative();
153
+ if (!native) {
154
+ throw new Error('Aster native addon not found. Build with: cd native && npx napi build --release --platform');
155
+ }
156
+ // Initialize contract identity binding (needed for _publishContracts)
157
+ const { setNativeContract } = await import('./contract/identity.js');
158
+ setNativeContract(native);
159
+ // Create node with RPC + admission ALPNs.
160
+ //
161
+ // Gate 0 hooks must be enabled whenever any admission gate is active
162
+ // (allow_all_consumers=false). Without enable_hooks=true, the
163
+ // before_connect callbacks never fire and the admitted-set is
164
+ // unenforced — every connection is allowed regardless of admission.
165
+ const alpns = [
166
+ Buffer.from(RPC_ALPN),
167
+ Buffer.from(ALPN_CONSUMER_ADMISSION),
168
+ Buffer.from(ALPN_PRODUCER_ADMISSION),
169
+ Buffer.from(ALPN_DELEGATED_ADMISSION),
170
+ ];
171
+ const gate0Needed = !this._allowAllConsumers;
172
+ const endpointConfig = gate0Needed
173
+ ? { enableHooks: true, hookTimeoutMs: 5000 }
174
+ : undefined;
175
+ this._node = await native.IrohNode.memoryWithAlpns(alpns, endpointConfig);
176
+ // Build service summaries for admission response
177
+ // Encode the server's own node ID as the RPC channel address
178
+ const nodeId = this._node.nodeId();
179
+ const rpcAddr = Buffer.from(nodeId).toString('base64');
180
+ this._serviceSummaries = [];
181
+ for (const info of this.registry.getAllServices()) {
182
+ this._serviceSummaries.push({
183
+ name: info.name,
184
+ version: info.version,
185
+ contractId: '',
186
+ pattern: info.scoped ?? 'shared',
187
+ methods: Object.keys(info.methods),
188
+ channels: { rpc: rpcAddr },
189
+ // TS binding only speaks JSON until @apache-fory/core JS becomes
190
+ // XLANG-compliant. Cross-language consumers see this and pick the
191
+ // matching codec.
192
+ serializationModes: ['json'],
193
+ });
194
+ }
195
+ // Auto-wire CapabilityInterceptor if any service declares requires=
196
+ const interceptors = [...this._userInterceptors];
197
+ let anyHasRequires = false;
198
+ for (const info of this.registry.getAllServices()) {
199
+ if (info.requires)
200
+ anyHasRequires = true;
201
+ for (const mi of info.methods.values()) {
202
+ if (mi.requires)
203
+ anyHasRequires = true;
204
+ }
205
+ }
206
+ const hasCapInterceptor = interceptors.some(i => i instanceof CapabilityInterceptor);
207
+ if ((!this._allowAllConsumers || anyHasRequires) && !hasCapInterceptor) {
208
+ const cap = new CapabilityInterceptor();
209
+ // Register requirements from service/method declarations
210
+ for (const info of this.registry.getAllServices()) {
211
+ for (const [methodName, mi] of info.methods.entries()) {
212
+ const req = mi.requires ?? info.requires;
213
+ if (req)
214
+ cap.setRequirement(info.name, methodName, req);
215
+ }
216
+ }
217
+ interceptors.unshift(cap);
218
+ }
219
+ // Create the RPC server (uses JsonCodec for cross-language compat)
220
+ this._rpcServer = new RpcServer({
221
+ registry: this.registry,
222
+ codec: new JsonCodec(),
223
+ interceptors,
224
+ logger: this.logger,
225
+ peerStore: this._peerStore,
226
+ });
227
+ // Publish contracts to registry doc (non-fatal on failure)
228
+ await this._publishContracts();
229
+ await this.health.start();
230
+ this._running = true;
231
+ this._printBanner();
232
+ // Always log startup info (visible even when stderr is not a TTY)
233
+ const serviceNames = this._serviceSummaries.map(s => s.name).join(', ');
234
+ this.logger.info(`server starting runtime=typescript services=[${serviceNames}] mode=${this._allowAllConsumers ? 'open-gate' : 'trusted'}`);
235
+ }
236
+ /**
237
+ * Create a registry doc and publish each service's contract.
238
+ *
239
+ * After publication, `_registryNamespace` is set to the 64-char hex
240
+ * namespace ID so the admission response can return it.
241
+ *
242
+ * Non-fatal: if publication fails, the server still works — consumers
243
+ * just won't get rich contract metadata.
244
+ */
245
+ async _publishContracts() {
246
+ try {
247
+ const dc = this._node.docsClient();
248
+ const bc = this._node.blobsClient();
249
+ // Step 1: Create registry doc and author
250
+ const registryDoc = await dc.create();
251
+ const authorId = await dc.createAuthor();
252
+ // Step 2-10: For each service, build contract and publish
253
+ for (const info of this.registry.getAllServices()) {
254
+ // Build ServiceContract from service info
255
+ const contract = fromServiceInfo(info);
256
+ const contractId = contractIdFromContract(contract);
257
+ // Build manifest with method field descriptors
258
+ const manifest = this._buildManifest(info, contractId);
259
+ const canonicalBytes = canonicalXlangBytes(contract);
260
+ // Build collection and upload to blob store
261
+ const { buildCollection: build } = await import('./contract/publication.js');
262
+ const entries = build(manifest, canonicalBytes);
263
+ const collectionHash = await bc.addCollection(entries.map(([name, data]) => [name, Buffer.from(data)]));
264
+ const ticket = bc.createCollectionTicket(collectionHash);
265
+ // Write ArtifactRef to registry doc
266
+ const { contractKey, versionKey } = await import('./registry/keys.js');
267
+ const artifactRef = {
268
+ contract_id: contractId,
269
+ collection_hash: collectionHash,
270
+ ticket,
271
+ published_by: authorId,
272
+ published_at_epoch_ms: Date.now(),
273
+ collection_format: 'index',
274
+ };
275
+ const encoder = new TextEncoder();
276
+ await registryDoc.setBytes(authorId, contractKey(contractId), Buffer.from(encoder.encode(JSON.stringify(artifactRef))));
277
+ // Write manifest shortcut (avoids blob download round-trip)
278
+ const { manifestToJson } = await import('./contract/manifest.js');
279
+ await registryDoc.setBytes(authorId, `manifests/${contractId}`, Buffer.from(encoder.encode(manifestToJson(manifest))));
280
+ // Write version pointer
281
+ await registryDoc.setBytes(authorId, versionKey(info.name, info.version), Buffer.from(encoder.encode(contractId)));
282
+ // Update service summary with contract ID
283
+ const summary = this._serviceSummaries.find(s => s.name === info.name);
284
+ if (summary)
285
+ summary.contractId = contractId;
286
+ this.logger.debug(`published contract ${contractId.slice(0, 12)} for ${info.name} v${info.version}`);
287
+ }
288
+ // Share registry doc (read-only) and store namespace ID
289
+ await registryDoc.shareWithAddr('read');
290
+ this._registryNamespace = registryDoc.docId();
291
+ this.logger.info(`registry doc ready — namespace=${this._registryNamespace.slice(0, 16)}… ` +
292
+ `services=${this.registry.getAllServices().length}`);
293
+ }
294
+ catch (err) {
295
+ // Non-fatal: server still works, but cross-language peers will see
296
+ // empty method tables. Log loudly so this isn't silently swallowed --
297
+ // every cross-language interop test depends on this code path.
298
+ const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err);
299
+ this.logger.error(`contract publication failed (non-fatal): cross-language clients ` +
300
+ `will see empty method tables. Cause: ${msg}`);
301
+ }
302
+ }
303
+ /**
304
+ * Build a ContractManifest from a ServiceInfo with field-level detail.
305
+ */
306
+ _buildManifest(info, contractId) {
307
+ const WIRE_TYPE_KEY = Symbol.for('aster.wire_type');
308
+ const methods = [];
309
+ /** Extract a field list by instantiating a constructor and reading keys. */
310
+ const extractFields = (Ctor) => {
311
+ if (!Ctor || typeof Ctor !== 'function')
312
+ return [];
313
+ try {
314
+ const inst = new Ctor();
315
+ const out = [];
316
+ for (const key of Object.keys(inst)) {
317
+ const val = inst[key];
318
+ let fieldType = 'str';
319
+ if (typeof val === 'number')
320
+ fieldType = Number.isInteger(val) ? 'int' : 'float';
321
+ else if (typeof val === 'boolean')
322
+ fieldType = 'bool';
323
+ else if (Array.isArray(val))
324
+ fieldType = 'list';
325
+ else if (val && typeof val === 'object')
326
+ fieldType = 'dict';
327
+ // Capture the field default so cross-language codegen can emit
328
+ // it. JSON-safe primitives only -- nested objects round-trip via
329
+ // JSON.stringify which would lose Date/Map/Set semantics.
330
+ let defaultValue = undefined;
331
+ if (val === null ||
332
+ typeof val === 'string' ||
333
+ typeof val === 'number' ||
334
+ typeof val === 'boolean') {
335
+ defaultValue = val;
336
+ }
337
+ else if (Array.isArray(val)) {
338
+ defaultValue = [];
339
+ }
340
+ else if (val && typeof val === 'object') {
341
+ defaultValue = {};
342
+ }
343
+ out.push({
344
+ name: key,
345
+ type: fieldType,
346
+ required: val === '' || val === 0 || val === false,
347
+ default: defaultValue,
348
+ });
349
+ }
350
+ return out;
351
+ }
352
+ catch {
353
+ return [];
354
+ }
355
+ };
356
+ const wireTagOf = (Ctor) => {
357
+ if (!Ctor || typeof Ctor !== 'function')
358
+ return undefined;
359
+ return Ctor[WIRE_TYPE_KEY];
360
+ };
361
+ const displayNameOf = (Ctor) => {
362
+ if (!Ctor || typeof Ctor !== 'function')
363
+ return '';
364
+ return Ctor.name ?? '';
365
+ };
366
+ for (const [methodName, mi] of info.methods.entries()) {
367
+ // RpcPattern is a string enum -- 'unary' / 'server_stream' /
368
+ // 'client_stream' / 'bidi_stream'. The previous integer comparison
369
+ // (mi.pattern === 1, etc.) silently fell through to 'unary' for
370
+ // every method, so the published manifest mislabeled @ServerStream
371
+ // and @BidiStream as unary. The server still dispatched correctly
372
+ // from the in-memory MethodInfo, but any client that consumed the
373
+ // manifest -- including the shell and `aster contract gen-client` --
374
+ // would call them as unary and crash on the second response frame.
375
+ const patternStr = mi.pattern === RpcPattern.SERVER_STREAM ? 'server_stream' :
376
+ mi.pattern === RpcPattern.CLIENT_STREAM ? 'client_stream' :
377
+ mi.pattern === RpcPattern.BIDI_STREAM ? 'bidi_stream' : 'unary';
378
+ const reqType = mi.requestType;
379
+ const respType = mi.responseType;
380
+ // TypeScript erases parameter types at runtime, so the only way to
381
+ // know what request/response classes a method takes is for the user
382
+ // to pass them explicitly via @Rpc({ request: T, response: U }).
383
+ // When a method is decorated as `@Rpc()` (or any options object that
384
+ // doesn't include `request:` / `response:`), the published manifest
385
+ // has empty fields and broken wire tags -- which silently breaks
386
+ // gen-client for cross-language consumers and breaks the shell's
387
+ // method discovery for native consumers. Warn loudly at server start
388
+ // so the failure mode is visible without making the decorator
389
+ // hard-fail (which would break unit tests of decorator metadata
390
+ // collection that don't actually go through the manifest publish
391
+ // path).
392
+ if (!reqType || !respType) {
393
+ const decoratorByPattern = {
394
+ unary: 'Rpc',
395
+ server_stream: 'ServerStream',
396
+ client_stream: 'ClientStream',
397
+ bidi_stream: 'BidiStream',
398
+ };
399
+ const decorator = decoratorByPattern[patternStr] ?? 'Rpc';
400
+ const missing = [];
401
+ if (!reqType)
402
+ missing.push('request');
403
+ if (!respType)
404
+ missing.push('response');
405
+ this.logger.warn(`${info.name}.${methodName}: @${decorator} is missing ` +
406
+ `${missing.join(' and ')} type(s). The published manifest will ` +
407
+ `have empty fields, which breaks cross-language gen-client and ` +
408
+ `the shell's method discovery. Pass the constructors explicitly: ` +
409
+ `@${decorator}({ request: SomeRequest, response: SomeResponse })`);
410
+ }
411
+ methods.push({
412
+ name: methodName,
413
+ pattern: patternStr,
414
+ // requestType / responseType carry the human-readable display name
415
+ // for the codegen (matches Python manifest layout). The wire tag
416
+ // travels separately so codegen can register the type with Fory.
417
+ requestType: displayNameOf(reqType),
418
+ responseType: displayNameOf(respType),
419
+ requestWireTag: wireTagOf(reqType),
420
+ responseWireTag: wireTagOf(respType),
421
+ timeout: mi.timeout ?? 0,
422
+ idempotent: mi.idempotent ?? false,
423
+ fields: extractFields(reqType),
424
+ responseFields: extractFields(respType),
425
+ });
426
+ }
427
+ return {
428
+ service: info.name,
429
+ version: info.version,
430
+ contractId,
431
+ canonicalEncoding: 'fory-xlang/0.15',
432
+ typeCount: 0,
433
+ typeHashes: [],
434
+ methodCount: methods.length,
435
+ methods,
436
+ // The TypeScript binding only speaks JSON on the wire — Fory JS is not
437
+ // yet XLANG-compliant. Cross-language consumers reading the manifest
438
+ // must use SerializationMode.JSON (3) for any call to this server.
439
+ serializationModes: ['json'],
440
+ scoped: (info.scoped === RpcScope.SESSION || info.scoped === 'stream') ? RpcScope.SESSION : RpcScope.SHARED,
441
+ deprecated: false,
442
+ };
443
+ }
444
+ /**
445
+ * Start accepting connections. Blocks until close() is called.
446
+ */
447
+ async serve() {
448
+ if (!this._node || !this._rpcServer) {
449
+ throw new Error('AsterServer.serve() called before start()');
450
+ }
451
+ // Install signal handlers for graceful shutdown
452
+ this._installSignalHandlers();
453
+ // Enable RPC server connection handling
454
+ this._rpcServer.setServing(true);
455
+ this._peerStore.startReaper();
456
+ // Spawn the Gate 0 hook loop if hooks are enabled. This polls
457
+ // before_connect events from iroh and applies the MeshEndpointHook's
458
+ // allow/deny decisions. Without this, the admitted-set is never
459
+ // consulted and every connection passes the QUIC handshake.
460
+ if (this._node.hasHooks?.()) {
461
+ void this._runGate0().catch(e => {
462
+ this.logger.error('gate0 hook loop failed', { error: String(e) });
463
+ });
464
+ }
465
+ // Single accept loop with ALPN routing (matches Python's _accept_loop)
466
+ this._servePromise = this._acceptLoop();
467
+ try {
468
+ await this._servePromise;
469
+ }
470
+ catch (e) {
471
+ if (!this._closed)
472
+ throw e;
473
+ }
474
+ }
475
+ /**
476
+ * Run the Gate 0 hook loop.
477
+ *
478
+ * Polls after_handshake events from the native receiver and dispatches
479
+ * each one through MeshEndpointHook.shouldAllow(). After-handshake fires
480
+ * for **all** connections (inbound and outbound) right after TLS, which
481
+ * is exactly when we want to enforce the admitted-set check.
482
+ *
483
+ * NOTE: We do NOT use before_connect — that fires only for *outgoing*
484
+ * connections in iroh, so it would miss incoming RPC connections to
485
+ * the server (which is what we need to gate).
486
+ *
487
+ * The native binding uses an event-id + respond-by-id API; this loop
488
+ * polls events directly and responds via respondAfterHandshake().
489
+ */
490
+ async _runGate0() {
491
+ if (!this._node)
492
+ return;
493
+ const receiver = this._node.takeHookReceiver();
494
+ if (receiver == null) {
495
+ this.logger.warn('Gate 0: hooks enabled but no receiver available');
496
+ return;
497
+ }
498
+ this.logger.debug('Gate 0: hook loop started');
499
+ try {
500
+ while (true) {
501
+ const event = await receiver.recvAfterHandshake();
502
+ if (event == null) {
503
+ this.logger.debug('Gate 0: receiver closed');
504
+ return;
505
+ }
506
+ const alpnBytes = new Uint8Array(event.info.alpn);
507
+ const peerId = event.info.remoteEndpointId;
508
+ const allow = this._hook.shouldAllow(peerId, alpnBytes);
509
+ if (allow) {
510
+ receiver.respondHandshake(event.eventId, true, undefined, undefined);
511
+ }
512
+ else {
513
+ this.logger.info(`Gate 0 denied ${peerId.slice(0, 12)} on alpn=${new TextDecoder().decode(alpnBytes)}`);
514
+ receiver.respondHandshake(event.eventId, false, 403, 'not admitted');
515
+ }
516
+ }
517
+ }
518
+ catch (e) {
519
+ this.logger.error('Gate 0 hook loop error', { error: String(e) });
520
+ }
521
+ }
522
+ /**
523
+ * Stop accepting connections and close the node.
524
+ */
525
+ async close() {
526
+ if (this._closed)
527
+ return;
528
+ this._closed = true;
529
+ this._running = false;
530
+ // Remove signal handlers
531
+ for (const cleanup of this._signalHandlers)
532
+ cleanup();
533
+ this._signalHandlers = [];
534
+ if (this._admissionAbort) {
535
+ this._admissionAbort.abort();
536
+ }
537
+ if (this._rpcServer) {
538
+ await this._rpcServer.close();
539
+ }
540
+ await this.health.stop();
541
+ if (this._node) {
542
+ try {
543
+ await this._node.close();
544
+ }
545
+ catch { /* ignore */ }
546
+ }
547
+ this.logger.info('AsterServer stopped');
548
+ }
549
+ /** The aster1... connection address for clients. */
550
+ get address() {
551
+ if (!this._node)
552
+ throw new Error('Server not started');
553
+ try {
554
+ const native = loadNativeSync();
555
+ if (native) {
556
+ const info = {
557
+ endpointId: this._node.nodeId(),
558
+ relayAddr: undefined,
559
+ directAddrs: [],
560
+ };
561
+ return native.asterTicketToString(info);
562
+ }
563
+ }
564
+ catch { /* fallback */ }
565
+ return this._node.nodeId();
566
+ }
567
+ /** Hex endpoint ID of this server's node. */
568
+ get endpointId() {
569
+ if (!this._node)
570
+ throw new Error('Server not started');
571
+ return this._node.nodeId();
572
+ }
573
+ /** Whether the server is running. */
574
+ get running() { return this._running; }
575
+ /** List of services hosted by this server. */
576
+ get services() { return [...this._serviceSummaries]; }
577
+ /** Create a local in-process transport for testing. */
578
+ localTransport() {
579
+ return new LocalTransport(this.registry);
580
+ }
581
+ // ── Admission loop ──────────────────────────────────────────────────────
582
+ /**
583
+ * Single accept loop with ALPN-based routing.
584
+ *
585
+ * All aster ALPNs (RPC, consumer admission, delegated admission) are
586
+ * multiplexed through one channel. This loop reads the ALPN tag and
587
+ * dispatches to the correct handler — matching Python's `_accept_loop`.
588
+ */
589
+ async _acceptLoop() {
590
+ if (!this._node)
591
+ return;
592
+ while (this._running && !this._closed) {
593
+ try {
594
+ // Accept next connection — ALPN tag is on the connection
595
+ const conn = await this._node.acceptAster();
596
+ const alpn = conn.alpn() ?? '';
597
+ if (alpn === RPC_ALPN) {
598
+ // RPC connection — dispatch to RpcServer
599
+ this._rpcServer.handleConnection(conn).catch(e => {
600
+ this.logger.error('rpc connection error', { error: String(e) });
601
+ });
602
+ }
603
+ else if (alpn === ALPN_CONSUMER_ADMISSION) {
604
+ // Consumer admission
605
+ this._handleAdmission(conn).catch(e => {
606
+ this.logger.error('admission error', { error: String(e) });
607
+ });
608
+ }
609
+ else if (alpn === ALPN_PRODUCER_ADMISSION) {
610
+ // Producer-to-producer mesh admission
611
+ this._handleProducerAdmission(conn).catch(e => {
612
+ this.logger.error('producer admission error', { error: String(e) });
613
+ });
614
+ }
615
+ else if (alpn === ALPN_DELEGATED_ADMISSION) {
616
+ // Delegated admission
617
+ this._handleDelegatedAdmission(conn).catch(e => {
618
+ this.logger.error('delegated admission error', { error: String(e) });
619
+ });
620
+ }
621
+ else {
622
+ this.logger.warn(`unknown ALPN: ${alpn}`);
623
+ try {
624
+ conn.close(400, 'unknown ALPN');
625
+ }
626
+ catch { /* ignore */ }
627
+ }
628
+ }
629
+ catch (e) {
630
+ if (this._closed)
631
+ return;
632
+ this.logger.error('accept error', { error: String(e) });
633
+ }
634
+ }
635
+ }
636
+ async _handleAdmission(conn) {
637
+ // Adapt NAPI connection to the interface handleConsumerAdmissionConnection expects
638
+ const adapted = {
639
+ acceptBi: () => conn.acceptBi(),
640
+ remoteId: () => conn.remoteNodeId?.() ?? conn.remoteId?.() ?? 'unknown',
641
+ };
642
+ const opts = {
643
+ services: this._serviceSummaries,
644
+ registryNamespace: this._registryNamespace || undefined,
645
+ allowUnenrolled: this._allowAllConsumers,
646
+ peerStore: this._peerStore,
647
+ logger: this.logger,
648
+ };
649
+ const rootKeyHex = this._resolveRootPubkeyHex();
650
+ await handleConsumerAdmissionConnection(adapted, rootKeyHex, this._hook, opts);
651
+ }
652
+ // ── Producer admission ───────────────────────────────────────────────────
653
+ async _handleProducerAdmission(conn) {
654
+ // Producer admission requires root pubkey and mesh state.
655
+ // If not configured (open mode), reject gracefully.
656
+ if (!this.config.rootPubkey && !this.config.rootPubkeyFile) {
657
+ this.logger.warn('producer admission: no root pubkey configured, ignoring');
658
+ return;
659
+ }
660
+ const { handleProducerAdmissionConnection } = await import('./trust/bootstrap.js');
661
+ const rootKeyHex = this._resolveRootPubkeyHex();
662
+ const { MeshState } = await import('./trust/mesh.js');
663
+ const meshState = new MeshState();
664
+ await handleProducerAdmissionConnection(conn, rootKeyHex, meshState);
665
+ }
666
+ _resolveRootPubkeyHex() {
667
+ if (this.config.rootPubkey)
668
+ return Buffer.from(this.config.rootPubkey).toString('hex');
669
+ if (this.config.rootPubkeyFile) {
670
+ try {
671
+ const { readFileSync } = require('node:fs');
672
+ const expanded = this.config.rootPubkeyFile.replace(/^~/, process.env.HOME ?? '');
673
+ const raw = readFileSync(expanded, 'utf-8').trim();
674
+ if (raw.startsWith('{')) {
675
+ try {
676
+ return JSON.parse(raw).public_key;
677
+ }
678
+ catch { /* fall through */ }
679
+ }
680
+ return raw;
681
+ }
682
+ catch {
683
+ return '';
684
+ }
685
+ }
686
+ return '';
687
+ }
688
+ // ── Delegated admission ─────────────────────────────────────────────────
689
+ async _handleDelegatedAdmission(conn) {
690
+ const policy = this._delegationPolicies.values().next().value;
691
+ if (!policy)
692
+ return;
693
+ await handleDelegatedAdmissionConnection(conn, { policy, hook: this._hook, peerStore: this._peerStore });
694
+ }
695
+ // ── Signal handling ─────────────────────────────────────────────────────
696
+ _installSignalHandlers() {
697
+ if (typeof process === 'undefined')
698
+ return;
699
+ const shutdown = async () => {
700
+ this.logger.info('Server shutting down...');
701
+ await this.close();
702
+ };
703
+ const handler = () => { shutdown(); };
704
+ process.on('SIGTERM', handler);
705
+ process.on('SIGINT', handler);
706
+ this._signalHandlers.push(() => { process.off('SIGTERM', handler); }, () => { process.off('SIGINT', handler); });
707
+ }
708
+ // ── Banner ──────────────────────────────────────────────────────────────
709
+ _printBanner() {
710
+ if (typeof process !== 'undefined' && !process.stderr?.isTTY)
711
+ return;
712
+ const C = '\x1b[36m';
713
+ const B = '\x1b[1m';
714
+ const D = '\x1b[2m';
715
+ const G = '\x1b[32m';
716
+ const Y = '\x1b[33m';
717
+ const W = '\x1b[37m';
718
+ const R = '\x1b[0m';
719
+ const w = (s) => process.stderr.write(s);
720
+ w(`\n${C}${B}`);
721
+ w(` _ ____ _____ _____ ____\n`);
722
+ w(` / \\ / ___|_ _| ____| _ \\\n`);
723
+ w(` / _ \\ \\___ \\ | | | _| | |_) |\n`);
724
+ w(` / ___ \\ ___) || | | |___| _ <\n`);
725
+ w(` /_/ \\_\\____/ |_| |_____|_| \\_\\\n`);
726
+ w(`${R}\n`);
727
+ w(` ${D}RPC after hostnames.${R}\n\n`);
728
+ // Services table
729
+ if (this._serviceSummaries.length > 0) {
730
+ const maxName = Math.max(...this._serviceSummaries.map(s => s.name.length));
731
+ for (const s of this._serviceSummaries) {
732
+ const name = s.name.padEnd(maxName);
733
+ w(` ${G}\u25cf${R} ${B}${name}${R} ${D}v${s.version}${R}\n`);
734
+ }
735
+ w('\n');
736
+ }
737
+ // Endpoint
738
+ try {
739
+ const nodeId = this._node?.nodeId?.();
740
+ if (nodeId) {
741
+ const short = nodeId.slice(0, 16) + '\u2026';
742
+ w(` ${D}node id:${R} ${W}${short}${R} ${D}(this node's keypair fingerprint)${R}\n`);
743
+ }
744
+ w(` ${D}endpoint:${R} ${this.address}\n`);
745
+ }
746
+ catch { /* not started yet */ }
747
+ // Mode
748
+ const mode = this._allowAllConsumers ? `${Y}open-gate${R}` : `${G}trusted${R}`;
749
+ w(` ${D}mode:${R} ${mode}\n`);
750
+ // Log
751
+ const logFormat = this.config.logFormat || 'text';
752
+ const logLevel = this.config.logLevel || 'info';
753
+ w(` ${D}log:${R} ASTER_LOG_FORMAT=${W}${logFormat}${R} ASTER_LOG_LEVEL=${W}${logLevel}${R}\n`);
754
+ // Runtime
755
+ w(` ${D}runtime:${R} aster-rpc (typescript) iroh 0.97\n`);
756
+ // Copyright
757
+ w(`\n ${D}Copyright \u00a9 2026 Emrul Islam. All rights reserved.${R}\n\n`);
758
+ }
759
+ }
760
+ /**
761
+ * Read the first consumer (or named) peer entry from a .aster-identity TOML file
762
+ * without the synthesised attributes that loadIdentity adds.
763
+ */
764
+ function loadRawConsumerPeer(filePath, peerName) {
765
+ const { existsSync, readFileSync } = require('node:fs');
766
+ const { join } = require('node:path');
767
+ const path = filePath ?? join(process.cwd(), '.aster-identity');
768
+ if (!existsSync(path))
769
+ return null;
770
+ try {
771
+ const data = parseSimpleToml(readFileSync(path, 'utf-8'));
772
+ const peers = (data.peers ?? []);
773
+ if (peerName) {
774
+ return peers.find(p => p.name === peerName) ?? null;
775
+ }
776
+ return peers.find(p => p.role === 'consumer') ?? peers[0] ?? null;
777
+ }
778
+ catch {
779
+ return null;
780
+ }
781
+ }
782
+ /**
783
+ * Build a ConsumerEnrollmentCredential from a [[peers]] entry in .aster-identity.
784
+ * Mirrors Python's `_credential_from_peer_entry`.
785
+ */
786
+ function credentialFromPeerEntry(peer) {
787
+ return {
788
+ credentialType: (peer.type ?? 'policy'),
789
+ rootPubkey: peer.root_pubkey,
790
+ expiresAt: Number(peer.expires_at),
791
+ attributes: (peer.attributes ?? {}),
792
+ endpointId: peer.endpoint_id || undefined,
793
+ nonce: peer.nonce || undefined,
794
+ signature: peer.signature ?? '',
795
+ };
796
+ }
797
+ /**
798
+ * Load a pre-signed ConsumerEnrollmentCredential from a credential file.
799
+ *
800
+ * Accepts both formats produced by `aster enroll node`:
801
+ *
802
+ * 1. **TOML** (the actual `.cred` / `.aster-identity` format produced by
803
+ * the CLI today): a `[node]` section with the consumer's secret key,
804
+ * plus one or more `[[peers]]` sections each holding a signed
805
+ * enrollment credential. The first consumer-role peer is used.
806
+ * 2. **JSON** (legacy / hand-rolled credential dumps): a flat object
807
+ * with credential_type / root_pubkey / expires_at / attributes /
808
+ * endpoint_id / nonce / signature.
809
+ *
810
+ * Format detection peeks at the first non-whitespace character: `{`
811
+ * means JSON, anything else means TOML. The TOML path goes through the
812
+ * existing identity-loader helpers so that `enrollmentCredentialFile:`
813
+ * and `identity:` end up doing the same thing for the same file.
814
+ */
815
+ function loadEnrollmentCredential(filePath) {
816
+ const { readFileSync } = require('node:fs');
817
+ const { homedir } = require('node:os');
818
+ const expanded = filePath.startsWith('~')
819
+ ? filePath.replace(/^~/, homedir())
820
+ : filePath;
821
+ const text = readFileSync(expanded, 'utf-8');
822
+ const firstChar = text.replace(/^\s+/, '').charAt(0);
823
+ if (firstChar === '{') {
824
+ // JSON path
825
+ const d = JSON.parse(text);
826
+ return {
827
+ credentialType: (d.credential_type ?? d.type ?? 'policy'),
828
+ rootPubkey: d.root_pubkey,
829
+ expiresAt: Number(d.expires_at),
830
+ attributes: d.attributes ?? {},
831
+ endpointId: d.endpoint_id || undefined,
832
+ nonce: d.nonce || undefined,
833
+ signature: d.signature ?? '',
834
+ };
835
+ }
836
+ // TOML path -- reuse the identity helpers so behaviour matches `identity:`
837
+ const data = parseSimpleToml(text);
838
+ const peers = (data.peers ?? []);
839
+ const consumerPeer = peers.find(p => p.role === 'consumer') ?? peers[0];
840
+ if (!consumerPeer) {
841
+ throw new Error(`loadEnrollmentCredential(${filePath}): no [[peers]] entry found in ` +
842
+ `the TOML credential file. Did you run \`aster enroll node --role consumer\`?`);
843
+ }
844
+ return credentialFromPeerEntry(consumerPeer);
845
+ }
846
+ /**
847
+ * High-level Aster RPC client.
848
+ *
849
+ * Wraps connection setup, admission, and client stub creation.
850
+ * Supports reconnection with exponential backoff.
851
+ */
852
+ export class AsterClientWrapper {
853
+ transport;
854
+ config;
855
+ backoff;
856
+ _connected = false;
857
+ _gossipTopic = '';
858
+ _address;
859
+ _node = null;
860
+ _services = [];
861
+ _registryNamespace = '';
862
+ _inlineCredential = null;
863
+ _enrollmentCredentialFile;
864
+ _identitySecretKey = null;
865
+ constructor(opts) {
866
+ this.config = { ...configFromEnv(), ...opts.config };
867
+ if (opts.identity) {
868
+ this.config.identityFile = opts.identity;
869
+ }
870
+ this._address = opts.address ?? opts.endpointAddr;
871
+ this.backoff = opts.retryBackoff ?? DEFAULT_BACKOFF;
872
+ // Load identity file (.aster-identity) if present. The first consumer-role
873
+ // peer entry IS the credential — mirrors Python AsterClient behaviour.
874
+ // The node secret_key is also pulled out so the client's QUIC endpoint id
875
+ // matches the endpoint_id baked into the credential.
876
+ //
877
+ // We use loadIdentity for the secret key but parse the TOML raw for the
878
+ // peer entry — loadIdentity synthesizes `aster.name` into attributes,
879
+ // which would corrupt the signed-attribute set on the credential.
880
+ const identity = loadIdentity(this.config.identityFile, opts.peer, 'consumer');
881
+ if (identity) {
882
+ this._identitySecretKey = identity.secretKey;
883
+ if (!opts.enrollmentCredentialFile) {
884
+ const rawPeer = loadRawConsumerPeer(this.config.identityFile, opts.peer);
885
+ if (rawPeer) {
886
+ this._inlineCredential = credentialFromPeerEntry(rawPeer);
887
+ }
888
+ }
889
+ }
890
+ this._enrollmentCredentialFile =
891
+ opts.enrollmentCredentialFile ?? this.config.enrollmentCredentialFile;
892
+ // If the user only passed `enrollmentCredentialFile` (no separate
893
+ // `identity:`) and that file is a TOML `.aster-identity` produced by
894
+ // `aster enroll node`, reach into the same file for the [node]
895
+ // secret_key. Otherwise the QUIC endpoint id we generate at startup
896
+ // won't match the credential's `endpoint_id` and admission fails
897
+ // with no useful error.
898
+ //
899
+ // Both `enrollmentCredentialFile:` and `identity:` should do the
900
+ // same thing for the same TOML file -- mirrors the matching fix on
901
+ // the Python AsterClient.
902
+ if (!this._identitySecretKey
903
+ && this._enrollmentCredentialFile
904
+ && !identity) {
905
+ const credIdentity = loadIdentity(this._enrollmentCredentialFile, opts.peer, 'consumer');
906
+ if (credIdentity) {
907
+ this._identitySecretKey = credIdentity.secretKey;
908
+ }
909
+ }
910
+ if (opts.transport) {
911
+ this.transport = opts.transport;
912
+ this._connected = true;
913
+ }
914
+ }
915
+ /** Whether the client is connected. */
916
+ get connected() {
917
+ return this._connected;
918
+ }
919
+ /** Services discovered during admission. */
920
+ get services() { return [...this._services]; }
921
+ /** Registry namespace ID for service discovery (set after admission). */
922
+ get registryNamespace() { return this._registryNamespace || undefined; }
923
+ /** Hex-encoded 32-byte gossip topic ID for the producer mesh. */
924
+ get gossipTopic() { return this._gossipTopic; }
925
+ /**
926
+ * Connect to the server via consumer admission, then open an RPC transport.
927
+ *
928
+ * If the client was created with a transport, this is a no-op.
929
+ * If created with an address, it performs the full admission handshake.
930
+ */
931
+ async connect() {
932
+ if (this._connected)
933
+ return;
934
+ if (!this._address) {
935
+ throw new Error('AsterClient requires an address or transport. ' +
936
+ 'Pass address="aster1..." or transport=new IrohTransport(conn).');
937
+ }
938
+ const native = await loadNative();
939
+ if (!native) {
940
+ throw new Error('Aster native addon not found.');
941
+ }
942
+ // Create an in-memory client node. When an identity file provided a
943
+ // secret key, pass it through so the client's endpoint id matches the
944
+ // credential's enrolled endpoint_id.
945
+ if (this._identitySecretKey) {
946
+ this._node = await native.IrohNode.memoryWithAlpns([Buffer.from(ALPN_CONSUMER_ADMISSION), Buffer.from(RPC_ALPN)], { secretKey: Buffer.from(this._identitySecretKey) });
947
+ }
948
+ else {
949
+ this._node = await native.IrohNode.memory();
950
+ }
951
+ // Parse the address to get the endpoint ID and optional address hints.
952
+ //
953
+ // The ticket's `relayAddr` is a SocketAddr (STUN-discovered public IP),
954
+ // NOT an iroh RelayUrl. Treat it as another direct addr — iroh will use
955
+ // any reachable transport address it can.
956
+ let endpointId;
957
+ let allDirectAddrs = [];
958
+ if (this._address.startsWith('aster1')) {
959
+ let parsed;
960
+ try {
961
+ parsed = native.asterTicketFromString(this._address);
962
+ }
963
+ catch {
964
+ parsed = native.asterTicketDecode(Buffer.from(this._address));
965
+ }
966
+ endpointId = parsed.endpointId;
967
+ if (parsed.directAddrs?.length)
968
+ allDirectAddrs.push(...parsed.directAddrs);
969
+ if (parsed.relayAddr)
970
+ allDirectAddrs.push(parsed.relayAddr);
971
+ }
972
+ else {
973
+ // Treat as raw hex endpoint ID
974
+ endpointId = this._address;
975
+ }
976
+ const directAddrs = allDirectAddrs.length ? allDirectAddrs : undefined;
977
+ // Helper: connect using full address info when available, else bare endpoint ID
978
+ const doConnect = (alpn) => {
979
+ if (directAddrs) {
980
+ // No relay URL — iroh's connect_node_addr handles direct addresses
981
+ return this._node.connectNodeAddr(endpointId, alpn, directAddrs, undefined);
982
+ }
983
+ return this._node.connect(endpointId, alpn);
984
+ };
985
+ // Build credential: inline peer entry > credential file > null (open-gate).
986
+ let credential = this._inlineCredential;
987
+ let credentialFileLabel = null;
988
+ if (credential) {
989
+ credentialFileLabel = '<inline .aster-identity peer entry>';
990
+ }
991
+ else if (this._enrollmentCredentialFile) {
992
+ credential = loadEnrollmentCredential(this._enrollmentCredentialFile);
993
+ credentialFileLabel = this._enrollmentCredentialFile;
994
+ }
995
+ // Consumer admission
996
+ const admissionConn = await doConnect(Buffer.from(ALPN_CONSUMER_ADMISSION));
997
+ const admissionResponse = await performAdmission(admissionConn, credential);
998
+ if (!admissionResponse.admitted) {
999
+ let ourEndpointId = '';
1000
+ try {
1001
+ ourEndpointId = this._node?.nodeId?.() ?? '';
1002
+ }
1003
+ catch { /* ignore */ }
1004
+ throw new AdmissionDeniedError({
1005
+ hadCredential: credential != null,
1006
+ credentialFile: credentialFileLabel,
1007
+ ourEndpointId,
1008
+ serverAddress: this._address ?? '',
1009
+ });
1010
+ }
1011
+ this._services = admissionResponse.services ?? [];
1012
+ this._registryNamespace = admissionResponse.registryNamespace ?? '';
1013
+ this._gossipTopic = admissionResponse.gossipTopic ?? '';
1014
+ // Open RPC connection and create transport
1015
+ const rpcConn = await doConnect(Buffer.from(RPC_ALPN));
1016
+ const { IrohTransport: IrohTx } = await import('./transport/iroh.js');
1017
+ this.transport = new IrohTx(rpcConn);
1018
+ this._connected = true;
1019
+ }
1020
+ /** Create a typed client proxy for a service class. */
1021
+ async client(serviceClass) {
1022
+ return createClient(serviceClass, this.transport);
1023
+ }
1024
+ /** Create a typed client proxy for a service class. */
1025
+ service(serviceClass) {
1026
+ return createClient(serviceClass, this.transport);
1027
+ }
1028
+ /**
1029
+ * Create a dynamic proxy client for a service.
1030
+ *
1031
+ * @example
1032
+ * ```ts
1033
+ * const mc = client.proxy("MissionControl");
1034
+ * const result = await mc.getStatus({ agentId: "edge-1" });
1035
+ * console.log(result.status);
1036
+ * ```
1037
+ */
1038
+ proxy(serviceName) {
1039
+ // Check if this is a session-scoped service from the admission summary.
1040
+ // If so, return a SessionProxyClient that multiplexes calls over a
1041
+ // single bidi stream using the session protocol.
1042
+ const summary = this._services.find(s => s.name === serviceName);
1043
+ if (summary && (summary.pattern === 'session' || summary.pattern === 'stream')) {
1044
+ return new SessionProxyClient(serviceName, this._node, this.transport);
1045
+ }
1046
+ return new ProxyClient(serviceName, this.transport);
1047
+ }
1048
+ /** Reconnect with exponential backoff. */
1049
+ async reconnect(connectFn, maxAttempts = 5) {
1050
+ let delay = this.backoff.initialMs;
1051
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1052
+ try {
1053
+ this.transport = await connectFn();
1054
+ this._connected = true;
1055
+ return;
1056
+ }
1057
+ catch (e) {
1058
+ if (attempt === maxAttempts)
1059
+ throw e;
1060
+ const jitter = delay * this.backoff.jitter * (Math.random() * 2 - 1);
1061
+ const waitMs = Math.min(delay + jitter, this.backoff.maxMs);
1062
+ await new Promise(r => setTimeout(r, waitMs));
1063
+ delay = Math.min(delay * this.backoff.multiplier, this.backoff.maxMs);
1064
+ }
1065
+ }
1066
+ }
1067
+ /** Close the client and underlying transport. */
1068
+ async close() {
1069
+ this._connected = false;
1070
+ await this.transport.close();
1071
+ }
1072
+ }
1073
+ // ── ProxyClient ──────────────────────────────────────────────────────────────
1074
+ /**
1075
+ * Dynamic proxy client -- invokes RPC methods without local type definitions.
1076
+ *
1077
+ * Created via `AsterClientWrapper.proxy("ServiceName")`. Supports all four
1078
+ * RPC patterns:
1079
+ *
1080
+ * ```ts
1081
+ * const mc = client.proxy("MissionControl");
1082
+ *
1083
+ * // Unary
1084
+ * const status = await mc.getStatus({ agent_id: "edge-7" });
1085
+ *
1086
+ * // Client streaming — pass an async iterable
1087
+ * const result = await mc.ingestMetrics(asyncGenerator());
1088
+ *
1089
+ * // Server streaming — use .stream()
1090
+ * for await (const entry of mc.tailLogs.stream({ level: "info" })) { ... }
1091
+ *
1092
+ * // Bidi streaming — use .bidi()
1093
+ * const ch = mc.runCommand.bidi();
1094
+ * await ch.open();
1095
+ * await ch.send({ command: "ls" });
1096
+ * for await (const r of ch) { ... }
1097
+ * ```
1098
+ */
1099
+ export class ProxyClient {
1100
+ serviceName;
1101
+ transport;
1102
+ constructor(serviceName, transport) {
1103
+ this.serviceName = serviceName;
1104
+ this.transport = transport;
1105
+ return new Proxy(this, {
1106
+ get(target, prop) {
1107
+ if (prop in target || typeof prop === 'symbol') {
1108
+ return target[prop];
1109
+ }
1110
+ return _proxyMethod(target.serviceName, prop, target.transport);
1111
+ },
1112
+ });
1113
+ }
1114
+ }
1115
+ /** A bound proxy method supporting all RPC patterns. */
1116
+ function _proxyMethod(serviceName, methodName, transport) {
1117
+ // The callable: detects async iterables for client streaming, else unary
1118
+ const fn = async (payload) => {
1119
+ // Detect client streaming: payload is an async iterable (but not a plain object)
1120
+ if (payload != null && typeof payload === 'object' && Symbol.asyncIterator in payload) {
1121
+ return transport.clientStream(serviceName, methodName, payload);
1122
+ }
1123
+ // Default: unary. If the user accidentally calls a server-streaming
1124
+ // method via `await proxy.method(...)`, the underlying transport will
1125
+ // see a second response frame and throw with "multiple response
1126
+ // frames". Catch that and re-raise with an actionable hint pointing
1127
+ // at `proxy.method.stream(...)` / `.bidi()`.
1128
+ try {
1129
+ return await transport.unary(serviceName, methodName, payload ?? {});
1130
+ }
1131
+ catch (err) {
1132
+ const msg = err instanceof Error ? err.message : String(err);
1133
+ if (msg.includes('multiple response frames')) {
1134
+ throw new Error(`'${serviceName}.${methodName}' is a streaming RPC and cannot be ` +
1135
+ `called as a unary 'await proxy.${methodName}(...)'.\n` +
1136
+ ` - For server-streaming methods, iterate the result of ` +
1137
+ `'proxy.${methodName}.stream(...)':\n` +
1138
+ ` for await (const item of proxy.${methodName}.stream({...})) { ... }\n` +
1139
+ ` - For bidi-streaming methods, use 'proxy.${methodName}.bidi()'.`);
1140
+ }
1141
+ throw err;
1142
+ }
1143
+ };
1144
+ // .stream() — server streaming
1145
+ fn.stream = (payload) => {
1146
+ return transport.serverStream(serviceName, methodName, payload ?? {});
1147
+ };
1148
+ // .bidi() — bidirectional streaming. Returns a lazy wrapper so callers
1149
+ // can do `const ch = m.bidi(); await ch.open(); await ch.send(...)` —
1150
+ // mirrors Python's _ProxyBidiChannel behaviour. Opening eagerly here
1151
+ // would force every call site to handle the connect failure synchronously.
1152
+ fn.bidi = () => {
1153
+ return new ProxyBidiChannel(serviceName, methodName, transport);
1154
+ };
1155
+ return fn;
1156
+ }
1157
+ /**
1158
+ * Lazy bidi-stream wrapper used by ProxyClient. Opens the underlying
1159
+ * transport channel on first .open() / .send() / iteration.
1160
+ */
1161
+ export class ProxyBidiChannel {
1162
+ serviceName;
1163
+ methodName;
1164
+ transport;
1165
+ _channel = null;
1166
+ constructor(serviceName, methodName, transport) {
1167
+ this.serviceName = serviceName;
1168
+ this.methodName = methodName;
1169
+ this.transport = transport;
1170
+ }
1171
+ async open() {
1172
+ if (this._channel)
1173
+ return;
1174
+ this._channel = this.transport.bidiStream(this.serviceName, this.methodName);
1175
+ }
1176
+ async send(payload) {
1177
+ if (!this._channel)
1178
+ await this.open();
1179
+ await this._channel.send(payload);
1180
+ }
1181
+ async close() {
1182
+ if (this._channel)
1183
+ await this._channel.close();
1184
+ }
1185
+ [Symbol.asyncIterator]() {
1186
+ if (!this._channel) {
1187
+ // Open on the fly so `for await` after `await ch.send()` (which
1188
+ // already opened) and `for await` without an explicit open both work.
1189
+ this._channel = this.transport.bidiStream(this.serviceName, this.methodName);
1190
+ }
1191
+ return this._channel[Symbol.asyncIterator]();
1192
+ }
1193
+ }
1194
+ // ── SessionProxyClient ──────────────────────────────────────────────────────
1195
+ /**
1196
+ * Dynamic proxy client for session-scoped services. Opens a single bidi QUIC
1197
+ * stream with the session protocol (StreamHeader method="") and multiplexes
1198
+ * calls via CALL frames. Created automatically by AsterClientWrapper.proxy()
1199
+ * when the service summary indicates scoped='session'.
1200
+ */
1201
+ class SessionProxyClient {
1202
+ serviceName;
1203
+ _transport;
1204
+ _send = null;
1205
+ _recv = null;
1206
+ _codec = new JsonCodec();
1207
+ _opening = null;
1208
+ constructor(serviceName, _node, _transport) {
1209
+ this.serviceName = serviceName;
1210
+ this._transport = _transport;
1211
+ return new Proxy(this, {
1212
+ get(target, prop) {
1213
+ if (prop in target || typeof prop === 'symbol') {
1214
+ return target[prop];
1215
+ }
1216
+ return target._sessionMethod(prop);
1217
+ },
1218
+ });
1219
+ }
1220
+ _sessionMethod(methodName) {
1221
+ const self = this;
1222
+ const fn = async (payload) => {
1223
+ await self._ensureOpen();
1224
+ return self._callUnary(methodName, payload ?? {});
1225
+ };
1226
+ fn.stream = (_payload) => {
1227
+ throw new RpcError(StatusCode.UNIMPLEMENTED, 'session proxy server_stream not yet implemented');
1228
+ };
1229
+ fn.bidi = () => {
1230
+ throw new RpcError(StatusCode.UNIMPLEMENTED, 'session proxy bidi not yet implemented');
1231
+ };
1232
+ return fn;
1233
+ }
1234
+ async _ensureOpen() {
1235
+ if (this._send)
1236
+ return;
1237
+ if (this._opening)
1238
+ return this._opening;
1239
+ this._opening = this._open();
1240
+ return this._opening;
1241
+ }
1242
+ async _open() {
1243
+ // The IrohTransport wraps the connection. We need the raw connection
1244
+ // to open a bidi stream. Reach through the transport to get it.
1245
+ const conn = this._transport.conn ?? this._transport._conn;
1246
+ if (!conn)
1247
+ throw new RpcError(StatusCode.UNAVAILABLE, 'no QUIC connection for session');
1248
+ const bi = await conn.openBi();
1249
+ this._send = bi.takeSend();
1250
+ this._recv = bi.takeRecv();
1251
+ // Send the session StreamHeader (method="" signals session mode)
1252
+ const header = new StreamHeader({
1253
+ service: this.serviceName,
1254
+ method: '',
1255
+ version: 1,
1256
+ callId: crypto.randomUUID(),
1257
+ serializationMode: 3, // JSON
1258
+ });
1259
+ await writeFrame(this._send, this._codec.encode(header), HEADER);
1260
+ }
1261
+ async _callUnary(method, request) {
1262
+ // Send CALL frame with CallHeader
1263
+ const callHeader = new CallHeader({
1264
+ method,
1265
+ callId: crypto.randomUUID(),
1266
+ });
1267
+ await writeFrame(this._send, this._codec.encode(callHeader), CALL);
1268
+ // Send request payload
1269
+ await writeFrame(this._send, this._codec.encode(request), 0);
1270
+ // Read response
1271
+ const respFrame = await readFrame(this._recv, 0);
1272
+ if (!respFrame)
1273
+ throw new RpcError(StatusCode.UNAVAILABLE, 'session stream ended');
1274
+ const [respPayload, respFlags] = respFrame;
1275
+ if (respFlags & TRAILER) {
1276
+ const status = this._codec.decode(respPayload);
1277
+ throw RpcError.fromStatus(status.code, status.message);
1278
+ }
1279
+ // Spec: session unary has no success trailer. The response frame is
1280
+ // the complete response. Both Python and TS servers follow this rule.
1281
+ return this._codec.decode(respPayload);
1282
+ }
1283
+ /**
1284
+ * Close this session, releasing the underlying bidi stream.
1285
+ *
1286
+ * Must be a real method (not synthesised by the JS Proxy) so that
1287
+ * `proxy.close()` calls this implementation instead of routing
1288
+ * "close" through the session protocol as a remote method name.
1289
+ */
1290
+ async close() {
1291
+ const send = this._send;
1292
+ this._send = null;
1293
+ this._recv = null;
1294
+ if (send && typeof send.finish === 'function') {
1295
+ try {
1296
+ await send.finish();
1297
+ }
1298
+ catch {
1299
+ // Already closed / broken pipe -- best effort.
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+ // ── Native addon loader ──────────────────────────────────────────────────────
1305
+ let _native = null;
1306
+ function loadNativeSync() {
1307
+ if (_native)
1308
+ return _native;
1309
+ const { resolve } = require('node:path');
1310
+ const { existsSync } = require('node:fs');
1311
+ const platforms = [
1312
+ 'aster-transport.darwin-arm64.node',
1313
+ 'aster-transport.darwin-x64.node',
1314
+ 'aster-transport.linux-x64-gnu.node',
1315
+ 'aster-transport.linux-arm64-gnu.node',
1316
+ 'aster-transport.win32-x64-msvc.node',
1317
+ ];
1318
+ // 1. Try the workspace package
1319
+ try {
1320
+ _native = require('@aster-rpc/transport');
1321
+ return _native;
1322
+ }
1323
+ catch { /* next */ }
1324
+ // 2. Try ASTER_NATIVE_PATH env var
1325
+ const envPath = process.env.ASTER_NATIVE_PATH;
1326
+ if (envPath) {
1327
+ for (const name of platforms) {
1328
+ const full = resolve(envPath, name);
1329
+ if (existsSync(full)) {
1330
+ _native = require(full);
1331
+ return _native;
1332
+ }
1333
+ }
1334
+ }
1335
+ // 3. Try common workspace layouts relative to this file
1336
+ const searchDirs = [];
1337
+ try {
1338
+ const { dirname } = require('node:path');
1339
+ // When loaded from packages/aster/src/
1340
+ const thisDir = typeof __dirname !== 'undefined'
1341
+ ? __dirname
1342
+ : dirname(new URL(import.meta.url).pathname);
1343
+ searchDirs.push(resolve(thisDir, '../../../native')); // packages/aster/src -> native
1344
+ searchDirs.push(resolve(thisDir, '../../native')); // packages/aster -> native
1345
+ searchDirs.push(resolve(process.cwd(), 'bindings/typescript/native')); // repo root
1346
+ searchDirs.push(resolve(process.cwd(), 'node_modules/@aster-rpc/transport')); // linked
1347
+ }
1348
+ catch { /* ignore */ }
1349
+ for (const dir of searchDirs) {
1350
+ for (const name of platforms) {
1351
+ const full = resolve(dir, name);
1352
+ try {
1353
+ if (existsSync(full)) {
1354
+ _native = require(full);
1355
+ return _native;
1356
+ }
1357
+ }
1358
+ catch { /* next */ }
1359
+ }
1360
+ }
1361
+ return null;
1362
+ }
1363
+ async function loadNative() {
1364
+ return loadNativeSync();
1365
+ }
1366
+ //# sourceMappingURL=runtime.js.map