@calimero-network/mero-js 25.2.0 → 25.3.1

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.
@@ -1,5 +1,5 @@
1
1
  import { HttpClient, HTTPError } from '../http-client/index.js';
2
- import type { HealthStatus, ReadinessStatus, AdminAuthStatus, InstallApplicationRequest, InstallApplicationResponseData, InstallDevApplicationRequest, UninstallApplicationResponseData, ListApplicationsResponseData, GetApplicationResponseData, ApplicationVersionEntry, GetApplicationAbiResponseData, GetLatestVersionResponseData, ListPackagesResponseData, ListVersionsResponseData, CreateContextRequest, CreateContextResponseData, DeleteContextResponseData, GetContextsResponseData, Context, GenerateContextIdentityResponseData, GetContextIdentitiesResponseData, JoinContextResponseData, JoinSubgroupInheritanceResponseData, ContextGroupResponseData, ContextStorageResponseData, InviteSpecializedNodeRequest, InviteSpecializedNodeResponseData, UpdateContextApplicationRequest, ResyncContextRequest, ResyncContextResponseData, ContextsWithExecutorsResponseData, UploadBlobRequest, UploadBlobResponseData, DeleteBlobResponseData, ListBlobsResponseData, GetBlobInfoResponseData, BlobReadOptions, CreateContextAliasRequest, CreateApplicationAliasRequest, CreateDeviceAliasRequest, CreateAliasResponseData, LookupAliasResponseData, DeleteAliasResponseData, ListAliasesResponseData, ListNamespacesResponseData, CreateNamespaceRequest, CreateNamespaceResponseData, DeleteNamespaceResponseData, CreateNamespaceInvitationRequest, CreateNamespaceInvitationResponseData, CreateRecursiveInvitationResponseData, JoinNamespaceRequest, JoinNamespaceResponseData, CreateGroupInNamespaceRequest, CreateGroupInNamespaceResponseData, SubgroupEntry, Namespace, NamespaceIdentity, NodeIdentity, AccountPairInitRequest, AccountPairInitResponseData, AccountPairCompleteRequest, AccountPairCompleteResponseData, AccountSignWithRootRequest, AccountSignWithRootResponseData, RelinkDeviceRequest, RelinkDeviceResponseData, RescopeDeviceRequest, RescopeDeviceResponseData, LabelDeviceRequest, LabelDeviceResponseData, AccountDeviceEntry, AccountApplicationEntry, RevokeAccountDeviceRequest, LinkAccountDeviceRequest, LinkAccountDeviceResponseData, RevokeAccountDeviceResponseData, MemberDevicesEntry, ListMemberDevicesOptions, GroupInfoResponseData, CreateGroupRequest, CreateGroupResponseData, DeleteGroupResponseData, ListGroupMembersResponseData, ListGroupContextsResponseData, AddGroupMembersRequest, RemoveGroupMembersRequest, UpdateMemberRoleRequest, MemberCapabilities, SetMemberCapabilitiesRequest, SetDefaultCapabilitiesRequest, SetMemberAutoFollowRequest, SetSubgroupVisibilityRequest, SetTeeAdmissionPolicyRequest, GetTeeAdmissionPolicyResponseData, SetTeeAuthoringPolicyRequest, TransferOwnershipRequest, ChangeNamespaceAdminRequest, OwnerDeleteGroupRequest, RootGuardedOpRequest, SetGroupMetadataRequest, SetMemberMetadataRequest, SetContextMetadataRequest, MetadataRecord, SyncGroupResponseData, UpgradeGroupRequest, UpgradeGroupResponseData, GroupUpgradeStatusResponseData, MigrationStatus, CascadeStatusEntry, RetryGroupUpgradeResponseData, ReparentGroupRequest, ReparentGroupResponseData, IssueOwnershipProofRequest, IssueNamespaceOwnershipProofRequest, IssueOwnershipProofResponseData, CreateGroupInvitationRequest, CreateGroupInvitationResponseData, CreateRecursiveGroupInvitationResponseData, JoinGroupRequest, JoinGroupResponseData, TeeInfoResponseData, TeeAttestRequest, TeeAttestResponseData, TeeVerifyQuoteRequest, TeeVerifyQuoteResponseData, PerformIntentRequest, PerformIntentResponseData, IntentRelayInfo, QueryContextRequest, QueryContextResponseData, ListMemberDevicesResponseData, SealToAccountRequest, SealedEnvelope, AdmitJoinRequest, AdmitJoinResponseData, WarrantNonceState } from './admin-types.js';
2
+ import type { HealthStatus, ReadinessStatus, AdminAuthStatus, InstallApplicationRequest, InstallApplicationResponseData, InstallDevApplicationRequest, UninstallApplicationResponseData, ListApplicationsResponseData, GetApplicationResponseData, ApplicationVersionEntry, GetApplicationAbiResponseData, GetLatestVersionResponseData, ListPackagesResponseData, ListVersionsResponseData, CreateContextRequest, CreateContextResponseData, DeleteContextResponseData, GetContextsResponseData, Context, GenerateContextIdentityResponseData, GetContextIdentitiesResponseData, JoinContextResponseData, JoinSubgroupInheritanceResponseData, ContextGroupResponseData, ContextStorageResponseData, InviteSpecializedNodeRequest, InviteSpecializedNodeResponseData, UpdateContextApplicationRequest, ResyncContextRequest, ResyncContextResponseData, ContextsWithExecutorsResponseData, UploadBlobRequest, UploadBlobResponseData, DeleteBlobResponseData, ListBlobsResponseData, GetBlobInfoResponseData, BlobReadOptions, CreateContextAliasRequest, CreateApplicationAliasRequest, CreateDeviceAliasRequest, CreateAliasResponseData, LookupAliasResponseData, DeleteAliasResponseData, ListAliasesResponseData, ListNamespacesResponseData, CreateNamespaceRequest, CreateNamespaceResponseData, DeleteNamespaceResponseData, CreateNamespaceInvitationRequest, CreateNamespaceInvitationResponseData, CreateRecursiveInvitationResponseData, JoinNamespaceRequest, JoinNamespaceResponseData, CreateGroupInNamespaceRequest, CreateGroupInNamespaceResponseData, SubgroupEntry, Namespace, NamespaceIdentity, NodeIdentity, AccountPairInitRequest, AccountPairInitResponseData, AccountPairCompleteRequest, AccountPairCompleteResponseData, AccountSignWithRootRequest, AccountSignWithRootResponseData, RelinkDeviceRequest, RelinkDeviceResponseData, RescopeDeviceRequest, RescopeDeviceResponseData, LabelDeviceRequest, LabelDeviceResponseData, AccountDeviceEntry, AccountApplicationEntry, RevokeAccountDeviceRequest, LinkAccountDeviceRequest, LinkAccountDeviceResponseData, RevokeAccountDeviceResponseData, MemberDevicesEntry, ListMemberDevicesOptions, GroupInfoResponseData, CreateGroupRequest, CreateGroupResponseData, DeleteGroupResponseData, ListGroupMembersResponseData, ListGroupContextsResponseData, AddGroupMembersRequest, RemoveGroupMembersRequest, UpdateMemberRoleRequest, MemberCapabilities, SetMemberCapabilitiesRequest, SetDefaultCapabilitiesRequest, SetMemberAutoFollowRequest, SetSubgroupVisibilityRequest, SetTeeAdmissionPolicyRequest, GetTeeAdmissionPolicyResponseData, SetTeeAuthoringPolicyRequest, TransferOwnershipRequest, ChangeNamespaceAdminRequest, OwnerDeleteGroupRequest, RootGuardedOpRequest, SetGroupMetadataRequest, SetMemberMetadataRequest, SetContextMetadataRequest, MetadataRecord, SyncGroupResponseData, UpgradeGroupRequest, UpgradeGroupResponseData, GroupUpgradeStatusResponseData, MigrationStatus, NamespaceRevokedDevices, CascadeStatusEntry, RetryGroupUpgradeResponseData, ReparentGroupRequest, ReparentGroupResponseData, IssueOwnershipProofRequest, IssueNamespaceOwnershipProofRequest, IssueOwnershipProofResponseData, CreateGroupInvitationRequest, CreateGroupInvitationResponseData, CreateRecursiveGroupInvitationResponseData, JoinGroupRequest, JoinGroupResponseData, TeeInfoResponseData, TeeAttestRequest, TeeAttestResponseData, TeeVerifyQuoteRequest, TeeVerifyQuoteResponseData, PerformIntentRequest, PerformIntentResponseData, IntentRelayInfo, QueryContextRequest, QueryContextResponseData, ListMemberDevicesResponseData, SealToAccountRequest, SealedEnvelope, AdmitJoinRequest, AdmitJoinResponseData, WarrantNonceState } from './admin-types.js';
3
3
  import { type RedeemOutcome } from './redeem-invitation.js';
