@twinfinity/twin 6.1.0 → 6.1.2-ci.30738

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 (97) hide show
  1. package/README.md +63 -2
  2. package/dist/BuildInfo.js +3 -3
  3. package/dist/TwinClient.d.ts +312 -9
  4. package/dist/TwinClient.d.ts.map +1 -1
  5. package/dist/TwinClient.js +321 -6
  6. package/dist/TwinClient.js.map +1 -1
  7. package/dist/errors.d.ts +120 -5
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +116 -2
  10. package/dist/errors.js.map +1 -1
  11. package/dist/tsconfig.tsbuildinfo +1 -1
  12. package/dist/types/generated.d.ts +93 -31
  13. package/dist/types/generated.d.ts.map +1 -1
  14. package/dist/types/twin-api.d.ts +54 -1
  15. package/dist/types/twin-api.d.ts.map +1 -1
  16. package/dist/types/twin-permissions-api.d.ts +226 -6
  17. package/dist/types/twin-permissions-api.d.ts.map +1 -1
  18. package/docs/assets/hierarchy.js +1 -1
  19. package/docs/assets/navigation.js +1 -1
  20. package/docs/assets/search.js +1 -1
  21. package/docs/classes/AssetAttachmentConflictError.html +1 -1
  22. package/docs/classes/AssetAttachmentNotFoundError.html +1 -1
  23. package/docs/classes/AssetBatchError.html +1 -1
  24. package/docs/classes/AssetCatalogTooLargeError.html +1 -1
  25. package/docs/classes/AssetCategoryConflictError.html +1 -1
  26. package/docs/classes/AssetCategoryForbiddenError.html +1 -1
  27. package/docs/classes/AssetCategoryNotFoundError.html +1 -1
  28. package/docs/classes/AssetCategoryValidationError.html +1 -1
  29. package/docs/classes/AssetConflictError.html +1 -1
  30. package/docs/classes/AssetForbiddenError.html +1 -1
  31. package/docs/classes/AssetNotFoundError.html +1 -1
  32. package/docs/classes/AssetPermissionsConflictError.html +16 -0
  33. package/docs/classes/AssetTypeConflictError.html +1 -1
  34. package/docs/classes/AssetTypeForbiddenError.html +1 -1
  35. package/docs/classes/AssetTypeNotFoundError.html +1 -1
  36. package/docs/classes/AssetTypeValidationError.html +1 -1
  37. package/docs/classes/AssetValidationError.html +1 -1
  38. package/docs/classes/ProcessorNotFoundError.html +1 -1
  39. package/docs/classes/TwinClient.html +251 -73
  40. package/docs/classes/TwinConflictError.html +1 -1
  41. package/docs/classes/TwinFileConflictError.html +1 -1
  42. package/docs/classes/TwinFileNotFoundError.html +1 -1
  43. package/docs/classes/TwinFileValidationError.html +1 -1
  44. package/docs/classes/TwinNotFoundError.html +4 -2
  45. package/docs/classes/TwinPermissionsConflictError.html +19 -0
  46. package/docs/classes/TwinPermissionsForbiddenError.html +12 -0
  47. package/docs/classes/TwinPrincipalNotFoundError.html +5 -0
  48. package/docs/classes/TwinTemporarilyUnavailableError.html +10 -0
  49. package/docs/classes/TwinUnauthenticatedError.html +1 -1
  50. package/docs/classes/TwinValidationError.html +1 -1
  51. package/docs/hierarchy.html +1 -1
  52. package/docs/index.html +56 -4
  53. package/docs/interfaces/AssetCatalogTooLargeErrorBody.html +2 -2
  54. package/docs/interfaces/AssetGroupGet.html +9 -0
  55. package/docs/interfaces/AssetGroupsGet.html +3 -0
  56. package/docs/interfaces/AssetPermissionsConflictErrorBody.html +12 -0
  57. package/docs/interfaces/AssetPermissionsPut.html +9 -0
  58. package/docs/interfaces/AssetPrincipalPermissionsGet.html +23 -0
  59. package/docs/interfaces/Coordinates.html +6 -0
  60. package/docs/interfaces/EntityCollection.html +7 -2
  61. package/docs/interfaces/EntityRepresentation.html +12 -0
  62. package/docs/interfaces/Facility.html +8 -3
  63. package/docs/interfaces/FacilityPart.html +5 -3
  64. package/docs/interfaces/ITwinClient.html +251 -73
  65. package/docs/interfaces/InheritedPermissions.html +8 -0
  66. package/docs/interfaces/ListTwinItem.html +18 -2
  67. package/docs/interfaces/Location.html +4 -1
  68. package/docs/interfaces/MyPermissions.html +2 -2
  69. package/docs/interfaces/MyTwinPermissions.html +5 -0
  70. package/docs/interfaces/Site.html +11 -4
  71. package/docs/interfaces/Space.html +6 -3
  72. package/docs/interfaces/TwinErrorBody.html +2 -2
  73. package/docs/interfaces/TwinGet.html +29 -11
  74. package/docs/interfaces/TwinGroupGet.html +10 -0
  75. package/docs/interfaces/TwinGroupsGet.html +3 -0
  76. package/docs/interfaces/TwinPermissionsConflictErrorBody.html +8 -0
  77. package/docs/interfaces/TwinPermissionsPut.html +11 -0
  78. package/docs/interfaces/TwinPrincipalPermissionsGet.html +34 -0
  79. package/docs/interfaces/TwinPut.html +8 -10
  80. package/docs/interfaces/TwinReference.html +4 -0
  81. package/docs/interfaces/TwinSpecification.html +7 -9
  82. package/docs/media/twin-management.md +197 -0
  83. package/docs/modules.html +1 -1
  84. package/docs/types/TwinBoundCapabilityName.html +8 -0
  85. package/docs/types/TwinCapability.html +11 -5
  86. package/docs/types/TwinCapabilityName.html +8 -0
  87. package/docs/types/TwinErrorCause.html +1 -1
  88. package/docs/types/TwinErrorItemKind.html +1 -1
  89. package/docs/types/TwinPermissionsConflictCause.html +6 -0
  90. package/docs/types/TwinScopedCapability.html +9 -0
  91. package/docs/types/TwinScopedCapabilityName.html +8 -0
  92. package/package.json +3 -3
  93. package/src/TwinClient.ts +776 -15
  94. package/src/errors.ts +191 -4
  95. package/src/types/generated.ts +93 -33
  96. package/src/types/twin-api.ts +55 -1
  97. package/src/types/twin-permissions-api.ts +243 -6
