@twinfinity/twin 6.1.1 → 6.1.2-ci.31342

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 (99) 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 +95 -33
  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/SinglePropertyValueType.html +1 -1
  85. package/docs/types/TwinBoundCapabilityName.html +8 -0
  86. package/docs/types/TwinCapability.html +11 -5
  87. package/docs/types/TwinCapabilityName.html +8 -0
  88. package/docs/types/TwinDiagnosticCode.html +1 -1
  89. package/docs/types/TwinErrorCause.html +1 -1
  90. package/docs/types/TwinErrorItemKind.html +1 -1
  91. package/docs/types/TwinPermissionsConflictCause.html +6 -0
  92. package/docs/types/TwinScopedCapability.html +9 -0
  93. package/docs/types/TwinScopedCapabilityName.html +8 -0
  94. package/package.json +3 -3
  95. package/src/TwinClient.ts +776 -15
  96. package/src/errors.ts +191 -4
  97. package/src/types/generated.ts +105 -35
  98. package/src/types/twin-api.ts +55 -1
  99. package/src/types/twin-permissions-api.ts +243 -6
package/src/TwinClient.ts CHANGED
@@ -73,8 +73,14 @@ import {
73
73
  AssetCatalogTooLargeError,
74
74
  isAssetCatalogTooLargeErrorBody,
75
75
  TwinUnauthenticatedError,
76
+ TwinPermissionsConflictError,
77
+ TwinPermissionsForbiddenError,
78
+ TwinPrincipalNotFoundError,
79
+ TwinTemporarilyUnavailableError,
80
+ AssetPermissionsConflictError,
76
81
  readErrorBody
77
82
  } from './errors';
