@augmentd-labs/canvas-edge 0.4.1 → 0.5.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/README.md CHANGED
@@ -75,3 +75,12 @@ mirrors.json). `CANVAS_DIRECTION=pull` makes the container a backup target
75
75
 
76
76
  Protocol: canvas-server `docs/canvas-edge-protocol.md` (tunnel) and
77
77
  `docs/sync-protocol.md` (file plane).
78
+
79
+ ### Client certificates (0.5.0+)
80
+
81
+ Hub remotes retain `tls: { certFile, keyFile }` from the shared client config.
82
+ Workspace remotes can carry the same optional fields. Pairing, tunnel sockets,
83
+ mirror status calls, and object transfers all use the identity. Container bootstrap
84
+ accepts `CANVAS_TLS_CERT`/`CANVAS_TLS_KEY` as a complete pair of file paths; mount
85
+ these files read-only. Use leaf-first PEM chains and protected unencrypted RSA/EC
86
+ keys. Server verification is enabled. Restart the daemon/container after renewal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmentd-labs/canvas-edge",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -36,7 +36,8 @@
36
36
  "picomatch": "^2.3.1",
37
37
  "fastify": "^4.29.1",
38
38
  "pino": "^10.3.1",
39
- "socket.io-client": "^4.8.3"
39
+ "socket.io-client": "^4.8.3",
40
+ "@augmentd-labs/canvas-api-client": "^0.4.0"
40
41
  },
41
42
  "repository": {
42
43
  "type": "git",
@@ -46,6 +47,6 @@
46
47
  "bundleDependencies": [
47
48
  "canvas-stored"
48
49
  ],
49
- "canvasRev": "0dbc541",
50
+ "canvasRev": "61917c2",
50
51
  "canvasSource": "runtimes/edge"
51
52
  }
package/src/EdgeClient.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ import { createTlsTransport, resolveTls } from '@augmentd-labs/canvas-api-client/tls';
3
4
  import { io } from 'socket.io-client';
4
5
 
5
6
  const CHUNK_SIZE = 256 * 1024;
@@ -15,6 +16,7 @@ const CHUNK_SIZE = 256 * 1024;
15
16
  * See docs/canvas-edge-protocol.md for the frame protocol.
16
17
  */