4
4
  /**
5
5
  * Thrown when a node answers the warrant-nonce route with a 404.
@@ -720,6 +720,8 @@ export declare class AdminApiClient {
720
720
  * envelope to unwrap here (unlike most admin reads).
721
721
  */
722
722
  getMigrationStatus(namespaceId: string): Promise<MigrationStatus>;
723
+ /** Revoked devices and withdrawn slots of a namespace; 403 unless this node is a bound member. */
724
+ listNamespaceRevokedDevices(namespaceId: string): Promise<NamespaceRevokedDevices>;
723
725
  /** Per-group cascade-migration snapshots for a namespace. */
724
726
  getCascadeStatus(namespaceId: string): Promise<CascadeStatusEntry[]>;
725
727
  retryGroupUpgrade(groupId: string): Promise<RetryGroupUpgradeResponseData>;
@@ -1285,6 +1285,10 @@ export class AdminApiClient {
1285
1285
  async getMigrationStatus(namespaceId) {
1286
1286
  return this.httpClient.get(`/admin-api/groups/${pathSegment(namespaceId)}/migration-status`);
1287
1287
  }
1288
+ /** Revoked devices and withdrawn slots of a namespace; 403 unless this node is a bound member. */
1289
+ async listNamespaceRevokedDevices(namespaceId) {
1290
+ return this.httpClient.get(`/admin-api/namespaces/${pathSegment(namespaceId)}/revoked-devices`);
1291
+ }
1288
1292
  /** Per-group cascade-migration snapshots for a namespace. */
1289
1293
  async getCascadeStatus(namespaceId) {
1290
1294
  return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(namespaceId)}/cascade-status`));
@@ -1353,6 +1353,12 @@ export interface MigrationStatus {
1353
1353
  rollup: MigrationStatusRollup;
1354
1354
  members: MemberMigrationStatusEntry[];
1355
1355
  }
1356
+ export interface NamespaceRevokedDevices {
1357
+ /** Device ids (64 hex) revoked in the namespace. */
1358
+ revokedDevices: string[];
1359
+ /** Hex `sha256(account || device)` of each device its own account withdrew in the namespace. */
1360
+ withdrawnSlots: string[];
1361
+ }
1356
1362
  export interface CascadeStatusEntry {
1357
1363
  groupId: string;
1358
1364
  upgrade: GroupUpgradeStatus;
@@ -1,5 +1,7 @@
1
1
  import { isGroupMigrationEvent } from './group.js';
2
2
  import { AuthRevokedError, HTTPError } from '../http-client/index.js';
3
+ import { carriesProof, refuseProofRedirect } from '../http-client/web-client.js';
4
+ import { signedPath } from '../request-proof/authorizer.js';
3
5
  /**
4
6
  * `x-auth-error` reasons that mean the whole token family is gone, mirroring
5
7
  * the request path in `web-client.ts`. Anything else — `token_expired` above
@@ -143,13 +145,18 @@ export class SseClient {
143
145
  this.abortController = new AbortController();
144
146
  try {
145
147
  const url = `${this.baseUrl}/sse`;
148
+ const headers = {
149
+ ...(await this.authorize({ method: 'GET', path: signedPath(url) })),
150
+ 'Accept': 'text/event-stream',
151
+ };
152
+ const proofAttached = carriesProof(headers);
146
153
  const response = await this.fetchImpl(url, {
147
- headers: {
148
- ...(await this.authorize({ method: 'GET', path: new URL(url).pathname })),
149
- 'Accept': 'text/event-stream',
150
- },
154
+ headers,
151
155
  signal: this.abortController.signal,
156
+ ...(proofAttached && { redirect: 'manual' }),
152
157
  });
158
+ if (proofAttached)
159
+ refuseProofRedirect(response, url);
153
160
  if (!response.ok) {
154
161
  // Throw the same typed errors the request path throws. A bare
155
162
  // `Error("SSE connection failed: 401")` forced callers to string-match
@@ -379,15 +386,20 @@ export class SseClient {
379
386
  // differ in key order, which fails as a refused credential rather than as
380
387
  // anything that names the real cause.
381
388
  const body = JSON.stringify({ id: this.sessionId, method, params });
389
+ const headers = {
390
+ ...(await this.authorize({ method: 'POST', path: signedPath(url), body })),
391
+ 'Content-Type': 'application/json',
392
+ };
393
+ const proofAttached = carriesProof(headers);
382
394
  const response = await this.fetchImpl(url, {
383
395
  method: 'POST',
384
- headers: {
385
- ...(await this.authorize({ method: 'POST', path: new URL(url).pathname, body })),
386
- 'Content-Type': 'application/json',
387
- },
396
+ headers,
388
397
  body,
389
398
  signal: timeout.signal,
399
+ ...(proofAttached && { redirect: 'manual' }),
390
400
  });
401
+ if (proofAttached)
402
+ refuseProofRedirect(response, url);
391
403
  if (!response.ok) {
392
404
  // The subscribe path used to report only the status. On the one failure
393
405
  // that matters it is the whole of what the caller got: a revoked family
@@ -18,7 +18,7 @@ export interface Transport {
18
18
  */
19
19
  authorizeRequest?: (request: {
20
20
  method: string;
21
- /** The path the node will see, query excluded. */
21
+ /** The path and query the node will see, as `signedPath` reads them. */
22
22
  path: string;
23
23
  body?: string | Uint8Array;
24
24
  }) => Promise<Record<string, string>>;
@@ -36,6 +36,13 @@ export declare class AuthRevokedError extends HTTPError {
36
36
  /** Value of the `x-auth-error` header, e.g. `token_reuse` or `token_revoked`. */
37
37
  reason: string, status: number, statusText: string, url: string, headers: Headers, bodyText?: string);
38
38
  }
39
+ /** Whether `headers` carry a request proof, which must never follow a redirect. */
40
+ export declare function carriesProof(headers: Record<string, string>): boolean;
41
+ /**
42
+ * Throws when a proof-carrying request was answered with a redirect, which
43
+ * fetch reports as an opaque response with status 0 under redirect: 'manual'.
44
+ */
45
+ export declare function refuseProofRedirect(response: Response, url: string): void;
39
46
  export declare class WebHttpClient implements HttpClient {
40
47
  private transport;
41
48
  private refreshTokenPromise;
@@ -1,4 +1,6 @@
1
1
  import { combineSignals, createTimeoutSignal } from './signal-utils.js';
2
+ import { signedPath } from '../request-proof/authorizer.js';
3
+ import { REQUEST_PROOF_HEADER } from '../request-proof/request-proof.js';
2
4
  /**
3
5
  * `x-auth-error` reasons that mean the whole token family is gone.
4
6
  *
@@ -93,24 +95,36 @@ function headersToRecord(headers) {
93
95
  });
94
96
  return record;
95
97
  }
96
- // Web Standards HTTP client implementation
97
- /**
98
- * The path a node will verify a request signature against.
99
- *
100
- * `new URL` drops the query and keeps any prefix the base URL contributed,
101
- * which is exactly what reaches the server. The fallback covers a `url` that is
102
- * already relative — it cannot be parsed, and signing the whole string with its
103
- * query attached would be worse than signing the part before the `?`.
104
- */
105
- function pathForProof(url) {
98
+ /** Runtimes disagree on sending a bare `?`, so the URL parser drops it once and
99
+ * the URL signed and the URL fetched are one string. */
100
+ function withoutBareQuery(url) {
101
+ let parsed;
106
102
  try {
107
- return new URL(url).pathname;
103
+ parsed = new URL(url, globalThis.location?.href);
108
104
  }
109
105
  catch {
110
- const query = url.indexOf('?');
111
- return query === -1 ? url : url.slice(0, query);
106
+ return url; // relative, with no page to resolve it against
112
107
  }
108
+ if (parsed.search === '')
109
+ parsed.search = '';
110
+ return parsed.href;
111
+ }
112
+ /** Whether `headers` carry a request proof, which must never follow a redirect. */
113
+ export function carriesProof(headers) {
114
+ return Object.keys(headers).some((name) => name.toLowerCase() === REQUEST_PROOF_HEADER.toLowerCase());
113
115
  }
116
+ /**
117
+ * Throws when a proof-carrying request was answered with a redirect, which
118
+ * fetch reports as an opaque response with status 0 under redirect: 'manual'.
119
+ */
120
+ export function refuseProofRedirect(response, url) {
121
+ const redirected = response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400);
122
+ if (!redirected)
123
+ return;
124
+ // Status 0 marks a withheld response; its explanation is the body text.
125
+ throw new HTTPError(0, 'Redirect Not Followed', url, response.headers, `the node answered with a redirect; a request carrying ${REQUEST_PROOF_HEADER} is not followed to another location`);
126
+ }
127
+ // Web Standards HTTP client implementation
114
128
  export class WebHttpClient {
115
129
  constructor(transport) {
116
130
  this.transport = transport;
@@ -175,7 +189,7 @@ export class WebHttpClient {
175
189
  async makeRequest(path, init, retryCount = 0, requestStartTime) {
176
190
  // Maximum retry attempts to prevent infinite loops
177
191
  const MAX_RETRY_ATTEMPTS = 1;
178
- const url = this.buildUrl(path);
192
+ const url = withoutBareQuery(this.buildUrl(path));
179
193
  // Track request start time for timeout calculation (only on first attempt)
180
194
  // Use per-request start time to avoid corruption from concurrent requests
181
195
  const startTime = requestStartTime ?? Date.now();
@@ -195,9 +209,7 @@ export class WebHttpClient {
195
209
  }
196
210
  const method = init?.method || 'GET';
197
211
  // A credential that commits to the request can only be produced here, where
198
- // the method, the assembled path and the body are all known. The path comes
199
- // from the URL rather than from `path`, because a base URL may carry a prefix
200
- // (`…/admin-api`) that the node sees and a bare relative path would not sign.
212
+ // the method, the assembled path and query, and the body are all known.
201
213
  if (this.transport.authorizeRequest) {
202
214
  const body = init?.body;
203
215
  if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
@@ -207,7 +219,7 @@ export class WebHttpClient {
207
219
  }
208
220
  Object.assign(headersObj, await this.transport.authorizeRequest({
209
221
  method,
210
- path: new URL(url).pathname,
222
+ path: signedPath(url),
211
223
  // Narrowed by the guard above; `BodyInit` is wider than what can be
212
224
  // signed.
213
225
  body: body,
@@ -281,24 +293,28 @@ export class WebHttpClient {
281
293
  // nor the body — and a proof commits to both. Signed AFTER the body is
282
294
  // settled above, so what is hashed is what is sent.
283
295
  //
284
- // The path comes from the built URL's `pathname`, not from the `path`
285
- // argument: that argument may carry a query string, and may be relative to
286
- // a base URL that contributes its own prefix. The node verifies against the
287
- // path it received, so the only safe source is the URL actually being
288
- // fetched. A query string is deliberately not covered — core's `RequestSig`
289
- // signs the path alone, and signing more here would fail every request.
296
+ // The path and query come from the built URL, not from the `path` argument,
297
+ // which may be relative to a base URL that contributes its own prefix.
290
298
  if (this.transport.getProof) {
291
299
  const proof = await this.transport.getProof({
292
300
  method: requestInit.method ?? 'GET',
293
- path: pathForProof(url),
301
+ path: signedPath(url),
294
302
  body: requestInit.body,
295
303
  });
296
304
  if (proof) {
297
305
  headersObj['X-Calimero-Proof'] = proof;
298
306
  }
299
307
  }
308
+ // fetch forwards custom headers across redirects, so a proof must never be
309
+ // sent anywhere but where it was signed for.
310
+ const proofAttached = carriesProof(headersObj);
311
+ if (proofAttached && requestInit.redirect !== 'error') {
312
+ requestInit.redirect = 'manual';
313
+ }
300
314
  try {
301
315
  const response = await this.transport.fetch(url, requestInit);
316
+ if (proofAttached)
317
+ refuseProofRedirect(response, url);
302
318
  if (!response.ok) {
303
319
  const bodyText = await this.getBodyText(response);
304
320
  const httpError = new HTTPError(response.status, response.statusText, url, response.headers, bodyText);