@devicechain/client 0.14.0-0

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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright The DeviceChain Authors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ <!--
2
+ Copyright The DeviceChain Authors
3
+ SPDX-License-Identifier: Apache-2.0
4
+ -->
5
+
6
+ # @devicechain/client
7
+
8
+ The DeviceChain client SDK: a small, framework-agnostic core for talking to a
9
+ DeviceChain instance from the browser. Typed GraphQL over `fetch`, live
10
+ subscriptions over `graphql-ws`, and a token seam your app fills in.
11
+
12
+ There is no Apollo, no store and no React in here. It owns the wire; your app
13
+ owns state and UI.
14
+
15
+ ```bash
16
+ npm install @devicechain/client graphql
17
+ ```
18
+
19
+ `graphql` is a peer dependency — you almost certainly have it already, since the
20
+ typed documents this SDK accepts come from GraphQL Code Generator.
21
+
22
+ ## Where the requests go
23
+
24
+ Each DeviceChain functional area serves its own GraphQL endpoint, and the SDK
25
+ addresses them **relative to the page**, as `/api/<area>/graphql`. That is the
26
+ contract a DeviceChain ingress serves: the SPA at `/`, each area behind
27
+ `/api/<area>`.
28
+
29
+ So an app served from the same origin as the instance needs no configuration at
30
+ all. An app served from somewhere else needs a dev-server proxy or a reverse
31
+ proxy that forwards `/api/<area>/...` to the instance — the same shape the
32
+ DeviceChain console's own `vite.config.ts` uses.
33
+
34
+ ## Queries and mutations
35
+
36
+ ```ts
37
+ import { gql } from '@devicechain/client';
38
+ import { DeviceListDocument } from './gql/graphql'; // your generated documents
39
+
40
+ const { devices } = await gql('device-management', DeviceListDocument, {
41
+ first: 25,
42
+ });
43
+ ```
44
+
45
+ The area is the first argument, and it is a checked union — `AREAS` is exported
46
+ as a real runtime array, not just a type, so you can enumerate it.
47
+
48
+ The result and variable types are inferred from the document. If the operation
49
+ takes no variables, the argument is optional.
50
+
51
+ Failures arrive as a `GraphQLRequestError` carrying the HTTP status and the
52
+ GraphQL error list. `isForbiddenError(err)` is the one classification worth
53
+ special-casing, because "you may not do that" is a different thing to show a
54
+ user than "the request did not arrive".
55
+
56
+ ## Authentication
57
+
58
+ DeviceChain uses two tokens: a **tenant access token** for ordinary work, and an
59
+ **identity token** for instance-scoped admin endpoints. You register a getter for
60
+ each, and the SDK attaches the right one per request — admin areas are detected
61
+ from the area name, so a new admin call cannot quietly ride the tenant token.
62
+
63
+ ```ts
64
+ import { setAuthTokenGetter, setIdentityTokenGetter } from '@devicechain/client';
65
+
66
+ setAuthTokenGetter(async () => store.accessToken); // refresh inside if near expiry
67
+ setIdentityTokenGetter(async () => store.identityToken);
68
+ ```
69
+
70
+ Login and refresh calls run without a token; pass `{ anonymous: true }` for those.
71
+
72
+ `decodeToken`, `isExpired` and `hasAuthority` are exported for reading claims
73
+ client-side. They decode, they do not verify — the server is the authority.
74
+
75
+ ## Subscriptions
76
+
77
+ ```ts
78
+ import { subscribe, disposeSubscriptions } from '@devicechain/client';
79
+
80
+ const unsubscribe = subscribe(
81
+ 'event-management',
82
+ MeasurementStreamDocument,
83
+ { deviceToken: 'sensor-01' },
84
+ {
85
+ next: (data) => render(data),
86
+ error: (err) => report(err),
87
+ connected: (wasRetry) => setLive(true, wasRetry),
88
+ closed: () => setLive(false),
89
+ },
90
+ );
91
+
92
+ // on sign-out, drop every area socket so a stale token's connection cannot linger
93
+ disposeSubscriptions();
94
+ ```
95
+
96
+ One socket is shared per area and reused across subscriptions. `connected` and
97
+ `closed` are connection-level signals, which is what lets a UI tell "connected
98
+ but idle" apart from "offline" — a distinction a data-only sink cannot make.
99
+
100
+ ## Also in here
101
+
102
+ - **Basemap resolution** (`resolveBasemap`, `renderableBasemap`) — the client half
103
+ of the map-tile cascade, so every surface that draws a map resolves a
104
+ per-surface override over the tenant default the same way.
105
+ - **Token masks** (`generateToken`, `conformsToMask`, `parseMask`, …) — the
106
+ device-token vocabulary, validated client-side before a round trip.
107
+
108
+ ## Compatibility
109
+
110
+ ESM only, built for bundler resolution (Vite, webpack, Rollup, esbuild). The
111
+ emitted declarations use extensionless specifiers, which resolve under
112
+ TypeScript's `bundler` module resolution and **not** under `node16`/`nodenext`.
113
+
114
+ ## License
115
+
116
+ Apache-2.0. See [LICENSE](./LICENSE).
@@ -0,0 +1,50 @@
1
+ /** A basemap at any tier: the tile source, and a fallback view. */
2
+ export interface Basemap {
3
+ tileUrl?: string | null;
4
+ attribution?: string | null;
5
+ centerLat?: number | null;
6
+ centerLon?: number | null;
7
+ zoom?: number | null;
8
+ }
9
+ /** A resolved basemap that definitely has a tile source, so a renderer can draw it. */
10
+ export interface ResolvedBasemap {
11
+ tileUrl: string;
12
+ attribution: string | null;
13
+ centerLat: number | null;
14
+ centerLon: number | null;
15
+ zoom: number | null;
16
+ }
17
+ /**
18
+ * Fold a per-surface override over the tenant's effective basemap.
19
+ *
20
+ * 🔴 The TILE SOURCE is atomic, exactly as it is on the server. If the override names
21
+ * a tile URL, its attribution comes from the override too — never from the tenant. A
22
+ * per-field fold would credit the tenant's provider for the override's tiles, which
23
+ * is a licence violation manufactured by the merge rather than a cosmetic mismatch.
24
+ * This is the same rule as basemap.Merge in user-management, and it has to be
25
+ * restated here because this tier never reaches the server.
26
+ *
27
+ * The camera folds per field: a widget that only wants to open closer in should not
28
+ * have to restate a provider to keep it.
29
+ */
30
+ export declare function resolveBasemap(override: Basemap | null | undefined, tenant: Basemap | null | undefined): Basemap;
31
+ /**
32
+ * Narrow a folded basemap to one that can actually be rendered, or null.
33
+ *
34
+ * A basemap with a camera but no tile source is legitimate — an operator may set a
35
+ * starting view before choosing a provider — but there is nothing to draw, and the
36
+ * caller should say so rather than request tiles from an empty URL.
37
+ */
38
+ export declare function renderableBasemap(b: Basemap | null | undefined): ResolvedBasemap | null;
39
+ /**
40
+ * The fallback view a surface opens at when it has nothing of its own to fit to.
41
+ *
42
+ * 🔴 A FALLBACK, never an override (ADR-079 §5). A surface with real content — an
43
+ * existing fence's ring, a widget's markers — fits that and ignores this entirely.
44
+ * Returns null when no tier declared a centre, so the caller keeps its own default
45
+ * rather than being moved to 0,0.
46
+ */
47
+ export declare function fallbackView(b: Basemap | null | undefined): {
48
+ center: [number, number];
49
+ zoom: number | null;
50
+ } | null;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * True when the server REFUSED the request for lack of an authority, as opposed
3
+ * to failing it. Callers use this to render a permission state rather than an
4
+ * error state.
5
+ */
6
+ export declare function isForbiddenError(err: unknown): boolean;
@@ -0,0 +1,6 @@
1
+ export { gql, areaPath, areaKey, AREAS, resolveAuthToken, setAuthTokenGetter, setIdentityTokenGetter, GraphQLRequestError, type Area, type RequestOptions, type TypedDocument, } from './transport';
2
+ export { isForbiddenError } from './errors';
3
+ export { resolveBasemap, renderableBasemap, fallbackView, type Basemap, type ResolvedBasemap, } from './basemap';
4
+ export { subscribe, disposeSubscriptions, type SubscriptionSink } from './subscribe';
5
+ export { decodeToken, isExpired, hasAuthority, type DecodedClaims, } from './jwt';
6
+ export { generateToken, normalizeToken, conformsToMask, maskToRegExp, parseMask, resolveMask, isValidToken, sampleToken, validateMask, MAX_TOKEN_LEN, type GenerateOptions, type MaskSegment, type MaskProblem, } from './tokens';
package/dist/index.js ADDED
@@ -0,0 +1,360 @@
1
+ // src/transport.ts
2
+ var AREAS = [
3
+ "user-management",
4
+ "user-management/admin",
5
+ "user-management/settings",
6
+ "device-management",
7
+ "event-management",
8
+ "event-processing",
9
+ "device-state",
10
+ "command-delivery",
11
+ "dashboard-management",
12
+ "outbound-connectors",
13
+ "ai-inference/admin"
14
+ ];
15
+ function areaPath(area) {
16
+ return `/api/${area}/graphql`;
17
+ }
18
+ function areaKey(area) {
19
+ return area.split("/")[0];
20
+ }
21
+ function isIdentityArea(area) {
22
+ return area.endsWith("/admin") || area.endsWith("/settings");
23
+ }
24
+ var tokenGetter = null;
25
+ function setAuthTokenGetter(getter) {
26
+ tokenGetter = getter;
27
+ }
28
+ var identityTokenGetter = null;
29
+ function setIdentityTokenGetter(getter) {
30
+ identityTokenGetter = getter;
31
+ }
32
+ async function resolveAuthToken() {
33
+ return tokenGetter ? tokenGetter() : null;
34
+ }
35
+ var GraphQLRequestError = class extends Error {
36
+ constructor(message, status, errors) {
37
+ super(message);
38
+ this.status = status;
39
+ this.errors = errors;
40
+ this.name = "GraphQLRequestError";
41
+ }
42
+ };
43
+ async function gql(area, document, ...[variables, options]) {
44
+ const headers = { "Content-Type": "application/json" };
45
+ if (!options?.anonymous) {
46
+ const useIdentity = options?.identity ?? isIdentityArea(area);
47
+ const getter = useIdentity ? identityTokenGetter : tokenGetter;
48
+ if (getter) {
49
+ const token = await getter();
50
+ if (token) headers["Authorization"] = `Bearer ${token}`;
51
+ }
52
+ }
53
+ let res;
54
+ try {
55
+ res = await fetch(areaPath(area), {
56
+ method: "POST",
57
+ headers,
58
+ body: JSON.stringify({ query: document.toString(), variables })
59
+ });
60
+ } catch (err) {
61
+ throw new GraphQLRequestError(
62
+ err instanceof Error ? err.message : "Network request failed",
63
+ 0
64
+ );
65
+ }
66
+ if (!res.ok) {
67
+ throw new GraphQLRequestError(`Request failed (${res.status})`, res.status);
68
+ }
69
+ const body = await res.json();
70
+ if (body.errors && body.errors.length > 0) {
71
+ throw new GraphQLRequestError(body.errors[0].message, res.status, body.errors);
72
+ }
73
+ if (body.data === void 0) {
74
+ throw new GraphQLRequestError("Empty GraphQL response", res.status);
75
+ }
76
+ return body.data;
77
+ }
78
+
79
+ // src/errors.ts
80
+ var FORBIDDEN_PREFIX = /^forbidden\b/i;
81
+ function isForbiddenError(err) {
82
+ if (!(err instanceof GraphQLRequestError)) return false;
83
+ if (err.status === 403) return true;
84
+ const messages = err.errors?.length ? err.errors.map((e) => e.message) : [err.message];
85
+ return messages.some((m) => FORBIDDEN_PREFIX.test(m.trim()));
86
+ }
87
+
88
+ // src/basemap.ts
89
+ function text(v) {
90
+ if (typeof v !== "string") return null;
91
+ const trimmed = v.trim();
92
+ return trimmed === "" ? null : trimmed;
93
+ }
94
+ function num(v) {
95
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
96
+ }
97
+ function resolveBasemap(override, tenant) {
98
+ const o = override ?? {};
99
+ const t = tenant ?? {};
100
+ const overrideAttribution = text(o.attribution);
101
+ const overrideTiles = overrideAttribution === null ? null : text(o.tileUrl);
102
+ const tileUrl = overrideTiles ?? text(t.tileUrl);
103
+ const attribution = overrideTiles ? overrideAttribution : text(t.attribution);
104
+ const hasOverrideCentre = num(o.centerLat) !== null || num(o.centerLon) !== null;
105
+ const centerLat = hasOverrideCentre ? num(o.centerLat) : num(t.centerLat);
106
+ const centerLon = hasOverrideCentre ? num(o.centerLon) : num(t.centerLon);
107
+ return {
108
+ tileUrl,
109
+ attribution,
110
+ centerLat,
111
+ centerLon,
112
+ zoom: num(o.zoom) ?? num(t.zoom)
113
+ };
114
+ }
115
+ function renderableBasemap(b) {
116
+ const tileUrl = text(b?.tileUrl);
117
+ if (!tileUrl) return null;
118
+ return {
119
+ tileUrl,
120
+ attribution: text(b?.attribution),
121
+ centerLat: num(b?.centerLat),
122
+ centerLon: num(b?.centerLon),
123
+ zoom: num(b?.zoom)
124
+ };
125
+ }
126
+ function fallbackView(b) {
127
+ const lat = num(b?.centerLat);
128
+ const lon = num(b?.centerLon);
129
+ if (lat === null || lon === null) return null;
130
+ return { center: [lon, lat], zoom: num(b?.zoom) };
131
+ }
132
+
133
+ // src/subscribe.ts
134
+ import { createClient } from "graphql-ws";
135
+ function wsUrl(area) {
136
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
137
+ return `${scheme}://${window.location.host}${areaPath(area)}`;
138
+ }
139
+ var clients = /* @__PURE__ */ new Map();
140
+ function clientFor(area) {
141
+ let client = clients.get(area);
142
+ if (!client) {
143
+ client = createClient({
144
+ url: wsUrl(area),
145
+ connectionParams: async () => {
146
+ const token = await resolveAuthToken();
147
+ return token ? { Authorization: `Bearer ${token}` } : {};
148
+ }
149
+ });
150
+ clients.set(area, client);
151
+ }
152
+ return client;
153
+ }
154
+ function subscribe(area, document, variables, sink) {
155
+ const client = clientFor(area);
156
+ const disposers = [];
157
+ if (sink.connected) {
158
+ disposers.push(client.on("connected", (_socket, _payload, wasRetry) => sink.connected(wasRetry)));
159
+ }
160
+ if (sink.closed) {
161
+ disposers.push(client.on("closed", () => sink.closed()));
162
+ }
163
+ disposers.push(
164
+ client.subscribe(
165
+ {
166
+ query: document.toString(),
167
+ variables
168
+ },
169
+ {
170
+ next: (result) => {
171
+ if (result.data != null) sink.next(result.data);
172
+ },
173
+ error: (err) => sink.error?.(err),
174
+ complete: () => sink.complete?.()
175
+ }
176
+ )
177
+ );
178
+ return () => {
179
+ for (const dispose of disposers) dispose();
180
+ };
181
+ }
182
+ function disposeSubscriptions() {
183
+ for (const client of clients.values()) {
184
+ void client.dispose();
185
+ }
186
+ clients.clear();
187
+ }
188
+
189
+ // src/jwt.ts
190
+ function base64UrlDecode(segment) {
191
+ const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
192
+ const json = atob(padded);
193
+ return decodeURIComponent(
194
+ Array.prototype.map.call(json, (c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)).join("")
195
+ );
196
+ }
197
+ function decodeToken(token) {
198
+ const parts = token.split(".");
199
+ if (parts.length !== 3) return null;
200
+ try {
201
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
202
+ return {
203
+ tenant: payload.tenant ?? "",
204
+ username: payload.username ?? "",
205
+ roles: payload.roles ?? [],
206
+ authorities: payload.authorities ?? [],
207
+ typ: payload.typ ?? "",
208
+ exp: payload.exp
209
+ };
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ function isExpired(token, skewSeconds = 30) {
215
+ const claims = decodeToken(token);
216
+ if (!claims || claims.exp === void 0) return true;
217
+ const nowSeconds = Date.now() / 1e3;
218
+ return claims.exp - skewSeconds <= nowSeconds;
219
+ }
220
+ function hasAuthority(claims, required) {
221
+ if (!claims) return false;
222
+ return claims.authorities.some((a) => a === "*" || a === required);
223
+ }
224
+
225
+ // src/tokens.ts
226
+ var READABLE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
227
+ var DIGITS = "0123456789";
228
+ var TOKEN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
229
+ var MAX_TOKEN_LEN = 128;
230
+ function isValidToken(token) {
231
+ return token.length > 0 && token.length <= MAX_TOKEN_LEN && TOKEN_GRAMMAR.test(token);
232
+ }
233
+ var PLACEHOLDER = /\{([a-zA-Z]+)(?:-(\d+))?\}/g;
234
+ var KNOWN = ["alphanumeric", "numeric", "slug", "uuid"];
235
+ function parseMask(mask) {
236
+ const segments = [];
237
+ let last = 0;
238
+ for (const m of mask.matchAll(PLACEHOLDER)) {
239
+ const at = m.index ?? 0;
240
+ if (at > last) segments.push({ kind: "literal", text: mask.slice(last, at) });
241
+ const name = m[1].toLowerCase();
242
+ const type = KNOWN.includes(name) ? name : "unknown";
243
+ segments.push({ kind: "placeholder", type, n: m[2] ? parseInt(m[2], 10) : void 0, raw: m[0] });
244
+ last = at + m[0].length;
245
+ }
246
+ if (last < mask.length) segments.push({ kind: "literal", text: mask.slice(last) });
247
+ return segments;
248
+ }
249
+ function randomUUID() {
250
+ const g = globalThis;
251
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
252
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
253
+ const r = Math.random() * 16 | 0;
254
+ return (c === "x" ? r : r & 3 | 8).toString(16);
255
+ });
256
+ }
257
+ function generateToken(mask, opts = {}) {
258
+ const rand = opts.random ?? Math.random;
259
+ const uuid = opts.uuid ?? randomUUID;
260
+ const pick = (alphabet, n) => n > MAX_TOKEN_LEN ? "" : Array.from({ length: n }, () => alphabet[Math.floor(rand() * alphabet.length)]).join("");
261
+ return parseMask(mask).map((seg) => {
262
+ if (seg.kind === "literal") return seg.text;
263
+ switch (seg.type) {
264
+ case "alphanumeric":
265
+ return pick(READABLE_ALPHABET, seg.n ?? 8);
266
+ case "numeric":
267
+ return pick(DIGITS, seg.n ?? 4);
268
+ case "slug":
269
+ return opts.seed ? normalizeToken(opts.seed) : pick(READABLE_ALPHABET, 6);
270
+ case "uuid":
271
+ return uuid();
272
+ default:
273
+ return "";
274
+ }
275
+ }).join("");
276
+ }
277
+ function normalizeToken(input) {
278
+ return input.toLowerCase().trim().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]+/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
279
+ }
280
+ function escapeRegExp(s) {
281
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
282
+ }
283
+ function maskToRegExp(mask) {
284
+ const body = parseMask(mask).map((seg) => {
285
+ if (seg.kind === "literal") return escapeRegExp(seg.text);
286
+ switch (seg.type) {
287
+ case "alphanumeric":
288
+ return `[A-Za-z0-9]{${seg.n ?? 8}}`;
289
+ case "numeric":
290
+ return `[0-9]{${seg.n ?? 4}}`;
291
+ case "slug":
292
+ return `[a-z0-9]+(?:-[a-z0-9]+)*`;
293
+ case "uuid":
294
+ return `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`;
295
+ default:
296
+ return escapeRegExp(seg.raw);
297
+ }
298
+ }).join("");
299
+ return new RegExp(`^${body}$`);
300
+ }
301
+ function conformsToMask(mask, token) {
302
+ return maskToRegExp(mask).test(token);
303
+ }
304
+ var SAMPLE_SEED = "Sample Name";
305
+ function sampleToken(mask, seed = SAMPLE_SEED) {
306
+ return generateToken(mask, {
307
+ seed,
308
+ random: () => 0,
309
+ uuid: () => "3f2b8c14-9d7e-4a51-b6c2-0e8d5a1f7b93"
310
+ });
311
+ }
312
+ function validateMask(mask) {
313
+ if (mask === "") return { reason: "empty" };
314
+ let placeholders = 0;
315
+ for (const seg of parseMask(mask)) {
316
+ if (seg.kind !== "placeholder") continue;
317
+ if (seg.type === "unknown") return { reason: "unknownPlaceholder", placeholder: seg.raw };
318
+ if (seg.n !== void 0 && seg.n > MAX_TOKEN_LEN) {
319
+ return { reason: "widthTooLarge", placeholder: seg.raw, max: MAX_TOKEN_LEN };
320
+ }
321
+ placeholders++;
322
+ }
323
+ if (placeholders === 0) return { reason: "noPlaceholder" };
324
+ const sample = sampleToken(mask);
325
+ if (!isValidToken(sample)) return { reason: "invalidToken", sample };
326
+ return null;
327
+ }
328
+ function resolveMask(masks, entityType) {
329
+ return masks[entityType] ?? masks.default ?? "{slug}";
330
+ }
331
+ export {
332
+ AREAS,
333
+ GraphQLRequestError,
334
+ MAX_TOKEN_LEN,
335
+ areaKey,
336
+ areaPath,
337
+ conformsToMask,
338
+ decodeToken,
339
+ disposeSubscriptions,
340
+ fallbackView,
341
+ generateToken,
342
+ gql,
343
+ hasAuthority,
344
+ isExpired,
345
+ isForbiddenError,
346
+ isValidToken,
347
+ maskToRegExp,
348
+ normalizeToken,
349
+ parseMask,
350
+ renderableBasemap,
351
+ resolveAuthToken,
352
+ resolveBasemap,
353
+ resolveMask,
354
+ sampleToken,
355
+ setAuthTokenGetter,
356
+ setIdentityTokenGetter,
357
+ subscribe,
358
+ validateMask
359
+ };
360
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/transport.ts", "../src/errors.ts", "../src/basemap.ts", "../src/subscribe.ts", "../src/jwt.ts", "../src/tokens.ts"],
4
+ "sourcesContent": ["// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// A tiny GraphQL-over-fetch client, in the spirit of a REST `fetchJson` helper \u2014\n// no Apollo. Each DeviceChain functional area serves its own /graphql endpoint;\n// `area` selects which one. Requests carry the access-token Bearer when one is\n// available (see setAuthTokenGetter); login/refresh run with no token (the\n// GraphQL handler lets an absent token through with no tenant).\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\n\n// DeviceChain functional areas that expose a GraphQL endpoint. An \"area\" ending in\n// `/admin` is a service's instance-scoped admin API (ADR-033), served by that\n// service at a second path and authenticated with the identity token rather than a\n// tenant access token (see the `identity` request option).\n//\n// `ai-inference` has no `/admin` sibling here because it has no tenant-facing\n// surface a console would call: its data plane serves exactly one mutation, reached\n// by event-processing's service token, never by a browser. Its whole console-facing\n// surface is the operator provider list (ADR-065).\n// Declared as a const array with the type derived from it, rather than as a bare\n// union, so the set is enumerable at RUNTIME. That is what lets a test walk every\n// member and check it against the deployment catalog the Helm chart uses \u2014 a\n// union alone erases at compile time, leaving the check to a hand-kept copy of\n// this list, which is the drift this whole mechanism exists to prevent.\nexport const AREAS = [\n 'user-management',\n 'user-management/admin',\n 'user-management/settings',\n 'device-management',\n 'event-management',\n 'event-processing',\n 'device-state',\n 'command-delivery',\n 'dashboard-management',\n 'outbound-connectors',\n 'ai-inference/admin',\n] as const;\n\nexport type Area = (typeof AREAS)[number];\n\n// Relative URL matching the cluster ingress contract: the ingress routes\n// https://<host>/api/<area>/graphql to each functional-area service and serves\n// the SPA at \"/\". In `vite dev` the same path is handled by the config proxy.\n// 'user-management/admin' resolves to /api/user-management/admin/graphql.\nexport function areaPath(area: Area): string {\n return `/api/${area}/graphql`;\n}\n\n/**\n * The deployed functional area an Area is served by \u2014 the segment before the\n * first slash, since a suffix like `/admin` or `/settings` names a PLANE of an\n * area rather than an area of its own.\n *\n * This is the join between an Area (a client-side routing convention) and the\n * area vocabulary the server reports from what the chart actually deployed, so\n * the console can tell \"never deployed here\" apart from a transport failure.\n * Note it is deliberately many-to-one: `user-management`,\n * `user-management/admin` and `user-management/settings` all key to\n * `user-management`, and the reverse direction does not exist \u2014 plenty of\n * deployed areas (event-sources, notification-management) have no Area member.\n */\nexport function areaKey(area: Area): string {\n return area.split('/')[0];\n}\n\n// Whether an area is served on an instance-scoped lane that authenticates with the\n// IDENTITY token rather than a tenant access token (ADR-033). Those endpoints accept\n// nothing else, so the credential follows from the area and is not a per-call\n// choice: deriving it here means a new admin call cannot silently ride the tenant\n// token and 401 (or, worse, be given a tenant token that some future endpoint\n// accepts). Callers may still pass `identity` explicitly; this is the default, not\n// an override.\nexport function isIdentityArea(area: Area): boolean {\n return area.endsWith('/admin') || area.endsWith('/settings');\n}\n\n// \u2500\u2500 Auth token injection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// A host (e.g. the console's AuthProvider) registers a getter so the client can\n// attach a fresh access token \u2014 refreshing it first if near expiry \u2014 without the\n// SDK owning React state or storage.\n\nlet tokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {\n tokenGetter = getter;\n}\n\n// The identity-token getter feeds the admin API (ADR-033): admin requests carry\n// the instance-scoped identity token, not the tenant access token. Registered\n// alongside setAuthTokenGetter; selected per request via the `identity` option.\nlet identityTokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setIdentityTokenGetter(getter: (() => Promise<string | null>) | null) {\n identityTokenGetter = getter;\n}\n\n// resolveAuthToken returns the current access token (refreshing via the getter),\n// or null. Internal to the SDK \u2014 the ws subscribe path (subscribe.ts) reuses it\n// so subscriptions authenticate with the same token as queries.\nexport async function resolveAuthToken(): Promise<string | null> {\n return tokenGetter ? tokenGetter() : null;\n}\n\n// \u2500\u2500 Errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport class GraphQLRequestError extends Error {\n constructor(\n message: string,\n /** HTTP status, or 0 for a transport-level failure. */\n public status: number,\n /** The raw `errors` array from a GraphQL response, when present. */\n public errors?: { message: string }[],\n ) {\n super(message);\n this.name = 'GraphQLRequestError';\n }\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: { message: string }[];\n}\n\nexport interface RequestOptions {\n /** Skip attaching the Bearer token (login / refresh run unauthenticated). */\n anonymous?: boolean;\n /**\n * Authenticate with the identity token instead of the tenant access token \u2014\n * for the instance-scoped admin API (ADR-033). Ignored when `anonymous`.\n *\n * Rarely needed: an `/admin` or `/settings` area defaults to the identity token\n * already (see isIdentityArea). Pass it only to force the lane for an area whose\n * name does not say so.\n */\n identity?: boolean;\n}\n\n// A GraphQL document tagged with its result/variable types \u2014 what gql()/subscribe()\n// accept. A code-generated `TypedDocumentString` (client preset) satisfies it, and\n// so does a hand-authored query string cast to it: the SDK runs in documentMode\n// 'string', calling only `toString()` to get the query text, so the phantom result\n// (`TResult`) and variable (`TVariables`) generics are all that distinguish them.\n// Exported so packages/apps without codegen (dashboards, widgets, the dash app)\n// hand-author typed documents against one shared alias instead of re-inlining it.\nexport type TypedDocument<TResult, TVariables> = DocumentTypeDecoration<TResult, TVariables> & {\n toString(): string;\n};\n\n// `document` is a code-generated typed document (a `TypedDocumentString` from the\n// GraphQL Code Generator client preset). It implements\n// `DocumentTypeDecoration<TResult, TVariables>`, which carries the result and\n// variable types as phantom generics, and stringifies (via `toString()`) to the\n// raw query text the server expects. The structural `& { toString(): string }`\n// keeps this accepting a document from ANY service while still inferring both the\n// result (`TResult`) and the variables (`TVariables`).\nexport async function gql<TResult, TVariables>(\n area: Area,\n document: TypedDocument<TResult, TVariables>,\n ...[variables, options]: TVariables extends Record<string, never>\n ? [variables?: undefined, options?: RequestOptions]\n : [variables: TVariables, options?: RequestOptions]\n): Promise<TResult> {\n const headers: Record<string, string> = { 'Content-Type': 'application/json' };\n\n if (!options?.anonymous) {\n const useIdentity = options?.identity ?? isIdentityArea(area);\n const getter = useIdentity ? identityTokenGetter : tokenGetter;\n if (getter) {\n const token = await getter();\n if (token) headers['Authorization'] = `Bearer ${token}`;\n }\n }\n\n let res: Response;\n try {\n res = await fetch(areaPath(area), {\n method: 'POST',\n headers,\n body: JSON.stringify({ query: document.toString(), variables }),\n });\n } catch (err) {\n throw new GraphQLRequestError(\n err instanceof Error ? err.message : 'Network request failed',\n 0,\n );\n }\n\n if (!res.ok) {\n throw new GraphQLRequestError(`Request failed (${res.status})`, res.status);\n }\n\n const body = (await res.json()) as GraphQLResponse<TResult>;\n if (body.errors && body.errors.length > 0) {\n throw new GraphQLRequestError(body.errors[0].message, res.status, body.errors);\n }\n if (body.data === undefined) {\n throw new GraphQLRequestError('Empty GraphQL response', res.status);\n }\n return body.data;\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Classification of transport failures a caller has to render differently.\n//\n// Most errors are one shape to a screen (\"something went wrong\"), but a refusal\n// is not: \"you may not see this\" and \"this could not be loaded\" call for\n// different copy, and collapsing them makes a permission boundary look like an\n// outage.\n//\n// This lives beside GraphQLRequestError rather than in a host app because more\n// than one consumer needs it: the console renders a device's position panel from\n// it, and the dashboard runtime's location channel turns the same refusal into a\n// first-class snapshot state (location:read is deliberately outside the read-only\n// viewer baseline, so being refused is an ordinary state rather than a fault).\n\nimport { GraphQLRequestError } from './transport';\n\n// The backend's authorization gate returns core/auth's ErrForbidden, whose text\n// is \"forbidden: missing required authority\"; graphql-go surfaces a resolver\n// error verbatim as the entry's `message`, over HTTP 200. Anchoring the match at\n// the START of the message keeps it from firing on an unrelated error that\n// merely mentions the word (an entity literally named \"forbidden-zone\", say).\nconst FORBIDDEN_PREFIX = /^forbidden\\b/i;\n\n/**\n * True when the server REFUSED the request for lack of an authority, as opposed\n * to failing it. Callers use this to render a permission state rather than an\n * error state.\n */\nexport function isForbiddenError(err: unknown): boolean {\n if (!(err instanceof GraphQLRequestError)) return false;\n // A transport-level 403 counts even with no GraphQL body to read.\n if (err.status === 403) return true;\n const messages = err.errors?.length ? err.errors.map((e) => e.message) : [err.message];\n return messages.some((m) => FORBIDDEN_PREFIX.test(m.trim()));\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// The client half of the basemap cascade (ADR-079).\n//\n// The SERVER resolves a tenant's override over the operator's instance default and\n// hands back one effective value. This module resolves the last tier \u2014 a per-surface\n// override, set on the widget or held as a personal preference in the browser \u2014 over\n// that tenant value, and turns the result into something a renderer can use.\n//\n// It lives in the SDK rather than in either app because THREE surfaces need the\n// identical answer: the console's geofence editor, the dashboard map widget, and the\n// `/dash` viewer app, which has its own login and would otherwise be the one place\n// that quietly disagrees.\n\n/** A basemap at any tier: the tile source, and a fallback view. */\nexport interface Basemap {\n tileUrl?: string | null;\n attribution?: string | null;\n centerLat?: number | null;\n centerLon?: number | null;\n zoom?: number | null;\n}\n\n/** A resolved basemap that definitely has a tile source, so a renderer can draw it. */\nexport interface ResolvedBasemap {\n tileUrl: string;\n attribution: string | null;\n centerLat: number | null;\n centerLon: number | null;\n zoom: number | null;\n}\n\n/** Treat blank and whitespace-only strings as unset \u2014 an emptied form field means \"inherit\". */\nfunction text(v: string | null | undefined): string | null {\n if (typeof v !== 'string') return null;\n const trimmed = v.trim();\n return trimmed === '' ? null : trimmed;\n}\n\nfunction num(v: number | null | undefined): number | null {\n return typeof v === 'number' && Number.isFinite(v) ? v : null;\n}\n\n/**\n * Fold a per-surface override over the tenant's effective basemap.\n *\n * \uD83D\uDD34 The TILE SOURCE is atomic, exactly as it is on the server. If the override names\n * a tile URL, its attribution comes from the override too \u2014 never from the tenant. A\n * per-field fold would credit the tenant's provider for the override's tiles, which\n * is a licence violation manufactured by the merge rather than a cosmetic mismatch.\n * This is the same rule as basemap.Merge in user-management, and it has to be\n * restated here because this tier never reaches the server.\n *\n * The camera folds per field: a widget that only wants to open closer in should not\n * have to restate a provider to keep it.\n */\nexport function resolveBasemap(override: Basemap | null | undefined, tenant: Basemap | null | undefined): Basemap {\n const o = override ?? {};\n const t = tenant ?? {};\n\n // \uD83D\uDD34 AN INCOMPLETE OVERRIDE IS NOT AN OVERRIDE. A tile URL with no credit line is\n // refused outright by the server at the tenant and instance tiers \u2014 the two halves\n // are stored as one value or not at all \u2014 but THIS tier never reaches the server: a\n // widget's own options and the fence editor's browser-local fields are entered\n // client-side and rendered directly.\n //\n // Without this the atomic fold below did its job and still produced the defect it\n // exists to prevent: it correctly refused to lend the tenant's credit line to the\n // override's tiles, and then drew those tiles with NO credit line at all. Correct\n // about the cross-crediting, wrong about the licence.\n //\n // So an override naming tiles without their attribution is discarded whole, and the\n // surface falls back to the tenant's properly-credited basemap. Failing closed to a\n // map that is credited beats honouring a preference that cannot legally be shown.\n //\n // On an ordinary instance the fallback is a real map, because a shipped default\n // means some tier below almost always has one. On an instance whose operator\n // deliberately stored `{}` there is nothing below, and the surface falls through to\n // the bundled schematic world \u2014 which is the correct outcome for the same reason,\n // just a less pretty one.\n const overrideAttribution = text(o.attribution);\n const overrideTiles = overrideAttribution === null ? null : text(o.tileUrl);\n const tileUrl = overrideTiles ?? text(t.tileUrl);\n const attribution = overrideTiles ? overrideAttribution : text(t.attribution);\n\n // Latitude and longitude move as a pair for the same reason: half a coordinate\n // from each tier names a point neither one chose.\n const hasOverrideCentre = num(o.centerLat) !== null || num(o.centerLon) !== null;\n const centerLat = hasOverrideCentre ? num(o.centerLat) : num(t.centerLat);\n const centerLon = hasOverrideCentre ? num(o.centerLon) : num(t.centerLon);\n\n return {\n tileUrl,\n attribution,\n centerLat,\n centerLon,\n zoom: num(o.zoom) ?? num(t.zoom),\n };\n}\n\n/**\n * Narrow a folded basemap to one that can actually be rendered, or null.\n *\n * A basemap with a camera but no tile source is legitimate \u2014 an operator may set a\n * starting view before choosing a provider \u2014 but there is nothing to draw, and the\n * caller should say so rather than request tiles from an empty URL.\n */\nexport function renderableBasemap(b: Basemap | null | undefined): ResolvedBasemap | null {\n const tileUrl = text(b?.tileUrl);\n if (!tileUrl) return null;\n return {\n tileUrl,\n attribution: text(b?.attribution),\n centerLat: num(b?.centerLat),\n centerLon: num(b?.centerLon),\n zoom: num(b?.zoom),\n };\n}\n\n/**\n * The fallback view a surface opens at when it has nothing of its own to fit to.\n *\n * \uD83D\uDD34 A FALLBACK, never an override (ADR-079 \u00A75). A surface with real content \u2014 an\n * existing fence's ring, a widget's markers \u2014 fits that and ignores this entirely.\n * Returns null when no tier declared a centre, so the caller keeps its own default\n * rather than being moved to 0,0.\n */\nexport function fallbackView(b: Basemap | null | undefined): { center: [number, number]; zoom: number | null } | null {\n const lat = num(b?.centerLat);\n const lon = num(b?.centerLon);\n if (lat === null || lon === null) return null;\n return { center: [lon, lat], zoom: num(b?.zoom) };\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// GraphQL subscriptions over the graphql-transport-ws protocol (ADR-037) \u2014 the\n// live half of the client wire, consumed by dashboards (the ADR-039 Hub) for\n// live telemetry. The backend serves subscriptions on the same /api/<area>/graphql\n// path via a WebSocket upgrade; a client derives the ws:// URL from the http one.\n//\n// This is the low-level SDK primitive: one lazily-created graphql-ws client per\n// area (connection reuse), and a subscribe() that binds a typed document to a\n// sink. The dashboard Hub layers subscription multiplexing on top (many widgets,\n// one connection).\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\nimport { createClient, type Client } from 'graphql-ws';\n\nimport { areaPath, resolveAuthToken, type Area } from './transport';\n\n// wsUrl builds the absolute ws(s):// URL for an area from the current origin,\n// mirroring the relative http path so dev proxy and prod ingress both work.\nfunction wsUrl(area: Area): string {\n const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\n return `${scheme}://${window.location.host}${areaPath(area)}`;\n}\n\n// One graphql-ws client per area \u2014 the WebSocket (and its connection_init auth)\n// is reused across every subscription to that area. connectionParams is a\n// function so the token is re-resolved on each (re)connect, surviving refresh.\nconst clients = new Map<Area, Client>();\n\nfunction clientFor(area: Area): Client {\n let client = clients.get(area);\n if (!client) {\n client = createClient({\n url: wsUrl(area),\n connectionParams: async () => {\n const token = await resolveAuthToken();\n return token ? { Authorization: `Bearer ${token}` } : {};\n },\n });\n clients.set(area, client);\n }\n return client;\n}\n\nexport interface SubscriptionSink<T> {\n next: (data: T) => void;\n error?: (err: unknown) => void;\n complete?: () => void;\n // Connection-level signals for the shared per-area socket, distinct from the\n // per-operation next/error/complete above. `connected` fires on each successful\n // connection_ack \u2014 `wasRetry` distinguishes a reconnect (after a dropped socket)\n // from the first connect; `closed` fires on each socket close (graphql-ws then\n // auto-retries a transient close). A consumer that only needs data can omit both;\n // a live-status indicator uses them to tell \"connected but idle\" from \"offline\".\n connected?: (wasRetry: boolean) => void;\n closed?: () => void;\n}\n\n// subscribe binds a typed subscription document to a sink and returns an\n// unsubscribe function. The document is the same code-generated typed document\n// the query path uses, so the result type is inferred. Errors from the operation\n// (or the socket) surface via sink.error; normal termination via sink.complete.\nexport function subscribe<TResult, TVariables>(\n area: Area,\n document: DocumentTypeDecoration<TResult, TVariables> & { toString(): string },\n variables: TVariables extends Record<string, never> ? undefined : TVariables,\n sink: SubscriptionSink<TResult>,\n): () => void {\n const client = clientFor(area);\n // Client-level connection events are registered per subscribe() and torn down with\n // it, alongside the operation itself, so a single dispose() call releases both.\n const disposers: Array<() => void> = [];\n if (sink.connected) {\n disposers.push(client.on('connected', (_socket, _payload, wasRetry) => sink.connected!(wasRetry)));\n }\n if (sink.closed) {\n disposers.push(client.on('closed', () => sink.closed!()));\n }\n disposers.push(\n client.subscribe<TResult>(\n {\n query: document.toString(),\n variables: variables as Record<string, unknown> | undefined,\n },\n {\n next: (result) => {\n if (result.data != null) sink.next(result.data);\n },\n error: (err) => sink.error?.(err),\n complete: () => sink.complete?.(),\n },\n ),\n );\n return () => {\n for (const dispose of disposers) dispose();\n };\n}\n\n// disposeSubscriptions tears down every cached area client (their sockets). A\n// host calls this on sign-out so a stale token's socket does not linger.\nexport function disposeSubscriptions(): void {\n for (const client of clients.values()) {\n void client.dispose();\n }\n clients.clear();\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Client-side decode of the DeviceChain access token (ADR-008 JWT). This reads\n// the payload for display + routing only; it does NOT verify the signature \u2014\n// every protected operation is still enforced server-side by the token's\n// authorities. Never trust these claims for authorization decisions beyond what\n// the UI shows.\n\nexport interface DecodedClaims {\n /** Tenant the subject acts within (authoritative server-side). */\n tenant: string;\n /** Human-readable subject identifier. */\n username: string;\n /** Assigned role names \u2014 display/audit only; enforcement is on authorities. */\n roles: string[];\n /** Effective capabilities (e.g. \"device:write\", or \"*\" for super-authority). */\n authorities: string[];\n /** \"access\" | \"refresh\". */\n typ: string;\n /** Expiry, epoch seconds (from the standard `exp` claim). */\n exp?: number;\n}\n\nfunction base64UrlDecode(segment: string): string {\n const padded = segment.replace(/-/g, '+').replace(/_/g, '/');\n const json = atob(padded);\n // Handle UTF-8 payloads.\n return decodeURIComponent(\n Array.prototype.map\n .call(json, (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))\n .join(''),\n );\n}\n\n/** Decode a JWT's payload, or null if it is malformed. */\nexport function decodeToken(token: string): DecodedClaims | null {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n try {\n const payload = JSON.parse(base64UrlDecode(parts[1]));\n return {\n tenant: payload.tenant ?? '',\n username: payload.username ?? '',\n roles: payload.roles ?? [],\n authorities: payload.authorities ?? [],\n typ: payload.typ ?? '',\n exp: payload.exp,\n };\n } catch {\n return null;\n }\n}\n\n/** True if the token is missing, malformed, or within `skewSeconds` of expiry. */\nexport function isExpired(token: string, skewSeconds = 30): boolean {\n const claims = decodeToken(token);\n if (!claims || claims.exp === undefined) return true;\n const nowSeconds = Date.now() / 1000;\n return claims.exp - skewSeconds <= nowSeconds;\n}\n\n/** True if the claims grant the authority (the super-authority \"*\" passes all). */\nexport function hasAuthority(claims: DecodedClaims | null, required: string): boolean {\n if (!claims) return false;\n return claims.authorities.some((a) => a === '*' || a === required);\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Token masks (ADR-042 P3): the client-side template language that mints, and\n// soft-validates, entity tokens. A mask is literal text plus typed placeholders,\n// and one mask drives three operations:\n//\n// generate fill placeholders to produce a concrete token\n// normalize kebab-case a human string into a {slug}\n// validate does a token conform to the mask's shape (soft, UI-only)\n//\n// Masks are advisory: the backend enforces only the security grammar\n// (isValidToken below mirrors rdb.ValidateToken). Style \u2014 lowercase-kebab,\n// prefixes \u2014 lives in the mask, applied here in the console, never on the wire.\n\n// The human-readable alphabet for generated ids: lowercase letters + digits minus\n// the ambiguous 0 o 1 l i, so a minted token can be read back off a label without\n// transcription errors.\nconst READABLE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';\nconst DIGITS = '0123456789';\n\n// The security grammar the backend enforces (rdb.ValidateToken): letters (either\n// case), digits, hyphen and underscore, starting alphanumeric. Kept in sync by\n// hand \u2014 the same regex, mirrored for a pre-submit check so the user sees a bad\n// token before the server rejects it.\nconst TOKEN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;\n\n/** The maximum token length (mirrors rdb.MaxTokenLen). */\nexport const MAX_TOKEN_LEN = 128;\n\n/** isValidToken mirrors the backend security grammar for a pre-submit check. */\nexport function isValidToken(token: string): boolean {\n return token.length > 0 && token.length <= MAX_TOKEN_LEN && TOKEN_GRAMMAR.test(token);\n}\n\n/** A parsed mask segment: a literal run, or a typed placeholder. */\nexport type MaskSegment =\n | { kind: 'literal'; text: string }\n | { kind: 'placeholder'; type: 'alphanumeric' | 'numeric' | 'slug' | 'uuid' | 'unknown'; n?: number; raw: string };\n\nconst PLACEHOLDER = /\\{([a-zA-Z]+)(?:-(\\d+))?\\}/g;\nconst KNOWN = ['alphanumeric', 'numeric', 'slug', 'uuid'] as const;\n\n/** parseMask splits a mask into its literal and placeholder segments. */\nexport function parseMask(mask: string): MaskSegment[] {\n const segments: MaskSegment[] = [];\n let last = 0;\n for (const m of mask.matchAll(PLACEHOLDER)) {\n const at = m.index ?? 0;\n if (at > last) segments.push({ kind: 'literal', text: mask.slice(last, at) });\n const name = m[1].toLowerCase();\n const type = (KNOWN as readonly string[]).includes(name) ? (name as 'alphanumeric') : 'unknown';\n segments.push({ kind: 'placeholder', type, n: m[2] ? parseInt(m[2], 10) : undefined, raw: m[0] });\n last = at + m[0].length;\n }\n if (last < mask.length) segments.push({ kind: 'literal', text: mask.slice(last) });\n return segments;\n}\n\nexport interface GenerateOptions {\n /** A human string (e.g. the entity's name) that fills {slug} placeholders. */\n seed?: string;\n /** Injectable RNG in [0,1) for deterministic tests; defaults to Math.random. */\n random?: () => number;\n /** Injectable uuid for deterministic tests; defaults to crypto.randomUUID. */\n uuid?: () => string;\n}\n\nfunction randomUUID(): string {\n const g = globalThis as { crypto?: { randomUUID?: () => string } };\n if (g.crypto?.randomUUID) return g.crypto.randomUUID();\n // Fallback for environments without Web Crypto (not the console; belt & braces).\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);\n });\n}\n\n/**\n * generateToken fills a mask's placeholders to produce a concrete token:\n * {alphanumeric-N} \u2192 N readable chars, {numeric-N} \u2192 N digits, {uuid} \u2192 a uuid,\n * {slug} \u2192 the normalized seed (or a short readable id when no seed is given, so\n * a \"regenerate\" button always yields something). Literals pass through.\n */\nexport function generateToken(mask: string, opts: GenerateOptions = {}): string {\n const rand = opts.random ?? Math.random;\n const uuid = opts.uuid ?? randomUUID;\n // \uD83D\uDD34 The width is bounded before it is used. A mask may name any width the\n // regexp matches, and `Array.from({length: 1e20})` THROWS RangeError \u2014 which\n // made this function, called during render by the settings editor and by every\n // create form, a way to kill the page from a stored value. No width past\n // MAX_TOKEN_LEN can mint a legal token anyway, so an absurd one contributes\n // nothing rather than exploding. validateMask refuses such a mask outright;\n // this keeps generation safe for a mask stored before it did.\n const pick = (alphabet: string, n: number) =>\n n > MAX_TOKEN_LEN\n ? ''\n : Array.from({ length: n }, () => alphabet[Math.floor(rand() * alphabet.length)]).join('');\n\n return parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return seg.text;\n switch (seg.type) {\n case 'alphanumeric':\n return pick(READABLE_ALPHABET, seg.n ?? 8);\n case 'numeric':\n return pick(DIGITS, seg.n ?? 4);\n case 'slug':\n return opts.seed ? normalizeToken(opts.seed) : pick(READABLE_ALPHABET, 6);\n case 'uuid':\n return uuid();\n default:\n return ''; // an unknown placeholder contributes nothing to a generated token\n }\n })\n .join('');\n}\n\n/**\n * normalizeToken kebab-cases a human string into a slug: lower-case, spaces and\n * underscores to hyphens, drop anything else, collapse and trim hyphens.\n * \"Ops Overview\" \u2192 \"ops-overview\".\n */\nexport function normalizeToken(input: string): string {\n return input\n .toLowerCase()\n .trim()\n .replace(/[\\s_]+/g, '-')\n .replace(/[^a-z0-9-]+/g, '')\n .replace(/-+/g, '-')\n .replace(/^-+|-+$/g, '');\n}\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * maskToRegExp compiles a mask into a whole-string RegExp for soft, UI-only\n * conformance checking. It is intentionally lenient (accepts either case in\n * alphanumeric/uuid segments) \u2014 the backend, not this, is the authority.\n */\nexport function maskToRegExp(mask: string): RegExp {\n const body = parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return escapeRegExp(seg.text);\n switch (seg.type) {\n case 'alphanumeric':\n return `[A-Za-z0-9]{${seg.n ?? 8}}`;\n case 'numeric':\n return `[0-9]{${seg.n ?? 4}}`;\n case 'slug':\n return `[a-z0-9]+(?:-[a-z0-9]+)*`;\n case 'uuid':\n return `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`;\n default:\n return escapeRegExp(seg.raw); // unknown placeholder must match itself literally\n }\n })\n .join('');\n return new RegExp(`^${body}$`);\n}\n\n/** conformsToMask reports whether a token matches the mask's shape (soft check). */\nexport function conformsToMask(mask: string, token: string): boolean {\n return maskToRegExp(mask).test(token);\n}\n\n/** The fixed seed sampleToken fills {slug} placeholders from. */\nconst SAMPLE_SEED = 'Sample Name';\n\n/**\n * sampleToken generates the token a mask would mint, deterministically. Same\n * generator as generateToken \u2014 a pinned RNG and uuid rather than a second\n * implementation, so a sample can never disagree with what a create form\n * actually produces.\n */\nexport function sampleToken(mask: string, seed: string = SAMPLE_SEED): string {\n return generateToken(mask, {\n seed,\n random: () => 0,\n uuid: () => '3f2b8c14-9d7e-4a51-b6c2-0e8d5a1f7b93',\n });\n}\n\n/**\n * Why a mask cannot mint usable tokens. Structured rather than a message\n * because this package is consumed by localized apps \u2014 the caller maps a reason\n * to its own wording.\n */\nexport type MaskProblem =\n | { reason: 'empty' }\n | { reason: 'unknownPlaceholder'; placeholder: string }\n | { reason: 'noPlaceholder' }\n | { reason: 'widthTooLarge'; placeholder: string; max: number }\n | { reason: 'invalidToken'; sample: string };\n\n/**\n * validateMask reports why a mask is unusable, or null when it is fine. It\n * mirrors the server (the tokenmask Go package), which is the authority and\n * refuses the same four things \u2014 every one of which fails SILENTLY otherwise:\n *\n * empty nothing to mint from\n * unknownPlaceholder contributes nothing, so \"dev-{sulg}\" mints \"dev-\"\n * noPlaceholder every entity mints the SAME token; the second create collides\n * invalidToken the minted sample is not a legal token (a space, a slash\u2026)\n *\n * \uD83D\uDD34 Keep this NO STRICTER than the server. A check the server does not make\n * would refuse a mask the platform accepts, and the operator would have no way\n * to get past it.\n */\nexport function validateMask(mask: string): MaskProblem | null {\n if (mask === '') return { reason: 'empty' };\n let placeholders = 0;\n for (const seg of parseMask(mask)) {\n if (seg.kind !== 'placeholder') continue;\n if (seg.type === 'unknown') return { reason: 'unknownPlaceholder', placeholder: seg.raw };\n // \uD83D\uDD34 Checked BEFORE anything samples the mask. This function is called during\n // render, per keystroke \u2014 a width of 5e7 froze the tab for seconds building a\n // string nobody would ever see, and 1e20 threw RangeError and killed the\n // page. Neither reached the `invalidToken` check below, which is where a\n // reader would expect an over-long token to be caught.\n if (seg.n !== undefined && seg.n > MAX_TOKEN_LEN) {\n return { reason: 'widthTooLarge', placeholder: seg.raw, max: MAX_TOKEN_LEN };\n }\n placeholders++;\n }\n if (placeholders === 0) return { reason: 'noPlaceholder' };\n const sample = sampleToken(mask);\n if (!isValidToken(sample)) return { reason: 'invalidToken', sample };\n return null;\n}\n\n/**\n * resolveMask picks the mask for an entity type from a token-masks map, falling\n * back to the \"default\" entry and finally to a bare {slug}.\n */\nexport function resolveMask(masks: Record<string, string>, entityType: string): string {\n return masks[entityType] ?? masks.default ?? '{slug}';\n}\n"],
5
+ "mappings": ";AAyBO,IAAM,QAAQ;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAQO,SAAS,SAAS,MAAoB;AAC3C,SAAO,QAAQ,IAAI;AACrB;AAeO,SAAS,QAAQ,MAAoB;AAC1C,SAAO,KAAK,MAAM,GAAG,EAAE,CAAC;AAC1B;AASO,SAAS,eAAe,MAAqB;AAClD,SAAO,KAAK,SAAS,QAAQ,KAAK,KAAK,SAAS,WAAW;AAC7D;AAQA,IAAI,cAAqD;AAElD,SAAS,mBAAmB,QAA+C;AAChF,gBAAc;AAChB;AAKA,IAAI,sBAA6D;AAE1D,SAAS,uBAAuB,QAA+C;AACpF,wBAAsB;AACxB;AAKA,eAAsB,mBAA2C;AAC/D,SAAO,cAAc,YAAY,IAAI;AACvC;AAIO,IAAM,sBAAN,cAAkC,MAAM;AAAA,EAC7C,YACE,SAEO,QAEA,QACP;AACA,UAAM,OAAO;AAJN;AAEA;AAGP,SAAK,OAAO;AAAA,EACd;AACF;AAuCA,eAAsB,IACpB,MACA,aACG,CAAC,WAAW,OAAO,GAGJ;AAClB,QAAM,UAAkC,EAAE,gBAAgB,mBAAmB;AAE7E,MAAI,CAAC,SAAS,WAAW;AACvB,UAAM,cAAc,SAAS,YAAY,eAAe,IAAI;AAC5D,UAAM,SAAS,cAAc,sBAAsB;AACnD,QAAI,QAAQ;AACV,YAAM,QAAQ,MAAM,OAAO;AAC3B,UAAI,MAAO,SAAQ,eAAe,IAAI,UAAU,KAAK;AAAA,IACvD;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,MAAM,SAAS,IAAI,GAAG;AAAA,MAChC,QAAQ;AAAA,MACR;AAAA,MACA,MAAM,KAAK,UAAU,EAAE,OAAO,SAAS,SAAS,GAAG,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,eAAe,QAAQ,IAAI,UAAU;AAAA,MACrC;AAAA,IACF;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,oBAAoB,mBAAmB,IAAI,MAAM,KAAK,IAAI,MAAM;AAAA,EAC5E;AAEA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,MAAI,KAAK,UAAU,KAAK,OAAO,SAAS,GAAG;AACzC,UAAM,IAAI,oBAAoB,KAAK,OAAO,CAAC,EAAE,SAAS,IAAI,QAAQ,KAAK,MAAM;AAAA,EAC/E;AACA,MAAI,KAAK,SAAS,QAAW;AAC3B,UAAM,IAAI,oBAAoB,0BAA0B,IAAI,MAAM;AAAA,EACpE;AACA,SAAO,KAAK;AACd;;;AClLA,IAAM,mBAAmB;AAOlB,SAAS,iBAAiB,KAAuB;AACtD,MAAI,EAAE,eAAe,qBAAsB,QAAO;AAElD,MAAI,IAAI,WAAW,IAAK,QAAO;AAC/B,QAAM,WAAW,IAAI,QAAQ,SAAS,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,CAAC,IAAI,OAAO;AACrF,SAAO,SAAS,KAAK,CAAC,MAAM,iBAAiB,KAAK,EAAE,KAAK,CAAC,CAAC;AAC7D;;;ACFA,SAAS,KAAK,GAA6C;AACzD,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,UAAU,EAAE,KAAK;AACvB,SAAO,YAAY,KAAK,OAAO;AACjC;AAEA,SAAS,IAAI,GAA6C;AACxD,SAAO,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,IAAI,IAAI;AAC3D;AAeO,SAAS,eAAe,UAAsC,QAA6C;AAChH,QAAM,IAAI,YAAY,CAAC;AACvB,QAAM,IAAI,UAAU,CAAC;AAsBrB,QAAM,sBAAsB,KAAK,EAAE,WAAW;AAC9C,QAAM,gBAAgB,wBAAwB,OAAO,OAAO,KAAK,EAAE,OAAO;AAC1E,QAAM,UAAU,iBAAiB,KAAK,EAAE,OAAO;AAC/C,QAAM,cAAc,gBAAgB,sBAAsB,KAAK,EAAE,WAAW;AAI5E,QAAM,oBAAoB,IAAI,EAAE,SAAS,MAAM,QAAQ,IAAI,EAAE,SAAS,MAAM;AAC5E,QAAM,YAAY,oBAAoB,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE,SAAS;AACxE,QAAM,YAAY,oBAAoB,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE,SAAS;AAExE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,MAAM,IAAI,EAAE,IAAI,KAAK,IAAI,EAAE,IAAI;AAAA,EACjC;AACF;AASO,SAAS,kBAAkB,GAAuD;AACvF,QAAM,UAAU,KAAK,GAAG,OAAO;AAC/B,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO;AAAA,IACL;AAAA,IACA,aAAa,KAAK,GAAG,WAAW;AAAA,IAChC,WAAW,IAAI,GAAG,SAAS;AAAA,IAC3B,WAAW,IAAI,GAAG,SAAS;AAAA,IAC3B,MAAM,IAAI,GAAG,IAAI;AAAA,EACnB;AACF;AAUO,SAAS,aAAa,GAAyF;AACpH,QAAM,MAAM,IAAI,GAAG,SAAS;AAC5B,QAAM,MAAM,IAAI,GAAG,SAAS;AAC5B,MAAI,QAAQ,QAAQ,QAAQ,KAAM,QAAO;AACzC,SAAO,EAAE,QAAQ,CAAC,KAAK,GAAG,GAAG,MAAM,IAAI,GAAG,IAAI,EAAE;AAClD;;;ACvHA,SAAS,oBAAiC;AAM1C,SAAS,MAAM,MAAoB;AACjC,QAAM,SAAS,OAAO,SAAS,aAAa,WAAW,QAAQ;AAC/D,SAAO,GAAG,MAAM,MAAM,OAAO,SAAS,IAAI,GAAG,SAAS,IAAI,CAAC;AAC7D;AAKA,IAAM,UAAU,oBAAI,IAAkB;AAEtC,SAAS,UAAU,MAAoB;AACrC,MAAI,SAAS,QAAQ,IAAI,IAAI;AAC7B,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa;AAAA,MACpB,KAAK,MAAM,IAAI;AAAA,MACf,kBAAkB,YAAY;AAC5B,cAAM,QAAQ,MAAM,iBAAiB;AACrC,eAAO,QAAQ,EAAE,eAAe,UAAU,KAAK,GAAG,IAAI,CAAC;AAAA,MACzD;AAAA,IACF,CAAC;AACD,YAAQ,IAAI,MAAM,MAAM;AAAA,EAC1B;AACA,SAAO;AACT;AAoBO,SAAS,UACd,MACA,UACA,WACA,MACY;AACZ,QAAM,SAAS,UAAU,IAAI;AAG7B,QAAM,YAA+B,CAAC;AACtC,MAAI,KAAK,WAAW;AAClB,cAAU,KAAK,OAAO,GAAG,aAAa,CAAC,SAAS,UAAU,aAAa,KAAK,UAAW,QAAQ,CAAC,CAAC;AAAA,EACnG;AACA,MAAI,KAAK,QAAQ;AACf,cAAU,KAAK,OAAO,GAAG,UAAU,MAAM,KAAK,OAAQ,CAAC,CAAC;AAAA,EAC1D;AACA,YAAU;AAAA,IACR,OAAO;AAAA,MACL;AAAA,QACE,OAAO,SAAS,SAAS;AAAA,QACzB;AAAA,MACF;AAAA,MACA;AAAA,QACE,MAAM,CAAC,WAAW;AAChB,cAAI,OAAO,QAAQ,KAAM,MAAK,KAAK,OAAO,IAAI;AAAA,QAChD;AAAA,QACA,OAAO,CAAC,QAAQ,KAAK,QAAQ,GAAG;AAAA,QAChC,UAAU,MAAM,KAAK,WAAW;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AACA,SAAO,MAAM;AACX,eAAW,WAAW,UAAW,SAAQ;AAAA,EAC3C;AACF;AAIO,SAAS,uBAA6B;AAC3C,aAAW,UAAU,QAAQ,OAAO,GAAG;AACrC,SAAK,OAAO,QAAQ;AAAA,EACtB;AACA,UAAQ,MAAM;AAChB;;;AClFA,SAAS,gBAAgB,SAAyB;AAChD,QAAM,SAAS,QAAQ,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AAC3D,QAAM,OAAO,KAAK,MAAM;AAExB,SAAO;AAAA,IACL,MAAM,UAAU,IACb,KAAK,MAAM,CAAC,MAAc,OAAO,OAAO,EAAE,WAAW,CAAC,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC,EAC/E,KAAK,EAAE;AAAA,EACZ;AACF;AAGO,SAAS,YAAY,OAAqC;AAC/D,QAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,MAAI;AACF,UAAM,UAAU,KAAK,MAAM,gBAAgB,MAAM,CAAC,CAAC,CAAC;AACpD,WAAO;AAAA,MACL,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU,QAAQ,YAAY;AAAA,MAC9B,OAAO,QAAQ,SAAS,CAAC;AAAA,MACzB,aAAa,QAAQ,eAAe,CAAC;AAAA,MACrC,KAAK,QAAQ,OAAO;AAAA,MACpB,KAAK,QAAQ;AAAA,IACf;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,UAAU,OAAe,cAAc,IAAa;AAClE,QAAM,SAAS,YAAY,KAAK;AAChC,MAAI,CAAC,UAAU,OAAO,QAAQ,OAAW,QAAO;AAChD,QAAM,aAAa,KAAK,IAAI,IAAI;AAChC,SAAO,OAAO,MAAM,eAAe;AACrC;AAGO,SAAS,aAAa,QAA8B,UAA2B;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,OAAO,YAAY,KAAK,CAAC,MAAM,MAAM,OAAO,MAAM,QAAQ;AACnE;;;AChDA,IAAM,oBAAoB;AAC1B,IAAM,SAAS;AAMf,IAAM,gBAAgB;AAGf,IAAM,gBAAgB;AAGtB,SAAS,aAAa,OAAwB;AACnD,SAAO,MAAM,SAAS,KAAK,MAAM,UAAU,iBAAiB,cAAc,KAAK,KAAK;AACtF;AAOA,IAAM,cAAc;AACpB,IAAM,QAAQ,CAAC,gBAAgB,WAAW,QAAQ,MAAM;AAGjD,SAAS,UAAU,MAA6B;AACrD,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO;AACX,aAAW,KAAK,KAAK,SAAS,WAAW,GAAG;AAC1C,UAAM,KAAK,EAAE,SAAS;AACtB,QAAI,KAAK,KAAM,UAAS,KAAK,EAAE,MAAM,WAAW,MAAM,KAAK,MAAM,MAAM,EAAE,EAAE,CAAC;AAC5E,UAAM,OAAO,EAAE,CAAC,EAAE,YAAY;AAC9B,UAAM,OAAQ,MAA4B,SAAS,IAAI,IAAK,OAA0B;AACtF,aAAS,KAAK,EAAE,MAAM,eAAe,MAAM,GAAG,EAAE,CAAC,IAAI,SAAS,EAAE,CAAC,GAAG,EAAE,IAAI,QAAW,KAAK,EAAE,CAAC,EAAE,CAAC;AAChG,WAAO,KAAK,EAAE,CAAC,EAAE;AAAA,EACnB;AACA,MAAI,OAAO,KAAK,OAAQ,UAAS,KAAK,EAAE,MAAM,WAAW,MAAM,KAAK,MAAM,IAAI,EAAE,CAAC;AACjF,SAAO;AACT;AAWA,SAAS,aAAqB;AAC5B,QAAM,IAAI;AACV,MAAI,EAAE,QAAQ,WAAY,QAAO,EAAE,OAAO,WAAW;AAErD,SAAO,uCAAuC,QAAQ,SAAS,CAAC,MAAM;AACpE,UAAM,IAAK,KAAK,OAAO,IAAI,KAAM;AACjC,YAAQ,MAAM,MAAM,IAAK,IAAI,IAAO,GAAK,SAAS,EAAE;AAAA,EACtD,CAAC;AACH;AAQO,SAAS,cAAc,MAAc,OAAwB,CAAC,GAAW;AAC9E,QAAM,OAAO,KAAK,UAAU,KAAK;AACjC,QAAM,OAAO,KAAK,QAAQ;AAQ1B,QAAM,OAAO,CAAC,UAAkB,MAC9B,IAAI,gBACA,KACA,MAAM,KAAK,EAAE,QAAQ,EAAE,GAAG,MAAM,SAAS,KAAK,MAAM,KAAK,IAAI,SAAS,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE;AAE7F,SAAO,UAAU,IAAI,EAClB,IAAI,CAAC,QAAQ;AACZ,QAAI,IAAI,SAAS,UAAW,QAAO,IAAI;AACvC,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,eAAO,KAAK,mBAAmB,IAAI,KAAK,CAAC;AAAA,MAC3C,KAAK;AACH,eAAO,KAAK,QAAQ,IAAI,KAAK,CAAC;AAAA,MAChC,KAAK;AACH,eAAO,KAAK,OAAO,eAAe,KAAK,IAAI,IAAI,KAAK,mBAAmB,CAAC;AAAA,MAC1E,KAAK;AACH,eAAO,KAAK;AAAA,MACd;AACE,eAAO;AAAA,IACX;AAAA,EACF,CAAC,EACA,KAAK,EAAE;AACZ;AAOO,SAAS,eAAe,OAAuB;AACpD,SAAO,MACJ,YAAY,EACZ,KAAK,EACL,QAAQ,WAAW,GAAG,EACtB,QAAQ,gBAAgB,EAAE,EAC1B,QAAQ,OAAO,GAAG,EAClB,QAAQ,YAAY,EAAE;AAC3B;AAEA,SAAS,aAAa,GAAmB;AACvC,SAAO,EAAE,QAAQ,uBAAuB,MAAM;AAChD;AAOO,SAAS,aAAa,MAAsB;AACjD,QAAM,OAAO,UAAU,IAAI,EACxB,IAAI,CAAC,QAAQ;AACZ,QAAI,IAAI,SAAS,UAAW,QAAO,aAAa,IAAI,IAAI;AACxD,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,eAAO,eAAe,IAAI,KAAK,CAAC;AAAA,MAClC,KAAK;AACH,eAAO,SAAS,IAAI,KAAK,CAAC;AAAA,MAC5B,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT;AACE,eAAO,aAAa,IAAI,GAAG;AAAA,IAC/B;AAAA,EACF,CAAC,EACA,KAAK,EAAE;AACV,SAAO,IAAI,OAAO,IAAI,IAAI,GAAG;AAC/B;AAGO,SAAS,eAAe,MAAc,OAAwB;AACnE,SAAO,aAAa,IAAI,EAAE,KAAK,KAAK;AACtC;AAGA,IAAM,cAAc;AAQb,SAAS,YAAY,MAAc,OAAe,aAAqB;AAC5E,SAAO,cAAc,MAAM;AAAA,IACzB;AAAA,IACA,QAAQ,MAAM;AAAA,IACd,MAAM,MAAM;AAAA,EACd,CAAC;AACH;AA4BO,SAAS,aAAa,MAAkC;AAC7D,MAAI,SAAS,GAAI,QAAO,EAAE,QAAQ,QAAQ;AAC1C,MAAI,eAAe;AACnB,aAAW,OAAO,UAAU,IAAI,GAAG;AACjC,QAAI,IAAI,SAAS,cAAe;AAChC,QAAI,IAAI,SAAS,UAAW,QAAO,EAAE,QAAQ,sBAAsB,aAAa,IAAI,IAAI;AAMxF,QAAI,IAAI,MAAM,UAAa,IAAI,IAAI,eAAe;AAChD,aAAO,EAAE,QAAQ,iBAAiB,aAAa,IAAI,KAAK,KAAK,cAAc;AAAA,IAC7E;AACA;AAAA,EACF;AACA,MAAI,iBAAiB,EAAG,QAAO,EAAE,QAAQ,gBAAgB;AACzD,QAAM,SAAS,YAAY,IAAI;AAC/B,MAAI,CAAC,aAAa,MAAM,EAAG,QAAO,EAAE,QAAQ,gBAAgB,OAAO;AACnE,SAAO;AACT;AAMO,SAAS,YAAY,OAA+B,YAA4B;AACrF,SAAO,MAAM,UAAU,KAAK,MAAM,WAAW;AAC/C;",
6
+ "names": []
7
+ }
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface DecodedClaims {
2
+ /** Tenant the subject acts within (authoritative server-side). */
3
+ tenant: string;
4
+ /** Human-readable subject identifier. */
5
+ username: string;
6
+ /** Assigned role names — display/audit only; enforcement is on authorities. */
7
+ roles: string[];
8
+ /** Effective capabilities (e.g. "device:write", or "*" for super-authority). */
9
+ authorities: string[];
10
+ /** "access" | "refresh". */
11
+ typ: string;
12
+ /** Expiry, epoch seconds (from the standard `exp` claim). */
13
+ exp?: number;
14
+ }
15
+ /** Decode a JWT's payload, or null if it is malformed. */
16
+ export declare function decodeToken(token: string): DecodedClaims | null;
17
+ /** True if the token is missing, malformed, or within `skewSeconds` of expiry. */
18
+ export declare function isExpired(token: string, skewSeconds?: number): boolean;
19
+ /** True if the claims grant the authority (the super-authority "*" passes all). */
20
+ export declare function hasAuthority(claims: DecodedClaims | null, required: string): boolean;
@@ -0,0 +1,13 @@
1
+ import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';
2
+ import { type Area } from './transport';
3
+ export interface SubscriptionSink<T> {
4
+ next: (data: T) => void;
5
+ error?: (err: unknown) => void;
6
+ complete?: () => void;
7
+ connected?: (wasRetry: boolean) => void;
8
+ closed?: () => void;
9
+ }
10
+ export declare function subscribe<TResult, TVariables>(area: Area, document: DocumentTypeDecoration<TResult, TVariables> & {
11
+ toString(): string;
12
+ }, variables: TVariables extends Record<string, never> ? undefined : TVariables, sink: SubscriptionSink<TResult>): () => void;
13
+ export declare function disposeSubscriptions(): void;
@@ -0,0 +1,92 @@
1
+ /** The maximum token length (mirrors rdb.MaxTokenLen). */
2
+ export declare const MAX_TOKEN_LEN = 128;
3
+ /** isValidToken mirrors the backend security grammar for a pre-submit check. */
4
+ export declare function isValidToken(token: string): boolean;
5
+ /** A parsed mask segment: a literal run, or a typed placeholder. */
6
+ export type MaskSegment = {
7
+ kind: 'literal';
8
+ text: string;
9
+ } | {
10
+ kind: 'placeholder';
11
+ type: 'alphanumeric' | 'numeric' | 'slug' | 'uuid' | 'unknown';
12
+ n?: number;
13
+ raw: string;
14
+ };
15
+ /** parseMask splits a mask into its literal and placeholder segments. */
16
+ export declare function parseMask(mask: string): MaskSegment[];
17
+ export interface GenerateOptions {
18
+ /** A human string (e.g. the entity's name) that fills {slug} placeholders. */
19
+ seed?: string;
20
+ /** Injectable RNG in [0,1) for deterministic tests; defaults to Math.random. */
21
+ random?: () => number;
22
+ /** Injectable uuid for deterministic tests; defaults to crypto.randomUUID. */
23
+ uuid?: () => string;
24
+ }
25
+ /**
26
+ * generateToken fills a mask's placeholders to produce a concrete token:
27
+ * {alphanumeric-N} → N readable chars, {numeric-N} → N digits, {uuid} → a uuid,
28
+ * {slug} → the normalized seed (or a short readable id when no seed is given, so
29
+ * a "regenerate" button always yields something). Literals pass through.
30
+ */
31
+ export declare function generateToken(mask: string, opts?: GenerateOptions): string;
32
+ /**
33
+ * normalizeToken kebab-cases a human string into a slug: lower-case, spaces and
34
+ * underscores to hyphens, drop anything else, collapse and trim hyphens.
35
+ * "Ops Overview" → "ops-overview".
36
+ */
37
+ export declare function normalizeToken(input: string): string;
38
+ /**
39
+ * maskToRegExp compiles a mask into a whole-string RegExp for soft, UI-only
40
+ * conformance checking. It is intentionally lenient (accepts either case in
41
+ * alphanumeric/uuid segments) — the backend, not this, is the authority.
42
+ */
43
+ export declare function maskToRegExp(mask: string): RegExp;
44
+ /** conformsToMask reports whether a token matches the mask's shape (soft check). */
45
+ export declare function conformsToMask(mask: string, token: string): boolean;
46
+ /**
47
+ * sampleToken generates the token a mask would mint, deterministically. Same
48
+ * generator as generateToken — a pinned RNG and uuid rather than a second
49
+ * implementation, so a sample can never disagree with what a create form
50
+ * actually produces.
51
+ */
52
+ export declare function sampleToken(mask: string, seed?: string): string;
53
+ /**
54
+ * Why a mask cannot mint usable tokens. Structured rather than a message
55
+ * because this package is consumed by localized apps — the caller maps a reason
56
+ * to its own wording.
57
+ */
58
+ export type MaskProblem = {
59
+ reason: 'empty';
60
+ } | {
61
+ reason: 'unknownPlaceholder';
62
+ placeholder: string;
63
+ } | {
64
+ reason: 'noPlaceholder';
65
+ } | {
66
+ reason: 'widthTooLarge';
67
+ placeholder: string;
68
+ max: number;
69
+ } | {
70
+ reason: 'invalidToken';
71
+ sample: string;
72
+ };
73
+ /**
74
+ * validateMask reports why a mask is unusable, or null when it is fine. It
75
+ * mirrors the server (the tokenmask Go package), which is the authority and
76
+ * refuses the same four things — every one of which fails SILENTLY otherwise:
77
+ *
78
+ * empty nothing to mint from
79
+ * unknownPlaceholder contributes nothing, so "dev-{sulg}" mints "dev-"
80
+ * noPlaceholder every entity mints the SAME token; the second create collides
81
+ * invalidToken the minted sample is not a legal token (a space, a slash…)
82
+ *
83
+ * 🔴 Keep this NO STRICTER than the server. A check the server does not make
84
+ * would refuse a mask the platform accepts, and the operator would have no way
85
+ * to get past it.
86
+ */
87
+ export declare function validateMask(mask: string): MaskProblem | null;
88
+ /**
89
+ * resolveMask picks the mask for an entity type from a token-masks map, falling
90
+ * back to the "default" entry and finally to a bare {slug}.
91
+ */
92
+ export declare function resolveMask(masks: Record<string, string>, entityType: string): string;
@@ -0,0 +1,54 @@
1
+ import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';
2
+ export declare const AREAS: readonly ['user-management', 'user-management/admin', 'user-management/settings', 'device-management', 'event-management', 'event-processing', 'device-state', 'command-delivery', 'dashboard-management', 'outbound-connectors', 'ai-inference/admin'];
3
+ export type Area = (typeof AREAS)[number];
4
+ export declare function areaPath(area: Area): string;
5
+ /**
6
+ * The deployed functional area an Area is served by — the segment before the
7
+ * first slash, since a suffix like `/admin` or `/settings` names a PLANE of an
8
+ * area rather than an area of its own.
9
+ *
10
+ * This is the join between an Area (a client-side routing convention) and the
11
+ * area vocabulary the server reports from what the chart actually deployed, so
12
+ * the console can tell "never deployed here" apart from a transport failure.
13
+ * Note it is deliberately many-to-one: `user-management`,
14
+ * `user-management/admin` and `user-management/settings` all key to
15
+ * `user-management`, and the reverse direction does not exist — plenty of
16
+ * deployed areas (event-sources, notification-management) have no Area member.
17
+ */
18
+ export declare function areaKey(area: Area): string;
19
+ export declare function isIdentityArea(area: Area): boolean;
20
+ export declare function setAuthTokenGetter(getter: (() => Promise<string | null>) | null): void;
21
+ export declare function setIdentityTokenGetter(getter: (() => Promise<string | null>) | null): void;
22
+ export declare function resolveAuthToken(): Promise<string | null>;
23
+ export declare class GraphQLRequestError extends Error {
24
+ /** HTTP status, or 0 for a transport-level failure. */
25
+ status: number;
26
+ /** The raw `errors` array from a GraphQL response, when present. */
27
+ errors?: {
28
+ message: string;
29
+ }[] | undefined;
30
+ constructor(message: string,
31
+ /** HTTP status, or 0 for a transport-level failure. */
32
+ status: number,
33
+ /** The raw `errors` array from a GraphQL response, when present. */
34
+ errors?: {
35
+ message: string;
36
+ }[] | undefined);
37
+ }
38
+ export interface RequestOptions {
39
+ /** Skip attaching the Bearer token (login / refresh run unauthenticated). */
40
+ anonymous?: boolean;
41
+ /**
42
+ * Authenticate with the identity token instead of the tenant access token —
43
+ * for the instance-scoped admin API (ADR-033). Ignored when `anonymous`.
44
+ *
45
+ * Rarely needed: an `/admin` or `/settings` area defaults to the identity token
46
+ * already (see isIdentityArea). Pass it only to force the lane for an area whose
47
+ * name does not say so.
48
+ */
49
+ identity?: boolean;
50
+ }
51
+ export type TypedDocument<TResult, TVariables> = DocumentTypeDecoration<TResult, TVariables> & {
52
+ toString(): string;
53
+ };
54
+ export declare function gql<TResult, TVariables>(area: Area, document: TypedDocument<TResult, TVariables>, ...[variables, options]: TVariables extends Record<string, never> ? [variables?: undefined, options?: RequestOptions] : [variables: TVariables, options?: RequestOptions]): Promise<TResult>;
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@devicechain/client",
3
+ "version": "0.14.0-0",
4
+ "description": "DeviceChain client SDK — two-tier auth, typed GraphQL operations, and graphql-ws subscriptions over the client wire.",
5
+ "keywords": [
6
+ "devicechain",
7
+ "iot",
8
+ "graphql",
9
+ "sdk",
10
+ "typescript"
11
+ ],
12
+ "license": "Apache-2.0",
13
+ "homepage": "https://devicechain.io",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/devicechain-io/devicechain.git",
17
+ "directory": "frontend/packages/client"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/devicechain-io/devicechain/issues"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "node ../../scripts/build-package.mjs",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@graphql-typed-document-node/core": "^3.2.0",
47
+ "graphql-ws": "^6.2.1"
48
+ },
49
+ "peerDependencies": {
50
+ "graphql": "^16.0.0 || ^17.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "graphql": "^17.0.2",
54
+ "typescript": "~7.0.2"
55
+ }
56
+ }