Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/binding-coap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,75 @@ servient
});
```

## Using PSK with CoAPs (DTLS)

The CoAP binding also supports secure communication over `coaps://` using DTLS with Pre-Shared Keys (PSK).

To use PSK security, define a `psk` security scheme in the Thing Description and provide the credentials when consuming the Thing.

### Thing Description Example (PSK)

```json
{
"title": "SecureThing",
"securityDefinitions": {
"psk_sc": {
"scheme": "psk"
}
},
"security": ["psk_sc"],
"properties": {
"count": {
"type": "integer",
"forms": [
{
"href": "coaps://localhost:5684/count"
}
]
}
}
}
```

### Client Example with PSK

```js
const { Servient } = require("@node-wot/core");
const { CoapClientFactory } = require("@node-wot/binding-coap");

const servient = new Servient();
servient.addClientFactory(new CoapClientFactory());

servient
.start()
.then(async (WoT) => {
try {
const td = await WoT.requestThingDescription("coaps://localhost:5684/secureThing");
const thing = await WoT.consume(td);

// configure PSK security
thing.setSecurity(td.securityDefinitions, {
identity: "Client_identity",
psk: "secretPSK",
});

const value = await thing.readProperty("count");
console.log("count value is:", await value.value());
} catch (err) {
console.error("Script error:", err);
}
})
.catch((err) => {
console.error("Start error:", err);
});
```

### Notes

- The `identity` must match the server configuration.
- The `psk` must match the server's configured secret.
- Currently, only the `psk` security scheme is supported for `coaps://` in this binding.

### Server Example

The server example produces a thing that allows for setting a property `count`. The thing is reachable through CoAP.
Expand Down
10 changes: 10 additions & 0 deletions packages/binding-coap/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import rootConfig from "../../eslint.config.mjs";

export default [
...rootConfig,
{
rules: {
"@typescript-eslint/no-unnecessary-condition": "warn",
},
},
];
21 changes: 15 additions & 6 deletions packages/binding-coap/src/coap-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export default class CoapClient implements ProtocolClient {
debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`);

// FIXME does not work with blockwise because of node-coap
const contentType = (res.headers["Content-Format"] as string) ?? form.contentType;
const rawContentType = res.headers["Content-Format"];
const contentType =
typeof rawContentType === "string" ? rawContentType : (form.contentType ?? ContentSerdes.DEFAULT);

resolve(new Content(contentType, Readable.from(res.payload)));
});
Expand Down Expand Up @@ -109,8 +111,11 @@ export default class CoapClient implements ProtocolClient {
debug(`CoapClient received ${res.code} from ${form.href}`);
debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`);
debug(`CoapClient received headers: ${JSON.stringify(res.headers)}`);
const contentType = res.headers["Content-Format"] as string;
resolve(new Content(contentType ?? "", Readable.from(res.payload)));

const rawContentType = res.headers["Content-Format"];
const contentType = typeof rawContentType === "string" ? rawContentType : ContentSerdes.DEFAULT;

