@uns-kit/core 3.0.47 → 3.0.48

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/MIGRATIONS.md CHANGED
@@ -9,6 +9,16 @@ Agents must inspect the application's existing ownership and shutdown flow
9
9
  before editing it. The examples below describe the intended behavior, not a
10
10
  mechanical search-and-replace operation.
11
11
 
12
+ ## 3.0.48 - Checked Service access documentation and examples
13
+
14
+ This patch clarifies existing 3.0.47 contracts; no new permission mode or automatic
15
+ application migration is introduced. Follow the
16
+ [source walkthrough](https://github.com/uns-openhub/uns-kit/blob/master/docs/catalog-publication-access.md)
17
+ or bundled `@uns-kit/core/runtime/service-access-guide.md` and complete projects in
18
+ `dist/runtime/service-access-examples/`. Examples use a rotating token provider;
19
+ background API reads, caller-mediated catalogs and MQTT ACLs are separate.
20
+ An application adopting the capability must still follow the 3.0.47 entry below.
21
+
12
22
  ## 3.0.47 - Common Service access and publications
13
23
 
14
24
  When crossing `<3.0.47` to `>=3.0.47`, adoption is explicit; existing endpoint authorization and caller forwarding do
package/README.md CHANGED
@@ -8,10 +8,10 @@ Note: Apps built with uns-kit are intended to be managed by the **UNS OpenHub co
8
8
 
9
9
  | Package | Description |
10
10
  | --- | --- |
11
- | [`@uns-kit/core`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-core) | Base runtime (UnsProxyProcess, MQTT helpers, config tooling, gRPC gateway). |
12
- | [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-api) | Express plugin — HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
13
- | [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
14
- | [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
11
+ | [`@uns-kit/core`](https://github.com/uns-openhub/uns-kit/tree/master/packages/uns-core) | Base runtime (UnsProxyProcess, MQTT helpers, config tooling, gRPC gateway). |
12
+ | [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/master/packages/uns-api) | Express plugin — HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
13
+ | [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/master/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
14
+ | [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/master/packages/uns-cli) | CLI for scaffolding new UNS applications. |
15
15
 
16
16
  ## Installation
17
17
 
@@ -95,47 +95,49 @@ const cron = await proc.createCrontabProxy("*/5 * * * *", { event: "tick" });
95
95
 
96
96
  ## Datahub client (last value + history)
97
97
 
98
- `UnsClient` provides a minimal REST client for the UNS OpenHub API, including batch last-value, single-topic catch-all history, and batch range endpoints. Prefer a long-lived service token if available; you can pass it directly and skip username/password auth.
98
+ `UnsClient` provides a minimal REST client for the UNS OpenHub API, including batch last-value, single-topic catch-all history, and batch range endpoints. Use `ServiceTokenProvider` for Controller-managed services so credential rotation is picked up on every request. Review the service's declared scopes and resource paths before Start; never cache a managed token at application startup.
99
99
 
100
- ```ts
101
- import { UnsClient } from "@uns-kit/core";
102
- import { ConfigFile } from "@uns-kit/core";
100
+ Read the [source Service access walkthrough](https://github.com/uns-openhub/uns-kit/blob/master/docs/catalog-publication-access.md)
101
+ or bundled `@uns-kit/core/runtime/service-access-guide.md`. Complete background-reader
102
+ and catalog projects ship in `dist/runtime/service-access-examples/`. A background
103
+ reader uses its service identity only; a catalog operation also carries its caller's
104
+ identity. These API permissions do not create MQTT ACLs or SQL grants.
103
105
 
104
- const config = await ConfigFile.loadConfig();
106
+ ```ts
107
+ import { ServiceTokenProvider, UnsClient } from "@uns-kit/core";
105
108
 
106
- const client = new UnsClient("https://datahub.example.com", {
107
- token: process.env.UNS_SERVICE_TOKEN ?? config.uns.token,
108
- });
109
+ const baseUrl = process.env.UNS_CONTROLLER_PUBLIC_BASE;
110
+ if (!baseUrl) throw new Error("Set the trusted Controller base URL.");
111
+ const client = new UnsClient(baseUrl, { tokenProvider: new ServiceTokenProvider() });
109
112
 
110
113
  const values = await client.lastValue([
111
- "raw/data/line-1/motor/main/temperature",
112
- "raw/data/line-1/motor/main/status",
114
+ "example/plant/line-1/motor/main/temperature",
115
+ "example/plant/line-1/motor/main/status",
113
116
  ]);
114
- console.log(values);
117
+ // Process the values without logging private records.
115
118
 
116
- const data = await client.getAttributeData("sij/acroni/vv/hrm-furnace/equipment/pusher/output-quantity", {
119
+ const data = await client.getAttributeData("example/plant/line-1/motor/main/temperature", {
117
120
  from: "2026-05-07T11:17:01.157Z",
118
121
  to: "2026-05-07T11:22:01.157Z",
119
- table: "uns_sij_hrm_furnace_data",
120
122
  aggregate: "last",
121
123
  dedupe: false,
122
124
  });
123
- console.log(data?.toRecords());
125
+ const records = data?.toRecords();
124
126
 
125
127
  const customData = await client.getData("/projects/project-name/path-to-data/data", {
126
128
  fromDate: "20260325",
127
129
  });
128
- console.log(await customData.json());
130
+ const customResult = await customData.json();
129
131
 
130
132
  const batchHistory = await client.history([
131
- "sij/acroni/vv/hrm-furnace/equipment/zone-1/temperature",
132
- "sij/acroni/vv/hrm-furnace/equipment/zone-2/temperature",
133
+ "example/plant/line-1/motor/main/temperature",
134
+ "example/plant/line-1/motor/main/status",
133
135
  ], {
134
136
  from: "2026-04-09T06:00:00Z",
135
137
  to: "2026-04-09T07:00:00Z",
136
138
  limit: 500,
137
139
  });
138
- console.log(batchHistory?.byTopic);
140
+ // Process the bounded batch result.
139
141
  ```
140
142
 
141
143
  ## Sub-Asset Publishing
@@ -0,0 +1,20 @@
1
+ # Service access examples
2
+
3
+ - `background-reader/`: no API or catalog; a reviewed workload token reads one UNS history source.
4
+ - `catalog-summary/`: a catalog-only caller requests a bounded derived count; the workload has no direct source permission.
5
+
6
+ Each folder is an independent, credential-free Node.js package. Copy one folder
7
+ to a new project, install dependencies and build. Deploy/review/start with a
8
+ Controller supporting common Service access (verified with Controller 2.2.95).
9
+ The [walkthrough](https://github.com/uns-openhub/uns-kit/blob/master/docs/catalog-publication-access.md)
10
+ explains what the developer, administrator, Controller, SDK and caller each own.
11
+
12
+ The examples are also shipped in `@uns-kit/core/dist/runtime/service-access-examples/`.
13
+ No real broker, database, user or production endpoint is supplied. Configure your
14
+ own local test resources. These files create no identities, grants or MQTT ACLs.
15
+
16
+ Maintainers: run `pnpm run ts:build` then `pnpm run test:service-access-examples`.
17
+ The verifier packs the SDK, installs it in a temporary consumer, builds/packs both
18
+ examples, and exercises their real handlers/token provider against a synthetic
19
+ loopback API. This checks imports, shipped files, token rotation, denial and
20
+ closed output, not real Controller policy, MQTT connectivity or cluster ownership.
@@ -0,0 +1,25 @@
1
+ # background-reader
2
+
3
+ A complete Node.js 22+ example using published SDK APIs from 3.0.47 onward.
4
+ Read the [Service access walkthrough](https://github.com/uns-openhub/uns-kit/blob/master/docs/catalog-publication-access.md) for the declaration, Controller review and acceptance steps.
5
+
6
+ ```sh
7
+ pnpm install
8
+ pnpm run build
9
+ cp config-example.json config.json
10
+ ```
11
+
12
+ The example configuration targets a local development broker/Controller. Select
13
+ your own endpoints and secret references before deployment. The Controller owns
14
+ instance configuration when deployed. `MQTT_USERNAME`/`MQTT_PASSWORD` concern broker
15
+ ACLs only and grant no OpenHub API rights. Do not commit `config.json` or `.env`.
16
+
17
+ Deploy the package, apply its exact-version Service access policy, and then Start.
18
+ The Controller supplies managed identity/credential paths. Do not manufacture RTT
19
+ launch variables or paste a human token into configuration.
20
+
21
+ This worker has no API or catalog. It polls five minutes of history, at most
22
+ 100 rows, every 30 seconds while active. Provision the exact declared source in
23
+ your own test environment; zero rows is a valid result. Its token can read that
24
+ source, while another source must return 403. SIGINT/SIGTERM aborts the current
25
+ request and stops polling before runtime cleanup.
@@ -0,0 +1,7 @@
1
+ import { copyFile, mkdir, rm } from "node:fs/promises";
2
+
3
+ await rm("dist", { recursive: true, force: true });
4
+ await mkdir("dist");
5
+ await copyFile("src/index.mjs", "dist/index.js");
6
+ await copyFile("src/reader.mjs", "dist/reader.mjs");
7
+ // service-access.json stays at the package root, as declared in package.json.
@@ -0,0 +1,22 @@
1
+ {
2
+ "infra": {
3
+ "host": "localhost"
4
+ },
5
+ "uns": {
6
+ "processName": "example-background-reader",
7
+ "instanceMode": "wait",
8
+ "handover": false
9
+ },
10
+ "mqtt": {
11
+ "username": {
12
+ "provider": "env",
13
+ "key": "MQTT_USERNAME",
14
+ "default": ""
15
+ },
16
+ "password": {
17
+ "provider": "env",
18
+ "key": "MQTT_PASSWORD",
19
+ "default": ""
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "example-background-reader",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "service-access.json",
13
+ "config-example.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "node build.mjs",
17
+ "start": "node dist/index.js"
18
+ },
19
+ "dependencies": {
20
+ "@uns-kit/core": "^3.0.47"
21
+ },
22
+ "unsDatahub": {
23
+ "schemaVersion": 1,
24
+ "kind": "addon",
25
+ "controllerCompatibility": ">=2 <3",
26
+ "serviceAccess": "service-access.json"
27
+ }
28
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "protocol": 1,
3
+ "permissions": {
4
+ "scopes": [
5
+ "read:uns"
6
+ ],
7
+ "accessRules": [
8
+ "example/background/line-1/equipment/main/temperature"
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,55 @@
1
+ import { ConfigFile, UnsProxyProcess } from "@uns-kit/core";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ import { readHistory } from "./reader.mjs";
5
+
6
+ const config = await ConfigFile.loadConfig();
7
+ const controllerBase = process.env.UNS_CONTROLLER_PUBLIC_BASE;
8
+ if (!controllerBase) throw new Error("UNS_CONTROLLER_PUBLIC_BASE must name your Controller.");
9
+ const stop = new AbortController();
10
+ const runtime = new UnsProxyProcess(config.infra.host, {
11
+ processName: config.uns.processName,
12
+ username: config.mqtt?.username,
13
+ password: config.mqtt?.password,
14
+ });
15
+ let currentRead = Promise.resolve();
16
+ let stopping;
17
+ const shutdown = () =>
18
+ (stopping ??= (async () => {
19
+ stop.abort();
20
+ await currentRead.catch(() => {});
21
+ await runtime.shutdown();
22
+ })());
23
+ for (const signal of ["SIGINT", "SIGTERM"])
24
+ process.once(signal, () => {
25
+ void shutdown().then(
26
+ () => process.exit(0),
27
+ () => {
28
+ process.exitCode = 1;
29
+ },
30
+ );
31
+ });
32
+ try {
33
+ await runtime.prepareHandover(config.uns.instanceMode, config.uns.handover);
34
+ while (!stop.signal.aborted) {
35
+ if (runtime.isActive()) {
36
+ const to = new Date(),
37
+ from = new Date(to.getTime() - 5 * 60000);
38
+ try {
39
+ currentRead = readHistory(controllerBase, from.toISOString(), to.toISOString(), { signal: stop.signal });
40
+ const rows = await currentRead;
41
+ // Put application processing here. Never log bearer values or raw records.
42
+ console.info(`Read ${rows.length} samples.`);
43
+ } catch {
44
+ if (!stop.signal.aborted) console.warn("History read failed; check Service access and API availability.");
45
+ }
46
+ }
47
+ try {
48
+ await delay(30000, undefined, { signal: stop.signal });
49
+ } catch (error) {
50
+ if (!stop.signal.aborted) throw error;
51
+ }
52
+ }
53
+ } finally {
54
+ await shutdown();
55
+ }
@@ -0,0 +1,21 @@
1
+ import { ServiceTokenProvider } from "@uns-kit/core/tools/auth/service-token-provider.js";
2
+
3
+ export const sourceTopic = "example/background/line-1/equipment/main/temperature";
4
+
5
+ /** A background API read has a workload identity and no human caller token. */
6
+ export async function readHistory(controllerBase, from, to, { tokenProvider = new ServiceTokenProvider(), fetch: request = fetch, signal } = {}) {
7
+ const token = await tokenProvider.getAccessToken();
8
+ if (!token) throw new Error("Service credential unavailable; review Service access.");
9
+ const response = await request(new URL("/api/catchall/batch/range", controllerBase), {
10
+ method: "POST",
11
+ redirect: "error",
12
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
13
+ body: JSON.stringify({ topics: [sourceTopic], from, to, limit: 100 }),
14
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000),
15
+ });
16
+ if (!response.ok) throw new Error(`UNS history returned HTTP ${response.status}; no credential fallback.`);
17
+ const result = await response.json();
18
+ const rows = result.results?.[0]?.data;
19
+ if (!Array.isArray(rows)) throw new Error("Unexpected history response.");
20
+ return rows;
21
+ }
@@ -0,0 +1,26 @@
1
+ # catalog-summary
2
+
3
+ A complete Node.js 22+ example using published SDK APIs from 3.0.47 onward.
4
+ Read the [Service access walkthrough](https://github.com/uns-openhub/uns-kit/blob/master/docs/catalog-publication-access.md) for the declaration, Controller review and acceptance steps.
5
+
6
+ ```sh
7
+ pnpm install
8
+ pnpm run build
9
+ cp config-example.json config.json
10
+ ```
11
+
12
+ The example configuration targets a local development broker/Controller. Select
13
+ your own endpoints and secret references before deployment. The Controller owns
14
+ instance configuration when deployed. `MQTT_USERNAME`/`MQTT_PASSWORD` concern broker
15
+ ACLs only and grant no OpenHub API rights. Do not commit `config.json` or `.env`.
16
+
17
+ Deploy the package, apply its exact-version Service access policy, and then Start.
18
+ The Controller supplies managed identity/credential paths. Do not manufacture RTT
19
+ launch variables or paste a human token into configuration.
20
+
21
+ This service requires a managed Controller launch for signed catalog route
22
+ publication. Its workload has no direct UNS access. Apply catalog:sources:read
23
+ and approve the publication for the intended audience. Give a test reader access
24
+ to the operation path, not the private source path. Execute a timezone-qualified
25
+ window of at most one hour. Expect {count, hasMore}; raw source fields are excluded.
26
+ A direct source request with the reader or service token must return 403.
@@ -0,0 +1,7 @@
1
+ import { copyFile, mkdir, rm } from "node:fs/promises";
2
+
3
+ await rm("dist", { recursive: true, force: true });
4
+ await mkdir("dist");
5
+ await copyFile("src/index.mjs", "dist/index.js");
6
+ await copyFile("src/offer.mjs", "dist/offer.mjs");
7
+ // service-access.json stays at the package root, as declared in package.json.
@@ -0,0 +1,23 @@
1
+ {
2
+ "infra": {
3
+ "host": "localhost"
4
+ },
5
+ "uns": {
6
+ "processName": "example-catalog-summary",
7
+ "instanceMode": "wait",
8
+ "handover": false,
9
+ "jwksWellKnownUrl": "http://localhost:3200/api/.well-known/jwks.json"
10
+ },
11
+ "mqtt": {
12
+ "username": {
13
+ "provider": "env",
14
+ "key": "MQTT_USERNAME",
15
+ "default": ""
16
+ },
17
+ "password": {
18
+ "provider": "env",
19
+ "key": "MQTT_PASSWORD",
20
+ "default": ""
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "example-catalog-summary",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "service-access.json",
13
+ "config-example.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "node build.mjs",
17
+ "start": "node dist/index.js"
18
+ },
19
+ "dependencies": {
20
+ "@uns-kit/core": "^3.0.47",
21
+ "@uns-kit/api": "^3.0.47"
22
+ },
23
+ "unsDatahub": {
24
+ "schemaVersion": 1,
25
+ "kind": "addon",
26
+ "controllerCompatibility": ">=2 <3",
27
+ "serviceAccess": "service-access.json"
28
+ }
29
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "protocol": 1,
3
+ "permissions": {
4
+ "scopes": [],
5
+ "accessRules": []
6
+ },
7
+ "publications": {
8
+ "protocol": 1,
9
+ "operations": [
10
+ {
11
+ "id": "sample-count",
12
+ "title": "Temperature sample count",
13
+ "method": "GET",
14
+ "path": "/api/example/public/line-1/equipment/dataset/sample-count/json",
15
+ "mode": "approved-service",
16
+ "requestSchema": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": [
20
+ "from",
21
+ "to"
22
+ ],
23
+ "properties": {
24
+ "from": {
25
+ "type": "string",
26
+ "format": "date-time"
27
+ },
28
+ "to": {
29
+ "type": "string",
30
+ "format": "date-time"
31
+ }
32
+ }
33
+ },
34
+ "responseSchema": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": [
38
+ "count",
39
+ "hasMore"
40
+ ],
41
+ "properties": {
42
+ "count": {
43
+ "type": "integer",
44
+ "minimum": 0
45
+ },
46
+ "hasMore": {
47
+ "type": "boolean"
48
+ }
49
+ }
50
+ },
51
+ "sources": [
52
+ {
53
+ "id": "temperature",
54
+ "kind": "history",
55
+ "topic": "example/private/line-1/equipment/main/temperature",
56
+ "maxWindowMs": 3600000,
57
+ "maxRows": 1000
58
+ }
59
+ ],
60
+ "timeoutMs": 10000,
61
+ "maxResponseBytes": 4096
62
+ }
63
+ ]
64
+ }
65
+ }
@@ -0,0 +1,50 @@
1
+ import "@uns-kit/api";
2
+ import { registerApiCatalog } from "@uns-kit/api";
3
+ import { ConfigFile, UnsProxyProcess } from "@uns-kit/core";
4
+ import { CatalogPublicationClient } from "@uns-kit/core/runtime/catalog-publication-client.js";
5
+ import { parseServiceAccessDeclaration } from "@uns-kit/core/runtime/service-access.js";
6
+ import { readFile } from "node:fs/promises";
7
+
8
+ import { createSummaryOffer } from "./offer.mjs";
9
+
10
+ const required = (key) => {
11
+ const value = process.env[key];
12
+ if (!value) throw new Error(`Missing managed runtime value: ${key}`);
13
+ return value;
14
+ };
15
+ const declaration = parseServiceAccessDeclaration(JSON.parse(await readFile(new URL("../service-access.json", import.meta.url), "utf8")));
16
+ const client = new CatalogPublicationClient({
17
+ controllerBase: required("UNS_CONTROLLER_PUBLIC_BASE"),
18
+ rttNode: required("RTT_NODE"),
19
+ instanceId: required("RTT_INSTANCE_ID"),
20
+ version: required("version"),
21
+ manifest: declaration.publications,
22
+ });
23
+ const config = await ConfigFile.loadConfig();
24
+ if (!config.uns.jwksWellKnownUrl) throw new Error("Configure the Controller JWKS URL.");
25
+ const runtime = new UnsProxyProcess(config.infra.host, {
26
+ processName: config.uns.processName,
27
+ username: config.mqtt?.username,
28
+ password: config.mqtt?.password,
29
+ });
30
+ let stopping;
31
+ const shutdown = () => (stopping ??= runtime.shutdown());
32
+ for (const signal of ["SIGINT", "SIGTERM"])
33
+ process.once(signal, () => {
34
+ void shutdown().then(
35
+ () => process.exit(0),
36
+ () => {
37
+ process.exitCode = 1;
38
+ },
39
+ );
40
+ });
41
+ try {
42
+ await runtime.prepareHandover(config.uns.instanceMode, config.uns.handover);
43
+ const api = await runtime.createApiProxy("catalog", { jwks: { wellKnownJwksUrl: config.uns.jwksWellKnownUrl } });
44
+ api.registerCatalogRoute();
45
+ await registerApiCatalog(api, { dataOfferSources: { sampleCount: createSummaryOffer(client) }, context: undefined });
46
+ } catch {
47
+ console.error("Catalog startup failed; check configuration, Service access and runtime logs.");
48
+ await shutdown();
49
+ process.exitCode = 1;
50
+ }
@@ -0,0 +1,35 @@
1
+ import { defineDataCatalogField, defineDataCatalogOfferSource, defineDataCatalogQueryParam, defineDataCatalogSchema } from "@uns-kit/api";
2
+ import { catalogPublicationHandler } from "@uns-kit/api/catalog-publication-handler.js";
3
+
4
+ export function createSummaryOffer(client) {
5
+ return defineDataCatalogOfferSource({
6
+ offerId: "sample-count",
7
+ displayName: "Temperature sample count",
8
+ description: "Count of samples in an approved interval; raw measurements are not returned.",
9
+ topic: "example/public/line-1",
10
+ asset: "equipment",
11
+ objectType: "dataset",
12
+ objectId: "sample-count",
13
+ attribute: "json",
14
+ method: "GET",
15
+ queryParams: ["from", "to"].map((name) =>
16
+ defineDataCatalogQueryParam(name, "ISO 8601 with timezone; at most one hour.", { required: true, format: "date-time" }),
17
+ ),
18
+ response: { statusCode: "200", contentType: "application/json" },
19
+ schema: defineDataCatalogSchema({
20
+ id: "sample-count",
21
+ title: "Sample count",
22
+ contentType: "application/json",
23
+ fields: [
24
+ defineDataCatalogField("count", "integer", "Number of returned samples"),
25
+ defineDataCatalogField("hasMore", "boolean", "More source samples exist than the bounded result contains"),
26
+ ],
27
+ }),
28
+ handler: ({ req, res }) =>
29
+ catalogPublicationHandler(client, "sample-count", async ({ history }) => {
30
+ const rows = await history("temperature");
31
+ if (!Array.isArray(rows.data) || typeof rows.truncated !== "boolean") throw new Error("Unexpected source response.");
32
+ return { count: rows.data.length, hasMore: rows.truncated };
33
+ })(req, res),
34
+ });
35
+ }
@@ -1,130 +1,246 @@
1
- # Service access and publications (3.0.47)
1
+ # Service access: from declaration to a running service
2
2
 
3
- This opt-in protocol separates permission to call a catalog operation from
4
- permission to read its source. It is not a sandbox for arbitrary application
5
- code or a substitute for restricted database and MQTT identities.
3
+ For application developers and coding agents using **SDK 3.0.47 or later**.
4
+ Common Service access also requires a matching Controller; these workflows were
5
+ verified with Controller **2.2.95**. SDK installation alone enables no host rights.
6
6
 
7
- Package metadata requests authority; it never grants it. Keep existing JWT/JWKS
8
- endpoint guards. Do not forward caller tokens to privileged sources or retry a
9
- denied caller read with stronger credentials.
7
+ The developer declares a permission ceiling and uses the SDK. An administrator
8
+ reviews the installed release in **Micro services → instance → Service access**.
9
+ The Controller stores that decision, issues the instance credential and supplies
10
+ its file path. The SDK reads the current credential when making a request. The UI
11
+ is a policy editor, not a place to copy a bearer token into application code.
10
12
 
11
- ## Declaration
13
+ ## Choose the access model
12
14
 
13
- Ship a bounded JSON file with the installed package, and retain it in the build:
15
+ | Application activity | Credential used | Administrator approves | Mechanism |
16
+ | --- | --- | --- | --- |
17
+ | Background UNS API read | Service token only | `read:uns` and explicit UNS paths | `ServiceTokenProvider` / API client |
18
+ | Approved derived catalog operation | Service token plus the incoming user's token | Service capability, exact publication sources/output and audience; user separately needs operation access | `CatalogPublicationClient` + `catalogPublicationHandler` |
19
+ | Direct MQTT subscribe/publish | Broker credentials | Broker ACLs for the relevant topics | MQTT connection settings and broker authorization |
20
+ | Direct SQL/external API access | That resource's credential | Database/external-system grants | Application adapter and secret references |
21
+
22
+ **`read:uns` protects OpenHub API reads. It does not install MQTT ACLs.**
23
+ MQTT-style filters in Service access describe API resource paths; their syntax
24
+ does not make the HTTP service token a broker password. `infra.host` and the
25
+ example's `mqtt.username/password` concern runtime MQTT connectivity. Configure
26
+ broker ACLs separately, including the runtime signaling topics your deployment
27
+ requires. A direct database connection is governed by its database identity.
28
+ This capability is not an arbitrary-code sandbox.
29
+
30
+ ## Runnable examples and package contents
31
+
32
+ Both examples are independent Node.js 22+ projects with complete source,
33
+ credential-free configuration, a declaration, build script and package metadata:
34
+
35
+ - [Background reader](https://github.com/uns-openhub/uns-kit/tree/master/examples/service-access/background-reader)
36
+ - [Catalog summary](https://github.com/uns-openhub/uns-kit/tree/master/examples/service-access/catalog-summary)
37
+
38
+ The npm core package also ships this guide at
39
+ `@uns-kit/core/runtime/service-access-guide.md` and both projects under
40
+ `node_modules/@uns-kit/core/dist/runtime/service-access-examples/`. Copy a project
41
+ to your own directory before installing/building it; do not edit `node_modules`.
42
+ Source code, not repeated prose fragments, is the complete runnable example.
43
+
44
+ Each `package.json` points at the root declaration and retains it in the artifact:
14
45
 
15
46
  ```json
16
- {"unsDatahub":{"serviceAccess":"service-access.json"}}
47
+ {
48
+ "files": ["dist", "service-access.json", "config-example.json"],
49
+ "unsDatahub": {
50
+ "schemaVersion": 1,
51
+ "kind": "addon",
52
+ "controllerCompatibility": ">=2 <3",
53
+ "serviceAccess": "service-access.json"
54
+ }
55
+ }
56
+ ```
57
+
58
+ This is metadata to merge into the application's existing package.json. Keep the
59
+ real name/version/dependencies/scripts. The SDK is imported as published packages;
60
+ no copied validators, source overlays or application-name Controller branches.
61
+ Do not declare the historical `catalogAccess` marker alongside `serviceAccess`.
62
+
63
+ In either copied example:
64
+
65
+ ```sh
66
+ pnpm install
67
+ pnpm run build
68
+ cp config-example.json config.json
17
69
  ```
18
70
 
19
- Use `parseServiceAccessDeclaration` from `@uns-kit/core/runtime/service-access.js`:
71
+ Select your local MQTT/Controller endpoints and use environment or secret-provider
72
+ references for credentials. Never commit config.json, .env, keys or tokens. Under
73
+ a Controller deployment, instance configuration is managed by that Controller.
74
+ The examples declare ordinary cold operation, not state-preserving hot handover.
75
+
76
+ ## 1. Background service: no Catalog and no HTTP listener
77
+
78
+ The background reader asks for one source through the OpenHub history API:
20
79
 
21
80
  ```json
22
81
  {
23
82
  "protocol": 1,
24
83
  "permissions": {
25
84
  "scopes": ["read:uns"],
26
- "accessRules": ["example/background/#"]
85
+ "accessRules": ["example/background/line-1/equipment/main/temperature"]
27
86
  }
28
87
  }
29
88
  ```
30
89
 
31
- This works without a catalog, HTTP endpoint, or human requester. The declaration
32
- requests a ceiling; an administrator must apply the effective policy under
33
- **Microservices → instance → Service access** before the service can start with
34
- a managed identity. Review the exact installed version and declaration digest.
35
- The administrator can select fewer capabilities and narrower MQTT path filters;
36
- unknown or administrative scopes are rejected. The identity, revocation and
37
- same-request reissue mechanisms are the existing managed workload mechanisms.
38
- UNS resource rules bound direct UNS reads; other recognized runtime scopes retain
39
- their existing authorization semantics. This protocol does not create new
40
- SQL, broker, filesystem or arbitrary external-service permissions.
41
-
42
- A combined service adds optional `publications`, containing the manifest parsed
43
- by `parseCatalogPublicationManifest`, to the same envelope. A publication-only
44
- service uses empty `permissions.scopes` and `permissions.accessRules`. Its
45
- managed token then has `catalog:sources:read` and **no raw UNS paths**. Adding
46
- publication sources never adds them to the service's direct `read:uns` rules.
47
- Publication approval is a separate operation/audience grant and never silently
48
- grants other package-requested capabilities. Review both in Service access.
49
- The Catalog contains execution/status and an administrator link to that editor.
50
- There is one writable publication editor.
51
-
52
- The earlier unpublished `unsDatahub.catalogAccess` marker remains an isolated
53
- compatibility adapter for the first local fixtures. New consumers use
54
- `serviceAccess`; declaring both markers is rejected. No released applications
55
- are automatically migrated.
56
-
57
- The optional publication manifest supports GET JSON,
58
- closed JSON schemas (primitive, object and array types; no references, defaults,
59
- or composition), string query parameters, and exact history source topics.
60
- Source path segments follow the existing managed-identity resource vocabulary:
61
- ASCII letters, digits and hyphens. Each source binds required `from`/`to` date-time parameters, a maximum window
62
- (at most 31 days) and row count (at most 27000). Dates require a UTC offset.
63
- Wildcard topics, arbitrary URLs/SQL/source selectors and undeclared input fields
64
- are rejected. The broker caps source responses at 1 MiB; published responses
65
- have a separately declared cap of at most 1 MiB.
66
-
67
- Each operation declares `caller` or `approved-service`. Both require an explicit
68
- administrator publication review and current caller permission on the SDK UNS
69
- resource path (the operation path without `/api/`). In caller mode, source rules
70
- and `read:uns` must also belong to the caller. Service mode never grants these
71
- raw permissions to the caller.
72
-
73
- ## Runtime
74
-
75
- Use `CatalogPublicationClient` with the installed, normalized manifest and the
76
- Controller-provided runtime identity (`RTT_NODE`, `version`, `RTT_INSTANCE_ID`,
77
- `UNS_CONTROLLER_PUBLIC_BASE`). The default `ServiceTokenProvider` reads the
78
- Controller's rotating token file. There is no caller-token fallback.
79
-
80
- Use `catalogPublicationHandler(client, operationId, transform)` in an existing
81
- `registerApiCatalog` handler. Call `api.registerCatalogRoute()` to opt into the
82
- existing signed launch route lease. The transform receives `history(sourceId)`
83
- and the already validated input. It owns the derived result, not authorization.
84
- The HTTP helper sets `Cache-Control: no-store` and rejects undeclared result
85
- fields. Each execution rechecks current authority after asynchronous work,
86
- including cached application results. Custom downloads and other handlers must
87
- use the same mediated path; this candidate adds no persistent result cache.
88
-
89
- A publication-only workload token has `catalog:sources:read` and no direct
90
- `read:uns` authority. A combined service may separately receive `read:uns` on
91
- its reviewed background paths, without receiving raw access to publication
92
- sources. The Controller broker checks current user policy, active machine
93
- identity/token generation, reviewed identity binding, launch, loaded portable
94
- configuration, publication revision, audience and source bounds. Its internal
95
- history token is exact-topic, lasts 15 seconds, and never leaves the broker.
96
- Neither token bytes nor raw records belong in logs, review receipts or audit.
97
-
98
- Two local acceptance consumers (energy intervals and quality sample counts) use
99
- the same helper and Controller path. No application-name branch is needed.
100
-
101
- ## Adoption boundary
102
-
103
- The SDK contract is introduced in 3.0.47. A matching Controller implementation
104
- must also be installed; an SDK upgrade alone does not enable the host capability.
105
- Existing applications do not change mode
106
- implicitly. Install the shared SDK release first, update consumer lockfiles,
107
- then review the matching installed declaration through the Controller.
108
- Controller publication is bound to the installed declaration and portable
109
- configuration; edits require review. A changed loaded configuration requires a
110
- new launch, not token refresh. With the matching Controller candidate, the
111
- signed digest of the same confirmed live launch survives ordinary credential
112
- expiry and Controller restart. Current identity, policy, generation and approval
113
- are still checked; expired bearer tokens remain invalid. Missing, corrupt,
114
- foreign or changed-launch evidence requires explicit reviewed recovery.
115
-
116
-
117
- ## Local common-contract acceptance
118
-
119
- An unrelated background history processor, with no API or Catalog offer, was
120
- reviewed, narrowed live, revoked, reissued with the same identity and cold-moved
121
- to another Controller. It retained the same policy and generation across the
122
- move. A combined service read its separate background source directly, while a
123
- catalog-only user consumed a derived result from a different approved source.
124
- Both the user and workload were denied direct access to that publication source.
125
- A publication-only empty raw policy stayed empty in storage and actual tokens.
126
-
127
- These are local candidate checks, including clean packed-SDK installs. They are
128
- not production, arbitrary-code isolation or external-resource adapter acceptance.
129
- SDK publication and downstream frozen dependency updates
130
- remain separate steps.
90
+ There is no `publications` section and no human caller. The complete
91
+ `background-reader/src/reader.mjs` imports:
92
+
93
+ ```js
94
+ import { ServiceTokenProvider } from '@uns-kit/core/tools/auth/service-token-provider.js';
95
+ const tokens = new ServiceTokenProvider();
96
+ // Inside each request, not once at application startup:
97
+ const token = await tokens.getAccessToken();
98
+ ```
99
+
100
+ It sends the token as `Authorization: Bearer …` to
101
+ `/api/catchall/batch/range` at `UNS_CONTROLLER_PUBLIC_BASE`, with a fixed declared
102
+ source, explicit from/to, a row limit, timeout and no redirects. Missing credentials
103
+ or 401/403 fail the request; no retry with a human or more powerful credential.
104
+ `src/index.mjs` schedules reads only while the runtime is active. Shutdown stops
105
+ admission, aborts the pending read and cleans up the runtime on SIGINT/SIGTERM.
106
+
107
+ Administrator steps after deployment:
108
+
109
+ 1. Open **Service access** for the exact installed example release and instance.
110
+ 2. Use **Restore defaults** to populate the draft, or select `read:uns` and the
111
+ exact declared path manually. Defaults do not save or grant anything.
112
+ 3. Review **Permitted UNS paths**, then **Apply access policy** and confirm.
113
+ Only declared paths or narrower filters are allowed; do not replace them with `#`.
114
+ 4. **Start** the prepared instance. First managed launch requires reviewed access.
115
+ There is no catalog publication to approve.
116
+
117
+ The Controller supplies `UNS_SERVICE_TOKEN_FILE` and runtime endpoint/identity
118
+ metadata. `ServiceTokenProvider` rereads that file for every request, so atomic
119
+ credential rotation is visible without an application restart. When the file is
120
+ configured it is authoritative: missing/empty/unreadable means failure, not fallback.
121
+ Do not cache the returned token for the lifetime of the process.
122
+
123
+ Acceptance on your local stack: create some history for the declared example
124
+ source. Expect 200 for that source and 403 for another source with the same service
125
+ credential. Verify a policy narrowing or revocation takes effect without granting
126
+ additional paths. Zero rows is a valid response if the selected window has no data.
127
+
128
+ ## 2. Catalog: a user requests a derived result
129
+
130
+ The catalog summary has empty direct `permissions.scopes` and `accessRules`. Its
131
+ optional `publications` declaration defines `sample-count`, GET JSON, from/to with
132
+ a timezone, at most one hour/1000 rows from
133
+ `example/private/line-1/equipment/main/temperature`, and a closed result containing
134
+ only `{count, hasMore}`. The response cap is 4096 bytes. See its complete
135
+ `service-access.json`; the source and response schema are reviewed data.
136
+
137
+ `catalog-summary/src/index.mjs` reads and validates that declaration with
138
+ `parseServiceAccessDeclaration`, then constructs `CatalogPublicationClient` using
139
+ the real Controller-injected `RTT_NODE`, `RTT_INSTANCE_ID`, `version` and
140
+ `UNS_CONTROLLER_PUBLIC_BASE`. It configures JWT/JWKS authentication, calls
141
+ `api.registerCatalogRoute()` for the signed installed-launch route lease, and
142
+ registers the offer with `registerApiCatalog`. Do not manufacture these launch
143
+ variables to make a directly started process appear Controller-managed.
144
+
145
+ `src/offer.mjs` wraps its business transform with the actual SDK helper:
146
+
147
+ ```js
148
+ catalogPublicationHandler(client, 'sample-count', async ({ history }) => {
149
+ const rows = await history('temperature');
150
+ return { count: rows.data.length, hasMore: rows.truncated };
151
+ });
152
+ ```
153
+
154
+ `temperature` is the declared source ID, not an arbitrary topic or URL from the
155
+ request. The complete source also validates the source response. The helper takes
156
+ the caller's bearer from the incoming HTTP request. The client separately reads
157
+ the service token through `ServiceTokenProvider`; they are never substituted for
158
+ each other. The business transform owns the count, not the authorization policy.
159
+
160
+ Administrator steps after preparation:
161
+
162
+ 1. Apply only `catalog:sources:read` in **Service access**, with **no direct UNS
163
+ paths**. This capability permits the reviewed catalog workflow, not raw reads.
164
+ 2. Expand **Published operations → Review access**. Check the exact source,
165
+ maximum interval/rows, **Published fields** and intended audience.
166
+ 3. **Review publication**, then **Approve publication**. A selected audience role
167
+ does not itself grant every user access to the catalog operation.
168
+ 4. Give a test reader access to the operation's SDK resource path:
169
+ `example/public/line-1/equipment/dataset/sample-count/json` (without `/api/`).
170
+ Do not grant the private source path just to make Execute work.
171
+ 5. Start the prepared instance and Execute the operation as that reader, for
172
+ example `from=2026-01-01T00:00:00Z` and `to=2026-01-01T01:00:00Z`.
173
+
174
+ When moving an existing service from legacy credentials to this declaration,
175
+ prepare the new version first. Use **Stop → review Service access/publication →
176
+ Start prepared version** on the chosen owner. A still-live older version can
177
+ block inspection of the prepared release; the same-instance Apply guard requires
178
+ this first-adoption sequence. Retain the old configuration for controlled rollback
179
+ until acceptance; do not grant raw access to get past an adoption error.
180
+
181
+ The request path is:
182
+
183
+ 1. Browser sends the user's token to the service API through the Controller.
184
+ 2. The SDK sends its service credential and the caller context to the Controller.
185
+ 3. Controller checks current user operation access, audience, active service
186
+ identity/generation, live launch, loaded configuration, declaration and approval.
187
+ 4. Controller obtains bounded source data using a short-lived exact-source
188
+ credential internally. It never sends that credential to the service/browser.
189
+ 5. The service transforms the data; SDK rechecks authority after the asynchronous
190
+ work and validates the closed output schema before returning JSON.
191
+
192
+ Only SDK-integrated execution gets that output validation. Preserve ordinary
193
+ endpoint JWT/JWKS guards; a custom endpoint or custom download must use the same
194
+ mediated path. Arbitrary app code with independent DB/MQTT credentials is outside
195
+ this enforcement. Revocation cannot erase bytes already delivered.
196
+
197
+ Acceptance: reader Execute 200/{count,hasMore}; caller and service direct source
198
+ reads 403; unapproved/revoked publication 403; invalid window/unknown parameter
199
+ 400; no raw source fields or credentials in result/logs. The caller needs no
200
+ source permission in `approved-service` mode. `caller` mode instead requires the
201
+ caller's own source permissions as well; it never retries with stronger authority.
202
+
203
+ ## Contract limits
204
+
205
+ The publication protocol supports GET JSON, string query parameters, exact history
206
+ topics and closed primitive/object/array schemas. Schema references, defaults and
207
+ composition, wildcard source topics, arbitrary URLs/SQL and undeclared inputs are
208
+ rejected. Source path segments use ASCII letters, digits and hyphens. Each source
209
+ binds the required timezone-qualified `from`/`to`, at most 31 days and 27000 rows.
210
+ The broker caps source responses at 1 MiB; the separately declared output cap is
211
+ also at most 1 MiB. The example intentionally chooses narrower limits.
212
+
213
+ A combined service can declare background `read:uns` paths and `publications` in
214
+ the same envelope. Review both separately. A publication source never becomes a
215
+ directly readable background path just because it appears in the manifest.
216
+ Unknown or administrative service scopes are rejected.
217
+
218
+ Ordinary credential renewal and Controller restart preserve a confirmed live
219
+ launch binding. They do not approve a different identity, declaration, policy,
220
+ configuration or launch. Missing or changed evidence requires reviewed recovery;
221
+ an expired bearer remains invalid.
222
+
223
+ ## Troubleshooting and agent checklist
224
+
225
+ | Symptom | Check |
226
+ | --- | --- |
227
+ | Start requires access review | Exact installed release and `unsDatahub.serviceAccess`; admin must apply the policy |
228
+ | Credential file missing | Real Controller launch/file access; never copy a human token into config |
229
+ | Background read 403 | Active workload identity, `read:uns`, matching permitted path, current generation |
230
+ | Catalog 403 | Caller operation access, audience, service capability and current publication approval |
231
+ | Placement/access changed | Current owner/version/loaded config; stop the legacy owner for first adoption and review the prepared release |
232
+ | API works, MQTT denied | Broker credentials/ACLs; API scopes do not create broker access |
233
+ | Configuration changed | Review the new configuration/contract and restart as required; token refresh cannot replace loaded-launch evidence |
234
+
235
+ For an agent generating a service: choose the model above, use the published SDK
236
+ imports, ship/validate the declaration, preserve local cleanup, and list the admin
237
+ review needed. Do not create identities, modify user rules or broker ACLs, disable
238
+ JWT checks, synthesize launch metadata, or broaden paths as an automatic fix.
239
+ Keep install/build, review, start, release and production deployment separate.
240
+
241
+ Maintainers run `pnpm run ts:build` and `pnpm run test:service-access-examples`.
242
+ The latter verifies packaged imports/assets, rotating token use, failures and the
243
+ real catalog HTTP helper using synthetic loopback endpoints. It does not certify
244
+ real MQTT ACLs, Controller RBAC/cluster ownership, or production resource grants.
245
+ Additional SQL, filesystem, broker and external-resource permission adapters are
246
+ outside this protocol. Follow the version-bounded core MIGRATIONS.md on adoption.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/core",
3
- "version": "3.0.47",
3
+ "version": "3.0.48",
4
4
  "description": "Core utilities and runtime building blocks for UNS-based realtime transformers.",
5
5
  "type": "module",
6
6
  "license": "MIT",