83
+ import type { TwinPermissionsConflictErrorBody, AssetPermissionsConflictErrorBody } from './errors';
78
84
  import type {
79
85
  AssetCategoryGet,
80
86
  AssetCategoryPut,
@@ -89,7 +95,18 @@ import type {
89
95
  ApiCreateUploadSessionResponse,
90
96
  UploadStatusGet
91
97
  } from './types/asset-attachment-api';
92
- import type { MyPermissions } from './types/twin-permissions-api';
98
+ import type {
99
+ MyPermissions,
100
+ MyTwinPermissions,
101
+ TwinGroupGet,
102
+ TwinGroupsGet,
103
+ TwinPermissionsPut,
104
+ TwinPrincipalPermissionsGet,
105
+ AssetGroupGet,
106
+ AssetGroupsGet,
107
+ AssetPermissionsPut,
108
+ AssetPrincipalPermissionsGet
109
+ } from './types/twin-permissions-api';
93
110
  import { Twin } from './types/Twin';
94
111
  import { TwinGeometryInfo } from './types/TwinGeometryInfo';
95
112
 
@@ -102,9 +119,23 @@ export interface ITwinClient {
102
119
  readonly baseUrl: string;
103
120
 
104
121
  /**
105
- * @param relatedTo - Filter twins by container ID.
122
+ * @param options - `relatedTo` filters by container ID; `includeDeleted` includes soft-deleted twins
123
+ * (returned redacted, default false); `page`/`limit` control pagination.
106
124
  * @throws {TwinValidationError} On invalid query parameters (400).
107
125
  */
126
+ listTwins(
127
+ options?: {
128
+ relatedTo?: string;
129
+ includeDeleted?: boolean;
130
+ page?: string;
131
+ limit?: number;
132
+ },
133
+ signal?: AbortSignal
134
+ ): Promise<PaginatedResponse<ListTwinItem>>;
135
+ /**
136
+ * @deprecated Use the options-object form: `listTwins({ relatedTo, page, limit }, signal)`.
137
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
138
+ */
108
139
  listTwins(
109
140
  relatedTo?: string,
110
141
  page?: string,
@@ -114,7 +145,16 @@ export interface ITwinClient {
114
145
 
115
146
  /**
116
147
  * Fetches all twins by auto-paginating through {@link listTwins}. Caps at 1000 pages.
117
- * @param relatedTo - Filter twins by container ID.
148
+ * @param options - `relatedTo` filters by container ID; `includeDeleted` includes soft-deleted twins
149
+ * (returned redacted, default false).
150
+ */
151
+ listAllTwins(
152
+ options?: { relatedTo?: string; includeDeleted?: boolean },
153
+ signal?: AbortSignal
154
+ ): Promise<ListTwinItem[]>;
155
+ /**
156
+ * @deprecated Use the options-object form: `listAllTwins({ relatedTo }, signal)`.
157
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
118
158
  */
119
159
  listAllTwins(relatedTo?: string, signal?: AbortSignal): Promise<ListTwinItem[]>;
120
160
 
@@ -321,9 +361,12 @@ export interface ITwinClient {
321
361
  * All operations succeed or all are rolled back.
322
362
  *
323
363
  * @param operations - Array of batch operations (max 1000).
324
- * @throws {AssetValidationError} On validation failure (400).
364
+ * @throws {AssetBatchError} When the batch rolled back. The error's `response` has per-item
365
+ * results with reverted statuses, and is thrown for any status the failing item carried --
366
+ * 400, 404 or 409.
367
+ * @throws {AssetValidationError} When the whole request was rejected before any item ran (400):
368
+ * an empty batch, duplicate ids, a body/URL twin mismatch, oversized metadata.
325
369
  * @throws {AssetForbiddenError} On insufficient permissions (403).
326
- * @throws {AssetBatchError} On conflict (409) or not found (404). The error's `response` has per-item results with reverted statuses.
327
370
  */
328
371
  batchAssets(twinId: string, operations: AssetBatchOperation[], signal?: AbortSignal): Promise<AssetBatchResponse>;
329
372
 
@@ -501,9 +544,10 @@ export interface ITwinClient {
501
544
 
502
545
  /**
503
546
  * Returns the caller's named, customer-scoped capabilities against the
504
- * Twin service (e.g. `ViewAssetTypes`, `EditAssetTypes`). The admin app
505
- * uses this to enable or disable buttons instead of letting the user
506
- * click through to a 403.
547
+ * Twin service — the asset-domain vocabulary `ViewAssets`, `EditAssets`,
548
+ * `DeleteAssets`, `EditAssetTypes` and `ManageAssetPermissions`.
549
+ * The admin app uses this to enable or disable
550
+ * buttons instead of letting the user click through to a 403.
507
551
  *
508
552
  * No client-side cache — every call hits the wire. Underlying scope
509
553
  * grants don't shift within a session, so callers are expected to
@@ -515,9 +559,267 @@ export interface ITwinClient {
515
559
  * on 403 (authenticated but no Twin-service capabilities). Unknown
516
560
  * capability strings should be ignored — adding a new capability is a
517
561
  * non-breaking change.
562
+ *
563
+ * A 503 throws rather than answering with an empty array: the Permission
564
+ * service gave no answer, so the capabilities are unknown rather than
565
+ * absent, and the call is safe to retry unchanged.
518
566
  */
519
567
  getMyPermissions(signal?: AbortSignal): Promise<MyPermissions>;
520
568
 
569
+ // ---------- Twin-scoped permissions (TWINS-2982) ----------
570
+
571
+ /**
572
+ * Returns the capabilities the caller holds on one specific twin. Open to every
573
+ * authenticated caller — an empty list is an honest answer rather than a refusal —
574
+ * so a UI can gate its controls on this before it knows anything about the caller.
575
+ *
576
+ * The answer mixes vocabularies: the twin's own `TwinScopedCapability` values, plus the
577
+ * asset-instance capabilities `ViewAssets`, `EditAssets` and `DeleteAssets`, which gate the
578
+ * assets hanging off this twin. The catalog capabilities and `ManageAssetPermissions` are not
579
+ * twin-bound and are absent here.
580
+ *
581
+ * Distinct from {@link getMyPermissions}, which answers the customer-scoped question over the
582
+ * whole asset domain. That one returns an empty list on 403; this route has no capability gate
583
+ * to refuse at, so a 403 here means something outside the Twin service refused, and is thrown.
584
+ *
585
+ * @throws {TwinNotFoundError} The twin does not exist, or the caller cannot see it — the
586
+ * surface does not distinguish the two.
587
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
588
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
589
+ * safe to retry unchanged.
590
+ */
591
+ getMyTwinPermissions(twinId: string, signal?: AbortSignal): Promise<MyTwinPermissions>;
592
+
593
+ /**
594
+ * Lists one page of the permission entries on every scope the twin owns. Each entry carries
595
+ * the etag of the scope it lives on, which is the value to send back when mutating that
596
+ * principal.
597
+ *
598
+ * `pagination.nextPage` is emitted unconditionally — it is non-null on the last page too — so
599
+ * it is not a termination signal. Page until a response comes back with fewer entries than the
600
+ * limit asked for, or use {@link listAllTwinPermissions}, which does that. On an access-review
601
+ * surface, stopping early under-reports who holds capabilities on the twin.
602
+ *
603
+ * @throws {TwinPermissionsForbiddenError} The caller may see the twin but does not hold
604
+ * `ManageTwinPermissions` on it (403).
605
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
606
+ * @throws {TwinValidationError} The limit is out of range, or the pagination token is invalid
607
+ * or does not match the query it was issued for (400).
608
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` — the twin owns no Twinfinity
609
+ * scope, so there is no permission state to read (409). Retrying will not help.
610
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
611
+ * safe to retry unchanged.
612
+ */
613
+ listTwinPermissions(
614
+ twinId: string,
615
+ options?: { page?: string; limit?: number },
616
+ signal?: AbortSignal
617
+ ): Promise<PaginatedResponse<TwinPrincipalPermissionsGet>>;
618
+
619
+ /**
620
+ * Every permission entry on the twin, following the pagination to the end. The whole
621
+ * access-control list of one twin is a handful of entries, and reviewing it is the point of
622
+ * the surface, so this — rather than {@link listTwinPermissions} — is what most callers want.
623
+ *
624
+ * Throws whatever {@link listTwinPermissions} throws, from whichever page hit it.
625
+ */
626
+ listAllTwinPermissions(twinId: string, signal?: AbortSignal): Promise<TwinPrincipalPermissionsGet[]>;
627
+
628
+ /**
629
+ * Replaces the principal's capabilities on the twin, and returns the entry as stored
630
+ * along with the etag to chain the next write on. `permissions: []` revokes all four
631
+ * capabilities without removing an entry that carries bits outside this vocabulary.
632
+ *
633
+ * `body.etag` may be omitted only for a principal that holds nothing on the twin yet;
634
+ * see {@link TwinPermissionsPut}. Both etag refusals arrive as
635
+ * {@link TwinPermissionsConflictError}, carrying the current state to retry from.
636
+ *
637
+ * @throws {TwinValidationError} The target is immutable through this surface — the twin's own
638
+ * default groups and the seeded system principals, marked by
639
+ * {@link TwinPrincipalPermissionsGet.readOnly} (cause `SystemPrincipalImmutable`) — or the
640
+ * write asked for `ManageTwinPermissions` on the all-authenticated principal (cause
641
+ * `ManagePermissionsNotGrantableToAllAuthenticated`), which would make the whole tenant an
642
+ * administrator of the twin (400).
643
+ * @throws {TwinPrincipalNotFoundError} No principal carries this id (404).
644
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
645
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
646
+ * @throws {TwinPermissionsConflictError} `EtagRequired`, `EtagMismatch` or
647
+ * `ScopesNotProvisioned` (409); the first two carry `currentState`.
648
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
649
+ * safe to retry unchanged.
650
+ */
651
+ setTwinPermissions(
652
+ twinId: string,
653
+ principalId: string,
654
+ body: TwinPermissionsPut,
655
+ signal?: AbortSignal
656
+ ): Promise<TwinPrincipalPermissionsGet>;
657
+
658
+ /**
659
+ * Removes the principal from the twin's access control list entirely — including any
660
+ * bits outside this capability vocabulary, which is what separates it from a
661
+ * {@link setTwinPermissions} call with an empty list.
662
+ *
663
+ * The etag is always required: a delete always replaces existing grants.
664
+ *
665
+ * @throws {TwinPrincipalNotFoundError} The principal holds nothing on the twin, so there is
666
+ * nothing to revoke and no entry to have read an etag from — whatever value was sent (404).
667
+ * @throws {TwinValidationError} The principal is immutable through this surface (400), decided
668
+ * before the etag is looked at.
669
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
670
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
671
+ * @throws {TwinPermissionsConflictError} `EtagMismatch` or `ScopesNotProvisioned` (409).
672
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
673
+ * safe to retry unchanged.
674
+ */
675
+ deleteTwinPermissions(
676
+ twinId: string,
677
+ principalId: string,
678
+ etag: string,
679
+ signal?: AbortSignal
680
+ ): Promise<TwinPrincipalPermissionsGet>;
681
+
682
+ /**
683
+ * Returns the three default groups the twin owns — `TwinReaders`, `TwinContributors`,
684
+ * `TwinAdministrators` — under the ids to name in {@link setTwinPermissions}.
685
+ *
686
+ * Not a picker for the principals a twin can be granted to: that is every group in the
687
+ * tenant, which the Permission service lists. These are the groups the twin itself
688
+ * owns, whose membership confers its preset capability levels.
689
+ *
690
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
691
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
692
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` (409).
693
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
694
+ * safe to retry unchanged.
695
+ */
696
+ listTwinGroups(twinId: string, signal?: AbortSignal): Promise<TwinGroupsGet>;
697
+
698
+ /**
699
+ * Returns one of the twin's own default groups.
700
+ *
701
+ * @throws {TwinPrincipalNotFoundError} The twin does not own this group — thrown even when the
702
+ * group exists in the tenant and holds capabilities on this twin, since ownership, not
703
+ * existence, is what this route answers (404).
704
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
705
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
706
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` (409).
707
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
708
+ * safe to retry unchanged.
709
+ */
710
+ getTwinGroup(twinId: string, groupId: string, signal?: AbortSignal): Promise<TwinGroupGet>;
711
+
712
+ // ---------- Asset-domain permissions ----------
713
+
714
+ /**
715
+ * Lists one page of the principals holding capabilities on the customer-wide asset domain, with
716
+ * the capabilities each of them holds. The companion administration surface to
717
+ * {@link getMyPermissions}, which answers only for the caller.
718
+ *
719
+ * `pagination.nextPage` is emitted unconditionally — it is non-null on the last page too — so it
720
+ * is not a termination signal. Page until a response comes back with fewer entries than the
721
+ * limit asked for, or use {@link listAllAssetPermissions}, which does that. On an access-review
722
+ * surface, stopping early under-reports who holds capabilities.
723
+ *
724
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
725
+ * (403).
726
+ * @throws {TwinValidationError} The limit is out of range, or the pagination token is invalid
727
+ * (400).
728
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
729
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
730
+ * safe to retry unchanged.
731
+ */
732
+ listAssetPermissions(
733
+ options?: { page?: string; limit?: number },
734
+ signal?: AbortSignal
735
+ ): Promise<PaginatedResponse<AssetPrincipalPermissionsGet>>;
736
+
737
+ /** Every entry of {@link listAssetPermissions}, paged to exhaustion. */
738
+ listAllAssetPermissions(signal?: AbortSignal): Promise<AssetPrincipalPermissionsGet[]>;
739
+
740
+ /**
741
+ * Replaces what one principal holds on the asset domain. Capabilities absent from
742
+ * `body.permissions` are revoked, and an empty list revokes everything — leaving the principal
743
+ * with no listing entry at all, exactly as {@link deleteAssetPermissions} does.
744
+ *
745
+ * `body.etag` is required whenever the principal already holds capabilities, and may be omitted
746
+ * only to create the grants of one that holds none.
747
+ *
748
+ * @throws {TwinPrincipalNotFoundError} No group in the tenant carries this id (404).
749
+ * @throws {TwinValidationError} `SystemPrincipalImmutable` — a seeded default group, whose
750
+ * preset is conferred by membership rather than by writing here — or
751
+ * `ManagePermissionsNotGrantableToAllAuthenticated` (400).
752
+ * @throws {AssetPermissionsConflictError} `EtagRequired` or `EtagMismatch` (409), carrying the
753
+ * current state to retry from.
754
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
755
+ * (403).
756
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
757
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
758
+ * safe to retry unchanged.
759
+ */
760
+ setAssetPermissions(
761
+ principalId: string,
762
+ body: AssetPermissionsPut,
763
+ signal?: AbortSignal
764
+ ): Promise<AssetPrincipalPermissionsGet>;
765
+
766
+ /**
767
+ * Removes the principal from the domain's access control list entirely, answering with it and
768
+ * an empty capability list.
769
+ *
770
+ * The answer carries the `etag` the revocation wrote: it belongs to the domain, not to the
771
+ * principal, so a revoked principal having no listing entry does not cost the caller one. Chain
772
+ * the next write on it exactly as with {@link setAssetPermissions}, without re-reading
773
+ * {@link listAssetPermissions}.
774
+ *
775
+ * The etag is always required — a revocation always replaces existing grants — and a request
776
+ * naming none is refused with a `400` rather than a conflict, before anything else is asked.
777
+ *
778
+ * @throws {TwinPrincipalNotFoundError} No group in the tenant carries this id, or it holds
779
+ * nothing here for a revocation to remove (404).
780
+ * @throws {TwinValidationError} `SystemPrincipalImmutable` or `EtagRequired` (400).
781
+ * @throws {AssetPermissionsConflictError} `EtagMismatch` (409).
782
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
783
+ * (403).
784
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
785
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
786
+ * safe to retry unchanged.
787
+ */
788
+ deleteAssetPermissions(
789
+ principalId: string,
790
+ etag: string,
791
+ signal?: AbortSignal
792
+ ): Promise<AssetPrincipalPermissionsGet>;
793
+
794
+ /**
795
+ * Returns the asset domain's default groups — `AssetsReaders`, `AssetsContributors`,
796
+ * `AssetsAdministrators` — under the ids whose membership confers their preset capabilities.
797
+ *
798
+ * A group whose seeding has not completed for this customer is omitted rather than listed under
799
+ * a placeholder title, so a short list is a state to poll rather than an error.
800
+ *
801
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
802
+ * (403).
803
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
804
+ * @throws {TwinTemporarilyUnavailableError} The titles could not be resolved (503); safe to
805
+ * retry unchanged.
806
+ */
807
+ listAssetGroups(signal?: AbortSignal): Promise<AssetGroupsGet>;
808
+
809
+ /**
810
+ * Returns one default group by its stable name, case-insensitively.
811
+ *
812
+ * @throws {TwinPrincipalNotFoundError} The name is one of the three but its group has not been
813
+ * seeded for this customer yet — safe to retry (404).
814
+ * @throws {TwinNotFoundError} The name is not one of the three; retrying will not help (404).
815
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
816
+ * (403).
817
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
818
+ * @throws {TwinTemporarilyUnavailableError} The title could not be resolved (503); safe to
819
+ * retry unchanged.
820
+ */
821
+ getAssetGroup(name: string, signal?: AbortSignal): Promise<AssetGroupGet>;
822
+
521
823
  /**
522
824
  * Returns the Twinfinity meta-schema document — the JSON Schema describing
523
825
  * valid asset-type metadata schemas. The bytes the backend itself
@@ -640,6 +942,42 @@ export interface ITwinClient {
640
942
  getAssetTypeCatalog(signal?: AbortSignal): Promise<AssetCatalogResponse>;
641
943
  }
642
944
 
945
+ /**
946
+ * The `Retry-After` of a 503, in seconds, when the response carried a usable one. The Twin service
947
+ * sends a delta-seconds value; an HTTP-date is permitted by RFC 7231 and simply yields nothing here
948
+ * rather than a nonsense delay.
949
+ */
950
+ function retryAfterSeconds(response: Response): number | undefined {
951
+ const header = response.headers?.get('Retry-After');
952
+ if (header === null || header === undefined) return undefined;
953
+
954
+ const seconds = Number(header);
955
+ return Number.isInteger(seconds) && seconds >= 0 ? seconds : undefined;
956
+ }
957
+
958
+ /**
959
+ * The batch response body carried by a failed batch, or `undefined` when the body is not one.
960
+ *
961
+ * A rolled-back batch answers with per-item results under the status of its *last failing item* --
962
+ * so the same endpoint returns a batch body under 400, 404 or 409, while a whole-request rejection
963
+ * (an empty batch, duplicate ids, a body/URL twin mismatch, oversized metadata) returns problem
964
+ * details under 400 as well. Status alone cannot tell those apart, and branching on it reported
965
+ * every per-item 400 -- a cross-twin id, a deleted asset, metadata failing its schema -- as a
966
+ * validation failure with no results for the caller to inspect.
967
+ */
968
+ async function readBatchResponse(response: Response): Promise<AssetBatchResponse | undefined> {
969
+ // Parse a clone: json() consumes the stream, and a body that turns out not to be a batch
970
+ // response still has to be readable as problem details or as text by the caller. Without
971
+ // clone -- some test doubles -- the probe is skipped rather than risk consuming the body.
972
+ if (typeof response.clone !== 'function') return undefined;
973
+ try {
974
+ const parsed = (await response.clone().json()) as AssetBatchResponse;
975
+ return Array.isArray(parsed?.results) ? parsed : undefined;
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ }
980
+
643
981
  export class TwinClient implements ITwinClient {
644
982
  private readonly _baseUrl: string;
645
983
  private readonly _assetTypeCatalogUrl: string;
@@ -664,16 +1002,59 @@ export class TwinClient implements ITwinClient {
664
1002
  return this._baseUrl;
665
1003
  }
666
1004
 
667
- async listTwins(
1005
+ listTwins(
1006
+ options?: {
1007
+ relatedTo?: string;
1008
+ includeDeleted?: boolean;
1009
+ page?: string;
1010
+ limit?: number;
1011
+ },
1012
+ signal?: AbortSignal
1013
+ ): Promise<PaginatedResponse<ListTwinItem>>;
1014
+ /**
1015
+ * @deprecated Use the options-object form: `listTwins({ relatedTo, page, limit }, signal)`.
1016
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
1017
+ */
1018
+ listTwins(
668
1019
  relatedTo?: string,
669
- page: string | undefined = undefined,
670
- limit = 100,
1020
+ page?: string,
1021
+ limit?: number,
671
1022
  signal?: AbortSignal
1023
+ ): Promise<PaginatedResponse<ListTwinItem>>;
1024
+ async listTwins(
1025
+ optionsOrRelatedTo?:
1026
+ | {
1027
+ relatedTo?: string;
1028
+ includeDeleted?: boolean;
1029
+ page?: string;
1030
+ limit?: number;
1031
+ }
1032
+ | string,
1033
+ pageOrSignal?: string | AbortSignal,
1034
+ positionalLimit?: number,
1035
+ positionalSignal?: AbortSignal
672
1036
  ): Promise<PaginatedResponse<ListTwinItem>> {
1037
+ let relatedTo: string | undefined;
1038
+ let includeDeleted = false;
1039
+ let page: string | undefined;
1040
+ let limit = 100;
1041
+ let signal: AbortSignal | undefined;
1042
+
1043
+ if (typeof optionsOrRelatedTo === 'string') {
1044
+ relatedTo = optionsOrRelatedTo;
1045
+ page = pageOrSignal as string | undefined;
1046
+ limit = positionalLimit ?? 100;
1047
+ signal = positionalSignal;
1048
+ } else {
1049
+ ({ relatedTo, includeDeleted = false, page = undefined, limit = 100 } = optionsOrRelatedTo ?? {});
1050
+ signal = pageOrSignal as AbortSignal | undefined;
1051
+ }
1052
+
673
1053
  const params = new URLSearchParams();
674
1054
  if (relatedTo) params.set('relatedTo', relatedTo);
675
1055
  if (page !== undefined) params.set('page', page);
676
1056
  if (limit !== 100) params.set('limit', limit.toString());
1057
+ if (includeDeleted) params.set('includeDeleted', 'true');
677
1058
 
678
1059
  const url = params.toString() ? `${this._baseUrl}?${params}` : this._baseUrl;
679
1060
 
@@ -695,14 +1076,41 @@ export class TwinClient implements ITwinClient {
695
1076
  throw new Error(`Failed to list twins: ${response.status} ${response.statusText} - ${errorText}`);
696
1077
  }
697
1078
 
698
- async listAllTwins(relatedTo?: string, signal?: AbortSignal): Promise<ListTwinItem[]> {
1079
+ listAllTwins(
1080
+ options?: { relatedTo?: string; includeDeleted?: boolean },
1081
+ signal?: AbortSignal
1082
+ ): Promise<ListTwinItem[]>;
1083
+ /**
1084
+ * @deprecated Use the options-object form: `listAllTwins({ relatedTo }, signal)`.
1085
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
1086
+ */
1087
+ listAllTwins(relatedTo?: string, signal?: AbortSignal): Promise<ListTwinItem[]>;
1088
+ async listAllTwins(
1089
+ optionsOrRelatedTo?: { relatedTo?: string; includeDeleted?: boolean } | string,
1090
+ signalArg?: AbortSignal
1091
+ ): Promise<ListTwinItem[]> {
1092
+ let relatedTo: string | undefined;
1093
+ let includeDeleted = false;
1094
+ let signal: AbortSignal | undefined;
1095
+
1096
+ if (typeof optionsOrRelatedTo === 'string') {
1097
+ relatedTo = optionsOrRelatedTo;
1098
+ signal = signalArg;
1099
+ } else {
1100
+ ({ relatedTo, includeDeleted = false } = optionsOrRelatedTo ?? {});
1101
+ signal = signalArg;
1102
+ }
1103
+
699
1104
  const allTwins: ListTwinItem[] = [];
700
1105
  let page: string | undefined = undefined;
701
1106
  const limit = 100;
702
1107
  const maxPages = 1000;
703
1108
 
704
1109
  for (let pageCount = 0; pageCount < maxPages; pageCount++) {
705
- const response = await this.listTwins(relatedTo, page, limit, signal);
1110
+ const response: PaginatedResponse<ListTwinItem> = await this.listTwins(
1111
+ { relatedTo, page, limit, includeDeleted },
1112
+ signal
1113
+ );
706
1114
  allTwins.push(...response.data);
707
1115
 
708
1116
  if (response.data.length < limit) {
@@ -1460,6 +1868,14 @@ export class TwinClient implements ITwinClient {
1460
1868
  return (await response.json()) as AssetBatchResponse;
1461
1869
  }
1462
1870
 
1871
+ // The body decides, not the status: a rolled-back batch is reported as a batch error at
1872
+ // whatever status its last failing item carried, and only a body that is not a batch
1873
+ // response falls through to the status arms below.
1874
+ const batchResponse = await readBatchResponse(response);
1875
+ if (batchResponse) {
1876
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, batchResponse);
1877
+ }
1878
+
1463
1879
  if (response.status === 400) {
1464
1880
  const errorBody = await readErrorBody(response);
1465
1881
  throw new AssetValidationError(`Asset batch validation failed: ${errorBody.detail}`, errorBody);
@@ -1470,9 +1886,12 @@ export class TwinClient implements ITwinClient {
1470
1886
  throw new AssetForbiddenError(`Asset batch forbidden: ${errorText}`);
1471
1887
  }
1472
1888
 
1889
+ // Reached only where the probe above could not read the body -- a Response without clone(),
1890
+ // which some test doubles are. These two statuses only ever carry a batch response, so read
1891
+ // it directly rather than losing the per-item results to the generic error below.
1473
1892
  if (response.status === 404 || response.status === 409) {
1474
- const batchResponse = (await response.json()) as AssetBatchResponse;
1475
- throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, batchResponse);
1893
+ const rolledBack = (await response.json()) as AssetBatchResponse;
1894
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, rolledBack);
1476
1895
  }
1477
1896
 
1478
1897
  const errorText = await response.text();
@@ -2027,6 +2446,348 @@ export class TwinClient implements ITwinClient {
2027
2446
  throw new Error(`Failed to fetch permissions: ${response.status} ${response.statusText}`);
2028
2447
  }
2029
2448
 
2449
+ // ---------- Twin-scoped permissions (TWINS-2982) ----------
2450
+
2451
+ async getMyTwinPermissions(twinId: string, signal?: AbortSignal): Promise<MyTwinPermissions> {
2452
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/permissions/me`, {
2453
+ headers: { Accept: 'application/json' },
2454
+ signal
2455
+ });
2456
+
2457
+ if (response.ok) return (await response.json()) as MyTwinPermissions;
2458
+ return this._failTwinPermissionsRequest(response, 'read the capabilities on the twin', twinId);
2459
+ }
2460
+
2461
+ async listTwinPermissions(
2462
+ twinId: string,
2463
+ options?: { page?: string; limit?: number },
2464
+ signal?: AbortSignal
2465
+ ): Promise<PaginatedResponse<TwinPrincipalPermissionsGet>> {
2466
+ const params = new URLSearchParams();
2467
+ if (options?.page !== undefined) params.set('page', options.page);
2468
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2469
+
2470
+ const query = params.toString();
2471
+ const response = await this._httpClient.fetch(
2472
+ HttpMethod.Get,
2473
+ `${this._baseUrl}/${twinId}/permissions${query ? `?${query}` : ''}`,
2474
+ { headers: { Accept: 'application/json' }, signal }
2475
+ );
2476
+
2477
+ if (response.ok) return (await response.json()) as PaginatedResponse<TwinPrincipalPermissionsGet>;
2478
+ return this._failTwinPermissionsRequest(response, 'list the twin permissions', twinId);
2479
+ }
2480
+
2481
+ async listAllTwinPermissions(twinId: string, signal?: AbortSignal): Promise<TwinPrincipalPermissionsGet[]> {
2482
+ const limit = 1000;
2483
+ const entries: TwinPrincipalPermissionsGet[] = [];
2484
+
2485
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
2486
+ // last page — the same reason `listAllTwins` pages this way.
2487
+ let page: string | undefined;
2488
+ let received: number;
2489
+ do {
2490
+ const response = await this.listTwinPermissions(twinId, { page, limit }, signal);
2491
+ entries.push(...response.data);
2492
+ received = response.data.length;
2493
+ page = response.pagination.nextPage;
2494
+ } while (received >= limit);
2495
+
2496
+ return entries;
2497
+ }
2498
+
2499
+ async setTwinPermissions(
2500
+ twinId: string,
2501
+ principalId: string,
2502
+ body: TwinPermissionsPut,
2503
+ signal?: AbortSignal
2504
+ ): Promise<TwinPrincipalPermissionsGet> {
2505
+ const response = await this._httpClient.fetch(
2506
+ HttpMethod.Put,
2507
+ `${this._baseUrl}/${twinId}/permissions/${principalId}`,
2508
+ {
2509
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2510
+ body: JSON.stringify(body),
2511
+ signal
2512
+ }
2513
+ );
2514
+
2515
+ if (response.ok) return (await response.json()) as TwinPrincipalPermissionsGet;
2516
+ return this._failTwinPermissionsRequest(response, 'set the twin permissions', twinId, {
2517
+ id: principalId,
2518
+ label: 'Principal'
2519
+ });
2520
+ }
2521
+
2522
+ async deleteTwinPermissions(
2523
+ twinId: string,
2524
+ principalId: string,
2525
+ etag: string,
2526
+ signal?: AbortSignal
2527
+ ): Promise<TwinPrincipalPermissionsGet> {
2528
+ const response = await this._httpClient.fetch(
2529
+ HttpMethod.Delete,
2530
+ `${this._baseUrl}/${twinId}/permissions/${principalId}`,
2531
+ {
2532
+ // The etag travels in the header, which is the transport the surface prefers and
2533
+ // the one that wins when both are present. It is repeated in the body — the other
2534
+ // carrier ADR-0008 documents, and the one the endpoint binds — so that the request
2535
+ // still names an etag if a proxy strips the header, or drops the body of a DELETE.
2536
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
2537
+ body: JSON.stringify({ etag }),
2538
+ signal
2539
+ }
2540
+ );
2541
+
2542
+ if (response.ok) return (await response.json()) as TwinPrincipalPermissionsGet;
2543
+ return this._failTwinPermissionsRequest(response, 'revoke the twin permissions', twinId, {
2544
+ id: principalId,
2545
+ label: 'Principal'
2546
+ });
2547
+ }
2548
+
2549
+ async listTwinGroups(twinId: string, signal?: AbortSignal): Promise<TwinGroupsGet> {
2550
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups`, {
2551
+ headers: { Accept: 'application/json' },
2552
+ signal
2553
+ });
2554
+
2555
+ if (response.ok) return (await response.json()) as TwinGroupsGet;
2556
+ return this._failTwinPermissionsRequest(response, "list the twin's groups", twinId);
2557
+ }
2558
+
2559
+ async getTwinGroup(twinId: string, groupId: string, signal?: AbortSignal): Promise<TwinGroupGet> {
2560
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups/${groupId}`, {
2561
+ headers: { Accept: 'application/json' },
2562
+ signal
2563
+ });
2564
+
2565
+ if (response.ok) return (await response.json()) as TwinGroupGet;
2566
+ return this._failTwinPermissionsRequest(response, "get the twin's group", twinId, {
2567
+ id: groupId,
2568
+ label: 'Group'
2569
+ });
2570
+ }
2571
+
2572
+ // ---------- Asset-domain permissions ----------
2573
+
2574
+ async listAssetPermissions(
2575
+ options?: { page?: string; limit?: number },
2576
+ signal?: AbortSignal
2577
+ ): Promise<PaginatedResponse<AssetPrincipalPermissionsGet>> {
2578
+ const params = new URLSearchParams();
2579
+ if (options?.page !== undefined) params.set('page', options.page);
2580
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2581
+
2582
+ const query = params.toString();
2583
+ const response = await this._httpClient.fetch(
2584
+ HttpMethod.Get,
2585
+ `${this._baseUrl}/permissions${query ? `?${query}` : ''}`,
2586
+ { headers: { Accept: 'application/json' }, signal }
2587
+ );
2588
+
2589
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetPrincipalPermissionsGet>;
2590
+ return this._failAssetPermissionsRequest(response, 'list the asset permissions');
2591
+ }
2592
+
2593
+ async listAllAssetPermissions(signal?: AbortSignal): Promise<AssetPrincipalPermissionsGet[]> {
2594
+ const limit = 1000;
2595
+ const entries: AssetPrincipalPermissionsGet[] = [];
2596
+
2597
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
2598
+ // last page — the same reason `listAllTwinPermissions` pages this way.
2599
+ let page: string | undefined;
2600
+ let received: number;
2601
+ do {
2602
+ const response = await this.listAssetPermissions({ page, limit }, signal);
2603
+ entries.push(...response.data);
2604
+ received = response.data.length;
2605
+ page = response.pagination.nextPage;
2606
+ } while (received >= limit);
2607
+
2608
+ return entries;
2609
+ }
2610
+
2611
+ async setAssetPermissions(
2612
+ principalId: string,
2613
+ body: AssetPermissionsPut,
2614
+ signal?: AbortSignal
2615
+ ): Promise<AssetPrincipalPermissionsGet> {
2616
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/permissions/${principalId}`, {
2617
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2618
+ body: JSON.stringify(body),
2619
+ signal
2620
+ });
2621
+
2622
+ if (response.ok) return (await response.json()) as AssetPrincipalPermissionsGet;
2623
+ return this._failAssetPermissionsRequest(response, 'set the asset permissions', {
2624
+ id: principalId,
2625
+ label: 'Principal'
2626
+ });
2627
+ }
2628
+
2629
+ async deleteAssetPermissions(
2630
+ principalId: string,
2631
+ etag: string,
2632
+ signal?: AbortSignal
2633
+ ): Promise<AssetPrincipalPermissionsGet> {
2634
+ const response = await this._httpClient.fetch(
2635
+ HttpMethod.Delete,
2636
+ `${this._baseUrl}/permissions/${principalId}`,
2637
+ {
2638
+ // The etag travels in the header, which is the transport the surface prefers and the
2639
+ // one that wins when both are present. It is repeated in the body — the other
2640
+ // carrier the endpoint binds — so the request still names an etag if a proxy strips
2641
+ // the header, or drops the body of a DELETE.
2642
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
2643
+ body: JSON.stringify({ etag }),
2644
+ signal
2645
+ }
2646
+ );
2647
+
2648
+ if (response.ok) return (await response.json()) as AssetPrincipalPermissionsGet;
2649
+ return this._failAssetPermissionsRequest(response, 'revoke the asset permissions', {
2650
+ id: principalId,
2651
+ label: 'Principal'
2652
+ });
2653
+ }
2654
+
2655
+ async listAssetGroups(signal?: AbortSignal): Promise<AssetGroupsGet> {
2656
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/groups`, {
2657
+ headers: { Accept: 'application/json' },
2658
+ signal
2659
+ });
2660
+
2661
+ if (response.ok) return (await response.json()) as AssetGroupsGet;
2662
+ return this._failAssetPermissionsRequest(response, "list the asset domain's default groups");
2663
+ }
2664
+
2665
+ async getAssetGroup(name: string, signal?: AbortSignal): Promise<AssetGroupGet> {
2666
+ const response = await this._httpClient.fetch(
2667
+ HttpMethod.Get,
2668
+ `${this._baseUrl}/groups/${encodeURIComponent(name)}`,
2669
+ { headers: { Accept: 'application/json' }, signal }
2670
+ );
2671
+
2672
+ if (response.ok) return (await response.json()) as AssetGroupGet;
2673
+ return this._failAssetPermissionsRequest(response, "get the asset domain's default group", {
2674
+ id: name,
2675
+ label: 'Group'
2676
+ });
2677
+ }
2678
+
2679
+ /**
2680
+ * Maps a refusal from the customer-wide asset permission surface onto the typed error its
2681
+ * status and cause name. The twin-scoped counterpart of this cannot be reused: its 404s are
2682
+ * about a twin, and this surface has none — a 404 naming no principal means the *name* was
2683
+ * unknown, not that a resource was missing.
2684
+ *
2685
+ * A 404 is dispatched on `item.kind`, which is what separates "that group has not been seeded
2686
+ * for this customer yet", which is worth retrying, from "no such group name", which is not.
2687
+ */
2688
+ private async _failAssetPermissionsRequest(
2689
+ response: Response,
2690
+ action: string,
2691
+ principal?: { id: string; label: string }
2692
+ ): Promise<never> {
2693
+ if (response.status === 401) {
2694
+ throw new TwinUnauthenticatedError('Not authenticated');
2695
+ }
2696
+
2697
+ const errorBody = await readErrorBody(response);
2698
+ switch (response.status) {
2699
+ case 400:
2700
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2701
+ case 403:
2702
+ // The message is the server's own, never a reason invented here: a Twin service that
2703
+ // could not evaluate the request answers a bodiless 403, and `errorBody.cause` is
2704
+ // what separates that from a genuine `InsufficientPermissions`.
2705
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2706
+ case 404:
2707
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
2708
+ throw new TwinPrincipalNotFoundError(
2709
+ `${principal.label} ${principal.id} not found on the asset domain`,
2710
+ errorBody
2711
+ );
2712
+ }
2713
+ throw new TwinNotFoundError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2714
+ case 409:
2715
+ throw new AssetPermissionsConflictError(
2716
+ `Failed to ${action}: ${errorBody.detail}`,
2717
+ errorBody as AssetPermissionsConflictErrorBody
2718
+ );
2719
+ case 503:
2720
+ throw new TwinTemporarilyUnavailableError(
2721
+ `Failed to ${action}: ${errorBody.detail}`,
2722
+ errorBody,
2723
+ retryAfterSeconds(response)
2724
+ );
2725
+ }
2726
+
2727
+ // A 500 can also mean the Permission service failed: the Twin service answers 503 when the
2728
+ // call threw, but 500 when it returned an upstream failure. Only the 503 half is classified
2729
+ // as retryable here, because the 500 arrives as a ProblemDetails payload with no cause to
2730
+ // recognise it by.
2731
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
2732
+ }
2733
+
2734
+ /**
2735
+ * Maps a refusal from the twin-scoped permission surface onto the typed error its status
2736
+ * and cause name. Shared by all six methods because the surface answers them uniformly:
2737
+ * the status is decided by the request's shape and the caller's grants, not by the route.
2738
+ *
2739
+ * A 404 is dispatched on `item.kind`, which is the only thing separating "no such twin, or
2740
+ * none you may see" from "the principal you named is not there" — the two arrive on the
2741
+ * same status from the same route.
2742
+ */
2743
+ private async _failTwinPermissionsRequest(
2744
+ response: Response,
2745
+ action: string,
2746
+ twinId: string,
2747
+ principal?: { id: string; label: string }
2748
+ ): Promise<never> {
2749
+ if (response.status === 401) {
2750
+ throw new TwinUnauthenticatedError('Not authenticated');
2751
+ }
2752
+
2753
+ const errorBody = await readErrorBody(response);
2754
+ switch (response.status) {
2755
+ case 400:
2756
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2757
+ case 403:
2758
+ // The message is the server's own, never a reason invented here: a 403 does not
2759
+ // always mean "you lack ManageTwinPermissions". `/permissions/me` has no capability
2760
+ // gate at all, and a Twin service that could not evaluate the request answers a
2761
+ // bodiless 403 — `errorBody.cause` is what separates those, so it travels with it.
2762
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2763
+ case 404:
2764
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
2765
+ throw new TwinPrincipalNotFoundError(
2766
+ `${principal.label} ${principal.id} not found on twin ${twinId}`,
2767
+ errorBody
2768
+ );
2769
+ }
2770
+ throw new TwinNotFoundError(`Twin ${twinId} not found`, errorBody);
2771
+ case 409:
2772
+ throw new TwinPermissionsConflictError(
2773
+ `Failed to ${action}: ${errorBody.detail}`,
2774
+ errorBody as TwinPermissionsConflictErrorBody
2775
+ );
2776
+ case 503:
2777
+ throw new TwinTemporarilyUnavailableError(
2778
+ `Failed to ${action}: ${errorBody.detail}`,
2779
+ errorBody,
2780
+ retryAfterSeconds(response)
2781
+ );
2782
+ }
2783
+
2784
+ // Note that a 500 can also mean the Permission service failed: the Twin service answers 503
2785
+ // when the call threw, but 500 when it returned an upstream failure. Only the 503 half is
2786
+ // classified as retryable here, because the 500 arrives as a ProblemDetails payload with no
2787
+ // cause to recognise it by.
2788
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
2789
+ }
2790
+
2030
2791
  async getAssetTypeMetaSchema(signal?: AbortSignal): Promise<Record<string, unknown>> {
2031
2792
  const url = `${this._assetTypeCatalogUrl}/meta-schema`;
2032
2793
  const response = await this._httpClient.fetch(HttpMethod.Get, url, {