17
18
  export default class EdgeClient {
19
+ #transport;
18
20
  #serverUrl;
19
21
  #token;
20
22
  #localApp;
@@ -23,10 +25,11 @@ export default class EdgeClient {
23
25
  #announced = false;
24
26
  #forwarded = [];
25
27
 
26
- constructor({ serverUrl, token, localApp, announce }) {
28
+ constructor({ serverUrl, token, localApp, announce, tls }) {
27
29
  if (!serverUrl || !token || !localApp?.inject || !announce?.instanceId) {
28
30
  throw new Error('EdgeClient requires serverUrl, token, localApp (fastify) and announce.instanceId');
29
31
  }
32
+ this.#transport = createTlsTransport(serverUrl, resolveTls(tls));
30
33
  this.#serverUrl = serverUrl.replace(/\/+$/, '');
31
34
  this.#token = token;
32
35
  this.#localApp = localApp;
@@ -38,17 +41,20 @@ export default class EdgeClient {
38
41
  * existing device registration endpoint. Persist the result locally and
39
42
  * never touch the user token again.
40
43
  */
41
- static async pair({ serverUrl, userToken, name, type = 'edge', ...deviceInfo }) {
42
- const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
43
- method: 'POST',
44
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
45
- body: JSON.stringify({ name, type, ...deviceInfo }),
46
- });
47
- const json = await res.json().catch(() => null);
48
- if (!res.ok || !json?.payload?.token) {
49
- throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
50
- }
51
- return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
44
+ static async pair({ serverUrl, userToken, name, type = 'edge', tls, ...deviceInfo }) {
45
+ const transport = createTlsTransport(serverUrl, resolveTls(tls));
46
+ try {
47
+ const res = await transport.fetch(`${serverUrl.replace(/\/+$/, '')}/rest/v2/auth/devices/register`, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
50
+ body: JSON.stringify({ name, type, ...deviceInfo }),
51
+ });
52
+ const json = await res.json().catch(() => null);
53
+ if (!res.ok || !json?.payload?.token) {
54
+ throw new Error(`Edge pairing failed: ${json?.message || res.statusText}`);
55
+ }
56
+ return { token: json.payload.token, deviceId: json.payload.deviceId || json.payload.id };
57
+ } finally { await transport.dispose(); }
52
58
  }
53
59
 
54
60
  get connected() {
@@ -58,8 +64,9 @@ export default class EdgeClient {
58
64
  connect() {
59
65
  if (this.#socket) return this;
60
66
  this.#socket = io(this.#serverUrl, {
67
+ ...this.#transport.socketOptions,
61
68
  auth: { token: this.#token },
62
- transports: ['websocket'],
69
+ transports: this.#transport.socketOptions.transports || ['websocket'],
63
70
  });
64
71
  // Announce on every (re)connect — announce is idempotent full state.
65
72
  this.#socket.on('connect', () => this.#socket.emit('edge:announce', this.#announce));
@@ -121,6 +128,7 @@ export default class EdgeClient {
121
128
  }
122
129
 
123
130
  close() {
131
+ void this.#transport.dispose().catch(() => {});
124
132
  for (const [emitter, listener] of this.#forwarded) emitter.off('**', listener);
125
133
  this.#forwarded = [];
126
134
  this.#socket?.disconnect();
package/src/env.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ import { resolveTls } from '@augmentd-labs/canvas-api-client/tls';
3
4
  import fs from 'fs';
4
5
  import os from 'os';
5
6
  import path from 'path';
@@ -81,7 +82,7 @@ export function ensureEnvConfig(env = process.env) {
81
82
  const prior = remotes[hubId] || {};
82
83
  // A device token minted for this remote stays authoritative; env only
83
84
  // seeds the user/API token it started from.
84
- remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env' };
85
+ remotes[hubId] = { ...prior, url, apiBase: prior.apiBase || '/rest/v2', auth: { ...(prior.auth || {}), token }, source: 'env', ...(resolveTls(prior.url && new URL(prior.url).origin !== new URL(url).origin ? undefined : prior.tls, env) ? { tls: resolveTls(prior.url && new URL(prior.url).origin !== new URL(url).origin ? undefined : prior.tls, env) } : { tls: undefined }) };
85
86
  writeJson(EDGE_PATHS.remotes, remotes);
86
87
 
87
88
  const cfg = readJson(EDGE_PATHS.mirrors, { version: 1, mirrors: [] }) || { version: 1, mirrors: [] };
@@ -142,7 +143,7 @@ export async function ensureDeviceToken(remoteId, { logger = null } = {}) {
142
143
  if (!r?.url || r.source !== 'env' || r.device?.token || !r.auth?.token) return hubFor(remoteId);
143
144
  const identity = deviceIdentity(null);
144
145
  const paired = await EdgeClient.pair({
145
- serverUrl: r.url, userToken: r.auth.token, name: identity.deviceName, type: 'edge',
146
+ serverUrl: r.url, tls: resolveTls(r.tls), userToken: r.auth.token, name: identity.deviceName, type: 'edge',
146
147
  deviceId: identity.deviceId, hostname: os.hostname(), platform: process.platform, arch: process.arch,
147
148
  });
148
149
  remotes[remoteId] = { ...r, device: { deviceId: paired.deviceId || identity.deviceId, token: paired.token, registeredAt: new Date().toISOString() } };
@@ -161,6 +162,7 @@ export function hubFor(remoteId) {
161
162
  return {
162
163
  id: remoteId,
163
164
  url: String(r.url).replace(/\/+$/, ''),
165
+ tls: resolveTls(r.tls),
164
166
  apiBase: r.apiBase || '/rest/v2',
165
167
  token,
166
168
  deviceId: deviceToken && r.device?.deviceId ? String(r.device.deviceId) : null,
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ import { createTlsTransport } from '@augmentd-labs/canvas-api-client/tls';
3
4
  import fs from 'fs';
4
5
  import path from 'path';
5
6
  import { io } from 'socket.io-client';
@@ -29,6 +30,7 @@ const STATUS_HEARTBEAT_MS = 60000;
29
30
  * ledger as `full` so a hub that lost its replica table rebuilds it.
30
31
  */
31
32
  export class MirrorRuntime {
33
+ #transport;
32
34
  #mirror;
33
35
  #hub;
34
36
  #identity;
@@ -45,6 +47,7 @@ export class MirrorRuntime {
45
47
  constructor({ mirror, hub, identity, logger }) {
46
48
  this.#mirror = mirror;
47
49
  this.#hub = hub;
50
+ this.#transport = createTlsTransport(hub.url, hub.tls);
48
51
  this.#identity = identity;
49
52
  this.#logger = logger;
50
53
  }
@@ -87,6 +90,7 @@ export class MirrorRuntime {
87
90
  this.#stored.addBackend('trash', { driver: 'file', root: path.join(internal, 'trash'), watch: false });
88
91
  this.#stored.addBackend('conflicts', { driver: 'file', root: path.join(internal, 'conflicts'), watch: false });
89
92
  this.#stored.addBackend('canvas:hub', {
93
+ fetch: this.#transport.fetch,
90
94
  driver: 'canvas', url: this.#hub.url, apiBase: this.#hub.apiBase, token: this.#hub.token,
91
95
  workspaceId: this.#mirror.workspaceId || this.#mirror.workspaceName, backend: 'workspace:home',
92
96
  deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName, prefixes: this.#mirror.pins || [], pollInterval: 30000,
@@ -113,6 +117,7 @@ export class MirrorRuntime {
113
117
  }
114
118
 
115
119
  async stop() {
120
+ // Close pooled TLS connections after stopping mirror activity below.
116
121
  clearInterval(this.#reporter);
117
122
  clearTimeout(this.#reportTimer);
118
123
  this.#socket?.close();
@@ -121,6 +126,7 @@ export class MirrorRuntime {
121
126
  await this.#stored?.stop().catch(() => {});
122
127
  this.#engine = null;
123
128
  this.#stored = null;
129
+ await this.#transport.dispose();
124
130
  }
125
131
 
126
132
  status() {
@@ -133,7 +139,7 @@ export class MirrorRuntime {
133
139
 
134
140
  async #fetchHubExclusions() {
135
141
  try {
136
- const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}/backends/file/workspace%3Ahome`, {
142
+ const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}/backends/file/workspace%3Ahome`, {
137
143
  headers: { authorization: `Bearer ${this.#hub.token}` },
138
144
  });
139
145
  const json = await res.json();
@@ -147,7 +153,7 @@ export class MirrorRuntime {
147
153
  async #resolveWorkspaceId() {
148
154
  if (this.#mirror.workspaceId && /^[0-9a-f-]{36}$/i.test(this.#mirror.workspaceId)) return this.#mirror.workspaceId;
149
155
  try {
150
- const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}`, { headers: { authorization: `Bearer ${this.#hub.token}` } });
156
+ const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${encodeURIComponent(this.#mirror.workspaceName)}`, { headers: { authorization: `Bearer ${this.#hub.token}` } });
151
157
  const json = await res.json();
152
158
  return json?.payload?.workspace?.id || json?.payload?.id || this.#mirror.workspaceId || this.#mirror.workspaceName;
153
159
  } catch { return this.#mirror.workspaceId || this.#mirror.workspaceName; }
@@ -156,7 +162,7 @@ export class MirrorRuntime {
156
162
  async #connectSocket() {
157
163
  try {
158
164
  const wsId = await this.#resolveWorkspaceId();
159
- const socket = io(this.#hub.url, { auth: { token: this.#hub.token }, transports: ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
165
+ const socket = io(this.#hub.url, { ...this.#transport.socketOptions, auth: { token: this.#hub.token }, transports: this.#transport.socketOptions.transports || ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 30000 });
160
166
  this.#socket = socket;
161
167
  socket.on('connect', () => { socket.emit('subscribe', { channel: `workspace:${wsId}` }); this.resync().catch(() => {}); });
162
168
  socket.on('backend.changed', (e) => { if (!e?.backend || e.backend === 'workspace:home') this.nudge(); });
@@ -188,7 +194,7 @@ export class MirrorRuntime {
188
194
  this.#pendingApplied = full ? [] : mergePairs(this.#pendingApplied, delta);
189
195
  const applied = full ? (typeof engine.appliedSnapshot === 'function' ? engine.appliedSnapshot() : []) : this.#pendingApplied;
190
196
  try {
191
- const res = await fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${ws}/mirrors/${encodeURIComponent(this.#identity.deviceId)}/status`, {
197
+ const res = await this.#transport.fetch(`${this.#hub.url}${this.#hub.apiBase}/workspaces/${ws}/mirrors/${encodeURIComponent(this.#identity.deviceId)}/status`, {
192
198
  method: 'POST',
193
199
  headers: { authorization: `Bearer ${this.#hub.token}`, 'content-type': 'application/json' },
194
200
  body: JSON.stringify({
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ import { normalizeTls } from '@augmentd-labs/canvas-api-client/tls';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
 
@@ -35,11 +36,12 @@ export function listRemotes(config = {}) {
35
36
  url: remote.url.replace(/\/+$/, ''),
36
37
  token: remote.token,
37
38
  enabled: remote.enabled !== false,
39
+ ...(remote.tls ? { tls: normalizeTls(remote.tls) } : {}),
38
40
  }));
39
41
  }
40
42
 
41
43
  /** Add or update (matched by url) a remote entry, persisting workspace.json. */
42
- export function saveRemote(dir, { url, token, enabled = true }) {
44
+ export function saveRemote(dir, { url, token, enabled = true, tls }) {
43
45
  if (!url || !token) throw new Error('remote requires url and token');
44
46
  const file = workspaceConfigPath(dir);
45
47
  const config = readWorkspaceConfig(dir);
@@ -50,8 +52,9 @@ export function saveRemote(dir, { url, token, enabled = true }) {
50
52
  existing.url = normalizedUrl;
51
53
  existing.token = token;
52
54
  existing.enabled = enabled;
55
+ if (tls !== undefined) existing.tls = normalizeTls(tls);
53
56
  } else {
54
- remotes.push({ url: normalizedUrl, token, enabled });
57
+ remotes.push({ url: normalizedUrl, token, enabled, ...(tls ? { tls: normalizeTls(tls) } : {}) });
55
58
  }
56
59
  config.remotes = remotes;
57
60
  fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
package/src/runtime.js CHANGED
@@ -45,6 +45,7 @@ export function connectRemotes({ dir, localApp, events, clientFactory } = {}) {
45
45
  const client = createClient({
46
46
  serverUrl: remote.url,
47
47
  token: remote.token,
48
+ tls: remote.tls,
48
49
  localApp,
49
50
  announce,
50
51
  });