@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.
- package/README.md +63 -2
- package/dist/BuildInfo.js +3 -3
- package/dist/TwinClient.d.ts +312 -9
- package/dist/TwinClient.d.ts.map +1 -1
- package/dist/TwinClient.js +321 -6
- package/dist/TwinClient.js.map +1 -1
- package/dist/errors.d.ts +120 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +116 -2
- package/dist/errors.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/generated.d.ts +93 -31
- package/dist/types/generated.d.ts.map +1 -1
- package/dist/types/twin-api.d.ts +54 -1
- package/dist/types/twin-api.d.ts.map +1 -1
- package/dist/types/twin-permissions-api.d.ts +226 -6
- package/dist/types/twin-permissions-api.d.ts.map +1 -1
- package/docs/assets/hierarchy.js +1 -1
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/AssetAttachmentConflictError.html +1 -1
- package/docs/classes/AssetAttachmentNotFoundError.html +1 -1
- package/docs/classes/AssetBatchError.html +1 -1
- package/docs/classes/AssetCatalogTooLargeError.html +1 -1
- package/docs/classes/AssetCategoryConflictError.html +1 -1
- package/docs/classes/AssetCategoryForbiddenError.html +1 -1
- package/docs/classes/AssetCategoryNotFoundError.html +1 -1
- package/docs/classes/AssetCategoryValidationError.html +1 -1
- package/docs/classes/AssetConflictError.html +1 -1
- package/docs/classes/AssetForbiddenError.html +1 -1
- package/docs/classes/AssetNotFoundError.html +1 -1
- package/docs/classes/AssetPermissionsConflictError.html +16 -0
- package/docs/classes/AssetTypeConflictError.html +1 -1
- package/docs/classes/AssetTypeForbiddenError.html +1 -1
- package/docs/classes/AssetTypeNotFoundError.html +1 -1
- package/docs/classes/AssetTypeValidationError.html +1 -1
- package/docs/classes/AssetValidationError.html +1 -1
- package/docs/classes/ProcessorNotFoundError.html +1 -1
- package/docs/classes/TwinClient.html +251 -73
- package/docs/classes/TwinConflictError.html +1 -1
- package/docs/classes/TwinFileConflictError.html +1 -1
- package/docs/classes/TwinFileNotFoundError.html +1 -1
- package/docs/classes/TwinFileValidationError.html +1 -1
- package/docs/classes/TwinNotFoundError.html +4 -2
- package/docs/classes/TwinPermissionsConflictError.html +19 -0
- package/docs/classes/TwinPermissionsForbiddenError.html +12 -0
- package/docs/classes/TwinPrincipalNotFoundError.html +5 -0
- package/docs/classes/TwinTemporarilyUnavailableError.html +10 -0
- package/docs/classes/TwinUnauthenticatedError.html +1 -1
- package/docs/classes/TwinValidationError.html +1 -1
- package/docs/hierarchy.html +1 -1
- package/docs/index.html +56 -4
- package/docs/interfaces/AssetCatalogTooLargeErrorBody.html +2 -2
- package/docs/interfaces/AssetGroupGet.html +9 -0
- package/docs/interfaces/AssetGroupsGet.html +3 -0
- package/docs/interfaces/AssetPermissionsConflictErrorBody.html +12 -0
- package/docs/interfaces/AssetPermissionsPut.html +9 -0
- package/docs/interfaces/AssetPrincipalPermissionsGet.html +23 -0
- package/docs/interfaces/Coordinates.html +6 -0
- package/docs/interfaces/EntityCollection.html +7 -2
- package/docs/interfaces/EntityRepresentation.html +12 -0
- package/docs/interfaces/Facility.html +8 -3
- package/docs/interfaces/FacilityPart.html +5 -3
- package/docs/interfaces/ITwinClient.html +251 -73
- package/docs/interfaces/InheritedPermissions.html +8 -0
- package/docs/interfaces/ListTwinItem.html +18 -2
- package/docs/interfaces/Location.html +4 -1
- package/docs/interfaces/MyPermissions.html +2 -2
- package/docs/interfaces/MyTwinPermissions.html +5 -0
- package/docs/interfaces/Site.html +11 -4
- package/docs/interfaces/Space.html +6 -3
- package/docs/interfaces/TwinErrorBody.html +2 -2
- package/docs/interfaces/TwinGet.html +29 -11
- package/docs/interfaces/TwinGroupGet.html +10 -0
- package/docs/interfaces/TwinGroupsGet.html +3 -0
- package/docs/interfaces/TwinPermissionsConflictErrorBody.html +8 -0
- package/docs/interfaces/TwinPermissionsPut.html +11 -0
- package/docs/interfaces/TwinPrincipalPermissionsGet.html +34 -0
- package/docs/interfaces/TwinPut.html +8 -10
- package/docs/interfaces/TwinReference.html +4 -0
- package/docs/interfaces/TwinSpecification.html +7 -9
- package/docs/media/twin-management.md +197 -0
- package/docs/modules.html +1 -1
- package/docs/types/TwinBoundCapabilityName.html +8 -0
- package/docs/types/TwinCapability.html +11 -5
- package/docs/types/TwinCapabilityName.html +8 -0
- package/docs/types/TwinErrorCause.html +1 -1
- package/docs/types/TwinErrorItemKind.html +1 -1
- package/docs/types/TwinPermissionsConflictCause.html +6 -0
- package/docs/types/TwinScopedCapability.html +9 -0
- package/docs/types/TwinScopedCapabilityName.html +8 -0
- package/package.json +3 -3
- package/src/TwinClient.ts +776 -15
- package/src/errors.ts +191 -4
- package/src/types/generated.ts +93 -33
- package/src/types/twin-api.ts +55 -1
- 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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
-
|
|
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
|
|
670
|
-
limit
|
|
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
|
-
|
|
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(
|
|
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
|
|
1475
|
-
throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`,
|
|
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, {
|