resolve(new Content(contentType, Readable.from(res.payload)));
});
req.on("error", (err: Error) => reject(err));
(async () => {
Expand Down Expand Up @@ -156,10 +161,12 @@ export default class CoapClient implements ProtocolClient {
debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`);

// FIXME does not work with blockwise because of node-coap
const contentType = res.headers["Content-Format"] ?? form.contentType ?? ContentSerdes.DEFAULT;
const rawContentType = res.headers["Content-Format"];
const contentType =
typeof rawContentType === "string" ? rawContentType : (form.contentType ?? ContentSerdes.DEFAULT);

res.on("data", (data: Buffer) => {
next(new Content(`${contentType}`, Readable.from(res.payload)));
next(new Content(contentType, Readable.from(res.payload)));
});

resolve(
Expand Down Expand Up @@ -190,7 +197,9 @@ export default class CoapClient implements ProtocolClient {
req.setOption("Accept", "application/td+json");
return new Promise<Content>((resolve, reject) => {
req.on("response", (res: IncomingMessage) => {
const contentType = (res.headers["Content-Format"] as string) ?? "application/td+json";
const rawContentType = res.headers["Content-Format"];
const contentType = typeof rawContentType === "string" ? rawContentType : "application/td+json";

resolve(new Content(contentType, Readable.from(res.payload)));
});
req.on("error", (err: Error) => reject(err));
Expand Down
90 changes: 37 additions & 53 deletions packages/binding-coap/src/coap-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const { debug, warn, info, error } = createLoggers("binding-coap", "coap-server"

type CoreLinkFormatParameters = Map<string, string[] | number[]>;

type AffordanceElement = PropertyElement | ActionElement | EventElement;
type AffordanceElement = Omit<PropertyElement, "forms"> | Omit<ActionElement, "forms"> | Omit<EventElement, "forms">;

// TODO: Move to core?
type AugmentedInteractionOptions = WoT.InteractionOptions & { formIndex: number };
Expand Down Expand Up @@ -145,11 +145,6 @@ export default class CoapServer implements ProtocolServer {
const port = this.getPort();
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expose() now proceeds even when getPort() returns -1 (server not listening). That will generate invalid coap://...:-1/... URLs and register mDNS with an invalid port. Consider restoring the guard (warn + return) or throwing a clear error when port === -1 to prevent exposing before start() completes.

Suggested change
const port = this.getPort();
const port = this.getPort();
if (port === -1) {
warn(
`CoapServer expose() called for '${thing.title}' but server is not listening (port -1). ` +
"Did you forget to call start()?"
);
return;
}

Copilot uses AI. Check for mistakes.
const urlPath = this.createThingUrlPath(thing);

if (port === -1) {
warn("CoapServer is assigned an invalid port, aborting expose process.");
return;
}

this.fillInBindingData(thing, port, urlPath);

debug(`CoapServer on port ${port} exposes '${thing.title}' as unique '/${urlPath}'`);
Expand Down Expand Up @@ -236,12 +231,8 @@ export default class CoapServer implements ProtocolServer {
}

private addFormToAffordance(form: Form, affordance: AffordanceElement): void {
const affordanceForms = affordance.forms;
if (affordanceForms == null) {
affordance.forms = [form];
} else {
affordanceForms.push(form);
}
const withForms = affordance as AffordanceElement & { forms?: Form[] };
(withForms.forms ??= []).push(form);
}

private fillInPropertyBindingData(thing: ExposedThing, base: string, offeredMediaType: string) {
Expand Down Expand Up @@ -354,9 +345,8 @@ export default class CoapServer implements ProtocolServer {

public async destroy(thingId: string): Promise<boolean> {
debug(`CoapServer on port ${this.getPort()} destroying thingId '${thingId}'`);
for (const name of this.things.keys()) {
const exposedThing = this.things.get(name);
if (exposedThing?.id === thingId) {
for (const [name, exposedThing] of this.things.entries()) {
if (exposedThing.id === thingId) {
this.things.delete(name);
this.coreResources.delete(name);
this.mdnsIntroducer?.delete(name);
Expand All @@ -374,7 +364,7 @@ export default class CoapServer implements ProtocolServer {
return Array.from(this.coreResources.values())
.map((resource) => {
const formattedPath = `</${resource.urlPath}>`;
const parameters = Array.from(resource.parameters?.entries() ?? []);
const parameters = resource.parameters ? Array.from(resource.parameters.entries()) : [];

const parameterValues = parameters.map((parameter) => {
const key = parameter[0];
Expand Down Expand Up @@ -499,20 +489,24 @@ export default class CoapServer implements ProtocolServer {
const { thingKey, affordanceType, affordanceKey } = this.parseUriSegments(requestUri);
const thing = this.things.get(thingKey);

if (thing == null) {
if (thing === undefined) {
this.sendNotFoundResponse(res);
return;
}

// TODO: Remove support for trailing slashes (or rather: trailing empty URI path segments)
if (affordanceType == null || affordanceType === "") {
if (!affordanceType) {
await this.handleTdRequest(req, res, thing);
return;
}

switch (affordanceType) {
case this.PROPERTY_DIR:
this.handlePropertyRequest(thing, affordanceKey, req, res, contentType);
if (!affordanceKey) {
this.handlePropertiesRequest(req, contentType, thing, res);
} else {
this.handlePropertyRequest(thing, affordanceKey, req, res, contentType);
}
break;
case this.ACTION_DIR:
this.handleActionRequest(thing, affordanceKey, req, res, contentType);
Expand Down Expand Up @@ -554,11 +548,6 @@ export default class CoapServer implements ProtocolServer {
) {
const property = thing.properties[affordanceKey];

Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thing.properties[affordanceKey] can be undefined for unknown property names, but the method now passes it straight into handleReadProperty/handleWriteProperty and later reads property.readOnly, which will throw. Add a not-found handling branch when property is undefined (likely a 4.04), instead of continuing.

Suggested change
if (!property) {
this.sendResponse(res, "4.04", "Property not found");
return;
}

Copilot uses AI. Check for mistakes.
if (property == null) {
this.handlePropertiesRequest(req, contentType, thing, res);
return;
}

switch (req.method) {
case "GET":
if (req.headers.Observe == null) {
Expand Down Expand Up @@ -588,7 +577,7 @@ export default class CoapServer implements ProtocolServer {
) {
const forms = thing.forms;

if (forms == null) {
if (!forms || forms.length === 0) {
this.sendNotFoundResponse(res);
return;
}
Expand Down Expand Up @@ -618,26 +607,30 @@ export default class CoapServer implements ProtocolServer {
contentType,
thing.uriVariables
);
const readablePropertyKeys = this.getReadableProperties(thing).map(([key, _]) => key);

const readablePropertyKeys = this.getReadableProperties(thing).map(([key]) => key);

const contentMap = await thing.handleReadMultipleProperties(readablePropertyKeys, interactionOptions);

const recordResponse: Record<string, DataSchemaValue> = {};
for (const [key, content] of contentMap.entries()) {
const value = ContentSerdes.get().contentToValue(
{ type: ContentSerdes.DEFAULT, body: await content.toBuffer() },
{}
);

if (value == null) {
// TODO: How should this case be handled?
try {
if (content.type !== ContentSerdes.DEFAULT && content.type !== "application/json") {
continue;
}
const buffer = await content.toBuffer();
const parsed = JSON.parse(buffer.toString());

recordResponse[key] = parsed;
} catch {
// Ignore non-JSON properties
Comment on lines +622 to +626
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using JSON.parse here changes behavior compared to ContentSerdes.contentToValue/JsonCodec: it will throw on non-JSON payloads (e.g. plain strings without quotes) and the catch block then drops that property entirely. This breaks the prior relaxed decoding behavior (and will omit string-valued properties encoded as non-JSON). Consider switching back to ContentSerdes.get().contentToValue({ type: content.type, body: buffer }, /* schema */ ...) so decoding is consistent with the rest of the stack.

Suggested change
const parsed = JSON.parse(buffer.toString());
recordResponse[key] = parsed;
} catch {
// Ignore non-JSON properties
const parsed = ContentSerdes.get().contentToValue(
{ type: content.type, body: buffer },
undefined
);
recordResponse[key] = parsed;
} catch {
// Ignore non-JSON properties or decoding errors

Copilot uses AI. Check for mistakes.
continue;
}

recordResponse[key] = value;
}

const content = ContentSerdes.get().valueToContent(recordResponse, undefined, contentType);
this.streamContentResponse(res, content);
const responseContent = ContentSerdes.get().valueToContent(recordResponse, undefined, contentType);

this.streamContentResponse(res, responseContent);
} catch (err) {
const errorMessage = `${err}`;
error(`CoapServer on port ${this.getPort()} got internal error on read '${req.url}': ${errorMessage}`);
Expand Down Expand Up @@ -775,11 +768,6 @@ export default class CoapServer implements ProtocolServer {
) {
const action = thing.actions[affordanceKey];

if (action == null) {
this.sendNotFoundResponse(res);
return;
}

if (req.method !== "POST") {
this.sendMethodNotAllowedResponse(res);
return;
Comment on lines 769 to 773
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action can be undefined when an unknown action name (or missing segment) is requested, but handleActionRequest later dereferences action.forms / action.uriVariables unconditionally. Please add/restore a not-found guard (e.g., 4.04) when action is undefined to avoid runtime exceptions.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -837,19 +825,14 @@ export default class CoapServer implements ProtocolServer {
) {
const event = thing.events[affordanceKey];

Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event can be undefined when an unknown event name (or missing segment) is requested, but the code later dereferences event.forms / event.uriVariables unconditionally. Please add/restore a not-found guard (4.04) when event is undefined to prevent runtime exceptions.

Suggested change
if (!event) {
debug(
`CoapServer on port ${this.getPort()} received request for unknown event '${affordanceKey}' from ${Helpers.toUriLiteral(
req.rsinfo.address
)}:${req.rsinfo.port}`
);
this.sendResponse(res, "4.04", "Not Found");
return;
}

Copilot uses AI. Check for mistakes.
if (event == null) {
this.sendNotFoundResponse(res);
return;
}

if (req.method !== "GET") {
this.sendMethodNotAllowedResponse(res);
return;
}

const observe = req.headers.Observe as number;
const observe = req.headers.Observe as number | undefined;

if (observe == null) {
if (observe === undefined) {
debug(
`CoapServer on port ${this.getPort()} rejects '${affordanceKey}' event subscription from ${Helpers.toUriLiteral(
req.rsinfo.address
Expand Down Expand Up @@ -923,17 +906,18 @@ export default class CoapServer implements ProtocolServer {
}

private getContentTypeFromRequest(req: IncomingMessage): string {
const contentType = req.headers["Content-Format"] as string;
const contentType = req.headers["Content-Format"] as string | undefined;

if (contentType == null) {
if (contentType === undefined) {
warn(
`CoapServer on port ${this.getPort()} received no Content-Format from ${Helpers.toUriLiteral(
req.rsinfo.address
)}:${req.rsinfo.port}`
);
return ContentSerdes.DEFAULT;
}

return contentType ?? ContentSerdes.DEFAULT;
return contentType;
}

private checkContentTypeSupportForInput(method: string, contentType: string): boolean {
Expand Down Expand Up @@ -974,7 +958,7 @@ export default class CoapServer implements ProtocolServer {
}

// TODO: The name of this method might not be ideal yet.
private streamContentResponse(res: OutgoingMessage, content: Content, options?: { end?: boolean | undefined }) {
private streamContentResponse(res: OutgoingMessage, content: Content, options?: { end?: boolean }) {
res.setOption("Content-Format", content.type);
res.code = "2.05";
content.body.pipe(res, options);
Expand Down
10 changes: 5 additions & 5 deletions packages/binding-coap/src/coaps-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ export default class CoapsClient implements ProtocolClient {
): Promise<Subscription> {
return new Promise<Subscription>((resolve, reject) => {
const requestUri = new URL(form.href.replace(/$coaps/, "https"));
if (this.authorization != null) {
if (this.authorization !== undefined) {
coaps.setSecurityParams(requestUri.hostname, this.authorization);
}

const callback = (resp: CoapResponse) => {
if (resp.payload != null) {
next(new Content(form?.contentType ?? ContentSerdes.DEFAULT, Readable.from(resp.payload)));
next(new Content(form.contentType ?? ContentSerdes.DEFAULT, Readable.from(resp.payload)));
}
};

Expand Down Expand Up @@ -163,14 +163,14 @@ export default class CoapsClient implements ProtocolClient {
}

public setSecurity(metadata: Array<SecurityScheme>, credentials?: pskSecurityParameters): boolean {
if (metadata === undefined || !Array.isArray(metadata) || metadata.length === 0) {
if (!Array.isArray(metadata) || metadata.length === 0) {
warn(`CoapsClient received empty security metadata`);
return false;
}

const security: SecurityScheme = metadata[0];

if (security.scheme === "psk" && credentials != null) {
if (security.scheme === "psk" && credentials !== undefined) {
this.authorization = { psk: {} };
this.authorization.psk[credentials.identity] = credentials.psk;
} else if (security.scheme === "apikey") {
Comment on lines +173 to 176
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

credentials is typed as pskSecurityParameters (currently an index-signature map), but it is used as if it had { identity: string; psk: string } properties (credentials.identity / credentials.psk). This is inconsistent with the typing and can lead to incorrect usage by callers; consider updating the pskSecurityParameters type (or adjusting the code) so the type matches the actual expected shape.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -224,7 +224,7 @@ export default class CoapsClient implements ProtocolClient {
): Promise<CoapResponse> {
// url only works with http*
const requestUri = new URL(form.href.replace(/$coaps/, "https"));
if (this.authorization != null) {
if (this.authorization !== undefined) {
coaps.setSecurityParams(requestUri.hostname, this.authorization);
}

Expand Down
Loading
Loading