@@ -1,6 +1,42 @@
1
1
  import { HttpMethod } from '@twinfinity/authentication';
2
2
  import { readT8ClientModelWithFactories, readT8ClientModelWithGeometry, readT8GIModelWithFactories, T8EntityType, T8GIMeshDescriptor, T8GIStyle, T8GIRegion, T8Product, T8Project, T8Unit } from '@twinfinity/t8-client-model';
3
- import { TwinValidationError, TwinConflictError, TwinNotFoundError, ProcessorNotFoundError, AssetValidationError, AssetForbiddenError, AssetConflictError, AssetNotFoundError, AssetAttachmentConflictError, AssetAttachmentNotFoundError, AssetBatchError, TwinFileValidationError, TwinFileConflictError, TwinFileNotFoundError, AssetCategoryValidationError, AssetCategoryConflictError, AssetCategoryNotFoundError, AssetCategoryForbiddenError, AssetTypeValidationError, AssetTypeConflictError, AssetTypeNotFoundError, AssetTypeForbiddenError, AssetCatalogTooLargeError, isAssetCatalogTooLargeErrorBody, TwinUnauthenticatedError, readErrorBody } from './errors';
3
+ import { TwinValidationError, TwinConflictError, TwinNotFoundError, ProcessorNotFoundError, AssetValidationError, AssetForbiddenError, AssetConflictError, AssetNotFoundError, AssetAttachmentConflictError, AssetAttachmentNotFoundError, AssetBatchError, TwinFileValidationError, TwinFileConflictError, TwinFileNotFoundError, AssetCategoryValidationError, AssetCategoryConflictError, AssetCategoryNotFoundError, AssetCategoryForbiddenError, AssetTypeValidationError, AssetTypeConflictError, AssetTypeNotFoundError, AssetTypeForbiddenError, AssetCatalogTooLargeError, isAssetCatalogTooLargeErrorBody, TwinUnauthenticatedError, TwinPermissionsConflictError, TwinPermissionsForbiddenError, TwinPrincipalNotFoundError, TwinTemporarilyUnavailableError, AssetPermissionsConflictError, readErrorBody } from './errors';
4
+ /**
5
+ * The `Retry-After` of a 503, in seconds, when the response carried a usable one. The Twin service
6
+ * sends a delta-seconds value; an HTTP-date is permitted by RFC 7231 and simply yields nothing here
7
+ * rather than a nonsense delay.
8
+ */
9
+ function retryAfterSeconds(response) {
10
+ const header = response.headers?.get('Retry-After');
11
+ if (header === null || header === undefined)
12
+ return undefined;
13
+ const seconds = Number(header);
14
+ return Number.isInteger(seconds) && seconds >= 0 ? seconds : undefined;
15
+ }
16
+ /**
17
+ * The batch response body carried by a failed batch, or `undefined` when the body is not one.
18
+ *
19
+ * A rolled-back batch answers with per-item results under the status of its *last failing item* --
20
+ * so the same endpoint returns a batch body under 400, 404 or 409, while a whole-request rejection
21
+ * (an empty batch, duplicate ids, a body/URL twin mismatch, oversized metadata) returns problem
22
+ * details under 400 as well. Status alone cannot tell those apart, and branching on it reported
23
+ * every per-item 400 -- a cross-twin id, a deleted asset, metadata failing its schema -- as a
24
+ * validation failure with no results for the caller to inspect.
25
+ */
26
+ async function readBatchResponse(response) {
27
+ // Parse a clone: json() consumes the stream, and a body that turns out not to be a batch
28
+ // response still has to be readable as problem details or as text by the caller. Without
29
+ // clone -- some test doubles -- the probe is skipped rather than risk consuming the body.
30
+ if (typeof response.clone !== 'function')
31
+ return undefined;
32
+ try {
33
+ const parsed = (await response.clone().json());
34
+ return Array.isArray(parsed?.results) ? parsed : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
4
40
  export class TwinClient {
5
41
  _httpClient;
6
42
  _baseUrl;
@@ -21,7 +57,22 @@ export class TwinClient {
21
57
  get baseUrl() {
22
58
  return this._baseUrl;
23
59
  }
24
- async listTwins(relatedTo, page = undefined, limit = 100, signal) {
60
+ async listTwins(optionsOrRelatedTo, pageOrSignal, positionalLimit, positionalSignal) {
61
+ let relatedTo;
62
+ let includeDeleted = false;
63
+ let page;
64
+ let limit = 100;
65
+ let signal;
66
+ if (typeof optionsOrRelatedTo === 'string') {
67
+ relatedTo = optionsOrRelatedTo;
68
+ page = pageOrSignal;
69
+ limit = positionalLimit ?? 100;
70
+ signal = positionalSignal;
71
+ }
72
+ else {
73
+ ({ relatedTo, includeDeleted = false, page = undefined, limit = 100 } = optionsOrRelatedTo ?? {});
74
+ signal = pageOrSignal;
75
+ }
25
76
  const params = new URLSearchParams();
26
77
  if (relatedTo)
27
78
  params.set('relatedTo', relatedTo);
@@ -29,6 +80,8 @@ export class TwinClient {
29
80
  params.set('page', page);
30
81
  if (limit !== 100)
31
82
  params.set('limit', limit.toString());
83
+ if (includeDeleted)
84
+ params.set('includeDeleted', 'true');
32
85
  const url = params.toString() ? `${this._baseUrl}?${params}` : this._baseUrl;
33
86
  const response = await this._httpClient.fetch(HttpMethod.Get, url, {
34
87
  headers: { Accept: 'application/json' },
@@ -44,13 +97,24 @@ export class TwinClient {
44
97
  const errorText = await response.text();
45
98
  throw new Error(`Failed to list twins: ${response.status} ${response.statusText} - ${errorText}`);
46
99
  }
47
- async listAllTwins(relatedTo, signal) {
100
+ async listAllTwins(optionsOrRelatedTo, signalArg) {
101
+ let relatedTo;
102
+ let includeDeleted = false;
103
+ let signal;
104
+ if (typeof optionsOrRelatedTo === 'string') {
105
+ relatedTo = optionsOrRelatedTo;
106
+ signal = signalArg;
107
+ }
108
+ else {
109
+ ({ relatedTo, includeDeleted = false } = optionsOrRelatedTo ?? {});
110
+ signal = signalArg;
111
+ }
48
112
  const allTwins = [];
49
113
  let page = undefined;
50
114
  const limit = 100;
51
115
  const maxPages = 1000;
52
116
  for (let pageCount = 0; pageCount < maxPages; pageCount++) {
53
- const response = await this.listTwins(relatedTo, page, limit, signal);
117
+ const response = await this.listTwins({ relatedTo, page, limit, includeDeleted }, signal);
54
118
  allTwins.push(...response.data);
55
119
  if (response.data.length < limit) {
56
120
  break;
@@ -511,6 +575,13 @@ export class TwinClient {
511
575
  if (response.ok) {
512
576
  return (await response.json());
513
577
  }
578
+ // The body decides, not the status: a rolled-back batch is reported as a batch error at
579
+ // whatever status its last failing item carried, and only a body that is not a batch
580
+ // response falls through to the status arms below.
581
+ const batchResponse = await readBatchResponse(response);
582
+ if (batchResponse) {
583
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, batchResponse);
584
+ }
514
585
  if (response.status === 400) {
515
586
  const errorBody = await readErrorBody(response);
516
587
  throw new AssetValidationError(`Asset batch validation failed: ${errorBody.detail}`, errorBody);
@@ -519,9 +590,12 @@ export class TwinClient {
519
590
  const errorText = await response.text();
520
591
  throw new AssetForbiddenError(`Asset batch forbidden: ${errorText}`);
521
592
  }
593
+ // Reached only where the probe above could not read the body -- a Response without clone(),
594
+ // which some test doubles are. These two statuses only ever carry a batch response, so read
595
+ // it directly rather than losing the per-item results to the generic error below.
522
596
  if (response.status === 404 || response.status === 409) {
523
- const batchResponse = (await response.json());
524
- throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, batchResponse);
597
+ const rolledBack = (await response.json());
598
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, rolledBack);
525
599
  }
526
600
  const errorText = await response.text();
527
601
  throw new Error(`Failed to execute asset batch: ${response.status} ${response.statusText} - ${errorText}`);
@@ -900,6 +974,247 @@ export class TwinClient {
900
974
  }
901
975
  throw new Error(`Failed to fetch permissions: ${response.status} ${response.statusText}`);
902
976
  }
977
+ // ---------- Twin-scoped permissions (TWINS-2982) ----------
978
+ async getMyTwinPermissions(twinId, signal) {
979
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/permissions/me`, {
980
+ headers: { Accept: 'application/json' },
981
+ signal
982
+ });
983
+ if (response.ok)
984
+ return (await response.json());
985
+ return this._failTwinPermissionsRequest(response, 'read the capabilities on the twin', twinId);
986
+ }
987
+ async listTwinPermissions(twinId, options, signal) {
988
+ const params = new URLSearchParams();
989
+ if (options?.page !== undefined)
990
+ params.set('page', options.page);
991
+ if (options?.limit !== undefined)
992
+ params.set('limit', options.limit.toString());
993
+ const query = params.toString();
994
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/permissions${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, signal });
995
+ if (response.ok)
996
+ return (await response.json());
997
+ return this._failTwinPermissionsRequest(response, 'list the twin permissions', twinId);
998
+ }
999
+ async listAllTwinPermissions(twinId, signal) {
1000
+ const limit = 1000;
1001
+ const entries = [];
1002
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
1003
+ // last page — the same reason `listAllTwins` pages this way.
1004
+ let page;
1005
+ let received;
1006
+ do {
1007
+ const response = await this.listTwinPermissions(twinId, { page, limit }, signal);
1008
+ entries.push(...response.data);
1009
+ received = response.data.length;
1010
+ page = response.pagination.nextPage;
1011
+ } while (received >= limit);
1012
+ return entries;
1013
+ }
1014
+ async setTwinPermissions(twinId, principalId, body, signal) {
1015
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}/permissions/${principalId}`, {
1016
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
1017
+ body: JSON.stringify(body),
1018
+ signal
1019
+ });
1020
+ if (response.ok)
1021
+ return (await response.json());
1022
+ return this._failTwinPermissionsRequest(response, 'set the twin permissions', twinId, {
1023
+ id: principalId,
1024
+ label: 'Principal'
1025
+ });
1026
+ }
1027
+ async deleteTwinPermissions(twinId, principalId, etag, signal) {
1028
+ const response = await this._httpClient.fetch(HttpMethod.Delete, `${this._baseUrl}/${twinId}/permissions/${principalId}`, {
1029
+ // The etag travels in the header, which is the transport the surface prefers and
1030
+ // the one that wins when both are present. It is repeated in the body — the other
1031
+ // carrier ADR-0008 documents, and the one the endpoint binds — so that the request
1032
+ // still names an etag if a proxy strips the header, or drops the body of a DELETE.
1033
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
1034
+ body: JSON.stringify({ etag }),
1035
+ signal
1036
+ });
1037
+ if (response.ok)
1038
+ return (await response.json());
1039
+ return this._failTwinPermissionsRequest(response, 'revoke the twin permissions', twinId, {
1040
+ id: principalId,
1041
+ label: 'Principal'
1042
+ });
1043
+ }
1044
+ async listTwinGroups(twinId, signal) {
1045
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups`, {
1046
+ headers: { Accept: 'application/json' },
1047
+ signal
1048
+ });
1049
+ if (response.ok)
1050
+ return (await response.json());
1051
+ return this._failTwinPermissionsRequest(response, "list the twin's groups", twinId);
1052
+ }
1053
+ async getTwinGroup(twinId, groupId, signal) {
1054
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups/${groupId}`, {
1055
+ headers: { Accept: 'application/json' },
1056
+ signal
1057
+ });
1058
+ if (response.ok)
1059
+ return (await response.json());
1060
+ return this._failTwinPermissionsRequest(response, "get the twin's group", twinId, {
1061
+ id: groupId,
1062
+ label: 'Group'
1063
+ });
1064
+ }
1065
+ // ---------- Asset-domain permissions ----------
1066
+ async listAssetPermissions(options, signal) {
1067
+ const params = new URLSearchParams();
1068
+ if (options?.page !== undefined)
1069
+ params.set('page', options.page);
1070
+ if (options?.limit !== undefined)
1071
+ params.set('limit', options.limit.toString());
1072
+ const query = params.toString();
1073
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/permissions${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, signal });
1074
+ if (response.ok)
1075
+ return (await response.json());
1076
+ return this._failAssetPermissionsRequest(response, 'list the asset permissions');
1077
+ }
1078
+ async listAllAssetPermissions(signal) {
1079
+ const limit = 1000;
1080
+ const entries = [];
1081
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
1082
+ // last page — the same reason `listAllTwinPermissions` pages this way.
1083
+ let page;
1084
+ let received;
1085
+ do {
1086
+ const response = await this.listAssetPermissions({ page, limit }, signal);
1087
+ entries.push(...response.data);
1088
+ received = response.data.length;
1089
+ page = response.pagination.nextPage;
1090
+ } while (received >= limit);
1091
+ return entries;
1092
+ }
1093
+ async setAssetPermissions(principalId, body, signal) {
1094
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/permissions/${principalId}`, {
1095
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
1096
+ body: JSON.stringify(body),
1097
+ signal
1098
+ });
1099
+ if (response.ok)
1100
+ return (await response.json());
1101
+ return this._failAssetPermissionsRequest(response, 'set the asset permissions', {
1102
+ id: principalId,
1103
+ label: 'Principal'
1104
+ });
1105
+ }
1106
+ async deleteAssetPermissions(principalId, etag, signal) {
1107
+ const response = await this._httpClient.fetch(HttpMethod.Delete, `${this._baseUrl}/permissions/${principalId}`, {
1108
+ // The etag travels in the header, which is the transport the surface prefers and the
1109
+ // one that wins when both are present. It is repeated in the body — the other
1110
+ // carrier the endpoint binds — so the request still names an etag if a proxy strips
1111
+ // the header, or drops the body of a DELETE.
1112
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
1113
+ body: JSON.stringify({ etag }),
1114
+ signal
1115
+ });
1116
+ if (response.ok)
1117
+ return (await response.json());
1118
+ return this._failAssetPermissionsRequest(response, 'revoke the asset permissions', {
1119
+ id: principalId,
1120
+ label: 'Principal'
1121
+ });
1122
+ }
1123
+ async listAssetGroups(signal) {
1124
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/groups`, {
1125
+ headers: { Accept: 'application/json' },
1126
+ signal
1127
+ });
1128
+ if (response.ok)
1129
+ return (await response.json());
1130
+ return this._failAssetPermissionsRequest(response, "list the asset domain's default groups");
1131
+ }
1132
+ async getAssetGroup(name, signal) {
1133
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/groups/${encodeURIComponent(name)}`, { headers: { Accept: 'application/json' }, signal });
1134
+ if (response.ok)
1135
+ return (await response.json());
1136
+ return this._failAssetPermissionsRequest(response, "get the asset domain's default group", {
1137
+ id: name,
1138
+ label: 'Group'
1139
+ });
1140
+ }
1141
+ /**
1142
+ * Maps a refusal from the customer-wide asset permission surface onto the typed error its
1143
+ * status and cause name. The twin-scoped counterpart of this cannot be reused: its 404s are
1144
+ * about a twin, and this surface has none — a 404 naming no principal means the *name* was
1145
+ * unknown, not that a resource was missing.
1146
+ *
1147
+ * A 404 is dispatched on `item.kind`, which is what separates "that group has not been seeded
1148
+ * for this customer yet", which is worth retrying, from "no such group name", which is not.
1149
+ */
1150
+ async _failAssetPermissionsRequest(response, action, principal) {
1151
+ if (response.status === 401) {
1152
+ throw new TwinUnauthenticatedError('Not authenticated');
1153
+ }
1154
+ const errorBody = await readErrorBody(response);
1155
+ switch (response.status) {
1156
+ case 400:
1157
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1158
+ case 403:
1159
+ // The message is the server's own, never a reason invented here: a Twin service that
1160
+ // could not evaluate the request answers a bodiless 403, and `errorBody.cause` is
1161
+ // what separates that from a genuine `InsufficientPermissions`.
1162
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1163
+ case 404:
1164
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
1165
+ throw new TwinPrincipalNotFoundError(`${principal.label} ${principal.id} not found on the asset domain`, errorBody);
1166
+ }
1167
+ throw new TwinNotFoundError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1168
+ case 409:
1169
+ throw new AssetPermissionsConflictError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1170
+ case 503:
1171
+ throw new TwinTemporarilyUnavailableError(`Failed to ${action}: ${errorBody.detail}`, errorBody, retryAfterSeconds(response));
1172
+ }
1173
+ // A 500 can also mean the Permission service failed: the Twin service answers 503 when the
1174
+ // call threw, but 500 when it returned an upstream failure. Only the 503 half is classified
1175
+ // as retryable here, because the 500 arrives as a ProblemDetails payload with no cause to
1176
+ // recognise it by.
1177
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
1178
+ }
1179
+ /**
1180
+ * Maps a refusal from the twin-scoped permission surface onto the typed error its status
1181
+ * and cause name. Shared by all six methods because the surface answers them uniformly:
1182
+ * the status is decided by the request's shape and the caller's grants, not by the route.
1183
+ *
1184
+ * A 404 is dispatched on `item.kind`, which is the only thing separating "no such twin, or
1185
+ * none you may see" from "the principal you named is not there" — the two arrive on the
1186
+ * same status from the same route.
1187
+ */
1188
+ async _failTwinPermissionsRequest(response, action, twinId, principal) {
1189
+ if (response.status === 401) {
1190
+ throw new TwinUnauthenticatedError('Not authenticated');
1191
+ }
1192
+ const errorBody = await readErrorBody(response);
1193
+ switch (response.status) {
1194
+ case 400:
1195
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1196
+ case 403:
1197
+ // The message is the server's own, never a reason invented here: a 403 does not
1198
+ // always mean "you lack ManageTwinPermissions". `/permissions/me` has no capability
1199
+ // gate at all, and a Twin service that could not evaluate the request answers a
1200
+ // bodiless 403 — `errorBody.cause` is what separates those, so it travels with it.
1201
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1202
+ case 404:
1203
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
1204
+ throw new TwinPrincipalNotFoundError(`${principal.label} ${principal.id} not found on twin ${twinId}`, errorBody);
1205
+ }
1206
+ throw new TwinNotFoundError(`Twin ${twinId} not found`, errorBody);
1207
+ case 409:
1208
+ throw new TwinPermissionsConflictError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
1209
+ case 503:
1210
+ throw new TwinTemporarilyUnavailableError(`Failed to ${action}: ${errorBody.detail}`, errorBody, retryAfterSeconds(response));
1211
+ }
1212
+ // Note that a 500 can also mean the Permission service failed: the Twin service answers 503
1213
+ // when the call threw, but 500 when it returned an upstream failure. Only the 503 half is
1214
+ // classified as retryable here, because the 500 arrives as a ProblemDetails payload with no
1215
+ // cause to recognise it by.
1216
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
1217
+ }
903
1218
  async getAssetTypeMetaSchema(signal) {
904
1219
  const url = `${this._assetTypeCatalogUrl}/meta-schema`;
905
1220
  const response = await this._httpClient.fetch(HttpMethod.Get, url, {