@kortix/agent-tunnel 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
@@ -3,7 +3,7 @@ import type { TunnelConfig } from './config';
3
3
  import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
4
4
  import { PermissionGuard } from './security/permission-guard';
5
5
  import type { LocalPermission } from './security/permission-guard';
6
- import { deriveSigningKey, signMessage, verifyMessageSignature } from '../shared/crypto';
6
+ import { signMessage, verifyMessageSignature } from '../shared/crypto';
7
7
 
8
8
  interface JsonRpcRequest {
9
9
  jsonrpc: '2.0';
@@ -54,7 +54,7 @@ export class TunnelAgent {
54
54
  private uptimeInterval: ReturnType<typeof setInterval> | null = null;
55
55
 
56
56
  // HMAC signature verification
57
- private signingKey: string;
57
+ private signingKey: string | null = null;
58
58
  private lastNonce = 0;
59
59
  private responseNonce = 0;
60
60
 
@@ -62,7 +62,6 @@ export class TunnelAgent {
62
62
  this.config = config;
63
63
  this.registry = registry;
64
64
  this.permissionGuard = new PermissionGuard();
65
- this.signingKey = deriveSigningKey(config.token);
66
65
  }
67
66
 
68
67
  connect(): void {
@@ -116,12 +115,11 @@ export class TunnelAgent {
116
115
  this.uptime = 0;
117
116
  this.lastNonce = 0;
118
117
  this.responseNonce = 0;
118
+ this.signingKey = null;
119
119
  this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
120
120
 
121
121
  // Send auth handshake as first message (token never in URL)
122
122
  this.send({ type: 'auth', token: this.config.token });
123
-
124
- log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
125
123
  });
126
124
 
127
125
  this.ws.addEventListener('message', (event) => {
@@ -150,7 +148,7 @@ export class TunnelAgent {
150
148
  }
151
149
 
152
150
  private async handleMessage(raw: string): Promise<void> {
153
- let msg: IncomingMessage;
151
+ let msg: any;
154
152
  try {
155
153
  msg = JSON.parse(raw);
156
154
  } catch {
@@ -158,6 +156,18 @@ export class TunnelAgent {
158
156
  return;
159
157
  }
160
158
 
159
+ // Handle auth_ok — server sends signing key after successful auth
160
+ if (msg.type === 'auth_ok' && msg.signingKey) {
161
+ this.signingKey = msg.signingKey;
162
+ log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
163
+ return;
164
+ }
165
+
166
+ if (!this.signingKey) {
167
+ log(`${c.yellow}!${c.reset}`, `Message received before auth completed`);
168
+ return;
169
+ }
170
+
161
171
  if (!this.verifyIncomingSignature(msg, raw)) {
162
172
  if ('id' in msg && msg.id) {
163
173
  this.sendSignedError(msg.id, -32000, 'Invalid message signature');
@@ -232,7 +242,7 @@ export class TunnelAgent {
232
242
  const { _sig, _nonce, ...payloadObj } = msg as any;
233
243
  const payload = JSON.stringify(payloadObj);
234
244
 
235
- if (!verifyMessageSignature(this.signingKey, payload, nonce, sig)) {
245
+ if (!verifyMessageSignature(this.signingKey!, payload, nonce, sig)) {
236
246
  log(`${c.red}✗${c.reset}`, `Invalid HMAC signature`);
237
247
  return false;
238
248
  }
@@ -278,7 +288,7 @@ export class TunnelAgent {
278
288
  }
279
289
 
280
290
  private sendSigned(data: Record<string, unknown>): void {
281
- if (this.ws?.readyState === WebSocket.OPEN) {
291
+ if (this.ws?.readyState === WebSocket.OPEN && this.signingKey) {
282
292
  const nonce = ++this.responseNonce;
283
293
  const payload = JSON.stringify(data);
284
294
  const sig = signMessage(this.signingKey, payload, nonce);
@@ -313,7 +323,7 @@ export class TunnelAgent {
313
323
  platform: platform(),
314
324
  arch: arch(),
315
325
  osVersion: release(),
316
- agentVersion: '0.1.1',
326
+ agentVersion: '0.1.2',
317
327
  },
318
328
  },
319
329
  });
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v1';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `atspi-helper-${HELPER_VERSION}.py`);
9
9
 
10
10
  const PYTHON_SOURCE = `#!/usr/bin/env python3
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v1';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `desktop-helper-win-${HELPER_VERSION}.exe`);
9
9
 
10
10
  const CSHARP_SOURCE = `
@@ -32,7 +32,7 @@ import type {
32
32
  } from './types';
33
33
 
34
34
  function tmpPath(): string {
35
- return join(tmpdir(), `kortix-ss-${randomBytes(6).toString('hex')}.png`);
35
+ return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}.png`);
36
36
  }
37
37
 
38
38
  function exec(cmd: string, args: string[]): Promise<string> {
@@ -32,7 +32,7 @@ import type {
32
32
  } from './types';
33
33
 
34
34
  function tmpPath(ext: string = '.png'): string {
35
- return join(tmpdir(), `kortix-ss-${randomBytes(6).toString('hex')}${ext}`);
35
+ return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}${ext}`);
36
36
  }
37
37
 
38
38
  function exec(cmd: string, args: string[], timeoutMs = 15000): Promise<string> {
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v4';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `desktop-helper-${HELPER_VERSION}`);
9
9
 
10
10
  const SWIFT_SOURCE = `
package/src/agent/cli.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- import { loadConfig } from './config';
2
+ import { loadConfig, type TunnelConfig } from './config';
3
3
  import { TunnelAgent } from './agent';
4
4
  import { CapabilityRegistry } from './capabilities/index';
5
5
  import { createFilesystemCapability } from './capabilities/filesystem';
6
6
  import { createShellCapability } from './capabilities/shell';
7
7
  import { createDesktopCapability } from './capabilities/desktop';
8
8
  import { hostname, platform, arch, release } from 'os';
9
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import { execSync } from 'child_process';
9
13
 
10
14
  const c = {
11
15
  reset: '\x1b[0m',
@@ -114,30 +118,14 @@ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabi
114
118
  console.log('');
115
119
  }
116
120
 
117
- async function commandConnect(flags: Record<string, string>): Promise<void> {
118
- const config = loadConfig({
119
- token: flags.token,
120
- tunnelId: flags['tunnel-id'],
121
- apiUrl: flags['api-url'],
122
- });
123
-
124
- if (!config.token) {
125
- console.error(`${c.red}${c.bold} error${c.reset} --token is required`);
126
- process.exit(1);
127
- }
128
-
129
- if (!config.tunnelId) {
130
- console.error(`${c.red}${c.bold} error${c.reset} --tunnel-id is required`);
131
- process.exit(1);
132
- }
133
-
121
+ function startAgent(config: TunnelConfig): void {
134
122
  const registry = new CapabilityRegistry();
135
123
  registry.register(createFilesystemCapability(config));
136
124
  registry.register(createShellCapability(config));
137
125
  registry.register(createDesktopCapability());
138
126
 
139
127
  clearScreen();
140
- await printStartup(config, registry.getCapabilityNames(), '0.1.0');
128
+ printStartup(config, registry.getCapabilityNames(), '0.1.2');
141
129
 
142
130
  const agent = new TunnelAgent(config, registry);
143
131
  agent.connect();
@@ -152,6 +140,152 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
152
140
  process.on('SIGINT', shutdown);
153
141
  }
154
142
 
143
+ function openBrowser(url: string): void {
144
+ try {
145
+ const plat = platform();
146
+ if (plat === 'darwin') execSync(`open "${url}"`);
147
+ else if (plat === 'win32') execSync(`start "" "${url}"`);
148
+ else execSync(`xdg-open "${url}"`);
149
+ } catch {}
150
+ }
151
+
152
+ const CONFIG_DIR = join(homedir(), '.agent-tunnel');
153
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
154
+
155
+ function saveCredentials(tunnelId: string, token: string, apiUrl: string): void {
156
+ mkdirSync(CONFIG_DIR, { recursive: true });
157
+ let existing: Record<string, unknown> = {};
158
+ if (existsSync(CONFIG_FILE)) {
159
+ try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
160
+ }
161
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, tunnelId, token, apiUrl }, null, 2));
162
+ }
163
+
164
+ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
165
+ console.log('');
166
+ console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
167
+ console.log('');
168
+
169
+ // Step 1: Create device auth request
170
+ let deviceCode: string;
171
+ let deviceSecret: string;
172
+ let verificationUrl: string;
173
+ let expiresAt: string;
174
+ let pollIntervalMs: number;
175
+
176
+ try {
177
+ const res = await fetch(`${config.apiUrl}/device-auth`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ machineHostname: hostname() }),
181
+ });
182
+ if (!res.ok) {
183
+ const text = await res.text().catch(() => '');
184
+ console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
185
+ process.exit(1);
186
+ }
187
+ const data = await res.json();
188
+ deviceCode = data.deviceCode;
189
+ deviceSecret = data.deviceSecret;
190
+ verificationUrl = data.verificationUrl;
191
+ expiresAt = data.expiresAt;
192
+ pollIntervalMs = data.pollIntervalMs || 2000;
193
+ } catch (err) {
194
+ console.error(` ${c.red}✗${c.reset} Failed to reach API at ${config.apiUrl}`);
195
+ process.exit(1);
196
+ return;
197
+ }
198
+
199
+ // Step 2: Display code and open browser
200
+ console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${deviceCode}${c.reset}`);
201
+ console.log('');
202
+ console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
203
+ console.log(` ${c.cyan}${verificationUrl}${c.reset}`);
204
+ console.log('');
205
+
206
+ openBrowser(verificationUrl);
207
+
208
+ // Step 3: Poll for approval
209
+ const expiresAtMs = new Date(expiresAt).getTime();
210
+
211
+ while (true) {
212
+ const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
213
+ if (remaining <= 0) {
214
+ console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
215
+ process.exit(1);
216
+ }
217
+
218
+ const min = Math.floor(remaining / 60);
219
+ const sec = remaining % 60;
220
+ process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
221
+
222
+ try {
223
+ const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status?secret=${deviceSecret}`);
224
+ if (res.ok) {
225
+ const data = await res.json();
226
+
227
+ if (data.status === 'approved' && data.tunnelId && data.token) {
228
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
229
+ console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
230
+ console.log('');
231
+
232
+ // Save credentials
233
+ saveCredentials(data.tunnelId, data.token, config.apiUrl);
234
+ console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
235
+ console.log('');
236
+
237
+ // Connect with received credentials
238
+ const fullConfig = loadConfig({
239
+ token: data.token,
240
+ tunnelId: data.tunnelId,
241
+ apiUrl: config.apiUrl,
242
+ });
243
+ startAgent(fullConfig);
244
+ return;
245
+ }
246
+
247
+ if (data.status === 'denied') {
248
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
249
+ console.log(` ${c.red}✗${c.reset} Authorization denied.`);
250
+ process.exit(1);
251
+ }
252
+
253
+ if (data.status === 'expired') {
254
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
255
+ console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+ } catch {}
260
+
261
+ await sleep(pollIntervalMs);
262
+ }
263
+ }
264
+
265
+ async function commandConnect(flags: Record<string, string>): Promise<void> {
266
+ const config = loadConfig({
267
+ token: flags.token,
268
+ tunnelId: flags['tunnel-id'],
269
+ apiUrl: flags['api-url'],
270
+ });
271
+
272
+ // If both token and tunnelId are provided, connect directly
273
+ if (config.token && config.tunnelId) {
274
+ startAgent(config);
275
+ return;
276
+ }
277
+
278
+ // If neither is provided, use device auth flow
279
+ if (!config.token && !config.tunnelId) {
280
+ await commandConnectDeviceAuth(config);
281
+ return;
282
+ }
283
+
284
+ // Partial — error
285
+ console.error(`${c.red}${c.bold} error${c.reset} Provide both --token and --tunnel-id, or neither (for device auth)`);
286
+ process.exit(1);
287
+ }
288
+
155
289
  async function commandStatus(flags: Record<string, string>): Promise<void> {
156
290
  const config = loadConfig({
157
291
  token: flags.token,
@@ -192,13 +326,13 @@ function showHelp(): void {
192
326
  console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
193
327
  console.log('');
194
328
  console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
195
- console.log(` ${c.cyan}connect${c.reset} Connect and start handling RPC requests`);
329
+ console.log(` ${c.cyan}connect${c.reset} Connect via device auth (opens browser)`);
196
330
  console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
197
331
  console.log(` ${c.cyan}help${c.reset} Show this help message`);
198
332
  console.log('');
199
333
  console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
200
- console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} API token ${c.dim}(or TUNNEL_TOKEN)${c.reset}`);
201
- console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(or TUNNEL_ID)${c.reset}`);
334
+ console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
335
+ console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
202
336
  console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
203
337
  console.log('');
204
338
  console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Strategy: spin up a lightweight mock HTTP server that simulates the
5
5
  * tunnel relay API, then invoke the CLI as a child process via `bun run`
6
- * with KORTIX_API_URL pointing at the mock. Assert on JSON stdout,
6
+ * with TUNNEL_API_URL pointing at the mock. Assert on JSON stdout,
7
7
  * stderr, and exit codes.
8
8
  *
9
9
  * Run: bun test src/client/cli.test.ts
@@ -267,9 +267,9 @@ function runCli(
267
267
  const child = spawn("bun", cliArgs, {
268
268
  env: {
269
269
  ...process.env,
270
- KORTIX_API_URL: `http://localhost:${mockPort}`,
271
- KORTIX_TOKEN: "test-token",
272
- KORTIX_TUNNEL_ID: "",
270
+ TUNNEL_API_URL: `http://localhost:${mockPort}`,
271
+ TUNNEL_TOKEN: "test-token",
272
+ TUNNEL_ID: "",
273
273
  ...envOverrides,
274
274
  },
275
275
  cwd: dirname(dirname(CLI_PATH)),
@@ -669,7 +669,7 @@ describe("Agent Tunnel CLI", () => {
669
669
 
670
670
  test("permission denied returns structured response", async () => {
671
671
  const r = await runCli("fs_read", '{"path":"/etc/passwd"}', {
672
- KORTIX_API_URL: `http://localhost:${permPort}`,
672
+ TUNNEL_API_URL: `http://localhost:${permPort}`,
673
673
  });
674
674
  // CLI should output permission-required JSON (not crash)
675
675
  expect(r.json).not.toBeNull();
@@ -683,7 +683,7 @@ describe("Agent Tunnel CLI", () => {
683
683
  const r = await runCli(
684
684
  "shell",
685
685
  '{"command":"rm","args":["-rf","/"]}',
686
- { KORTIX_API_URL: `http://localhost:${permPort}` }
686
+ { TUNNEL_API_URL: `http://localhost:${permPort}` }
687
687
  );
688
688
  expect(r.json!.success).toBe(false);
689
689
  expect(r.json!.permissionRequired).toBe(true);
@@ -695,7 +695,7 @@ describe("Agent Tunnel CLI", () => {
695
695
  describe("server unreachable", () => {
696
696
  test("status with dead server returns error JSON", async () => {
697
697
  const r = await runCli("status", undefined, {
698
- KORTIX_API_URL: "http://localhost:1",
698
+ TUNNEL_API_URL: "http://localhost:1",
699
699
  });
700
700
  expect(r.exitCode).toBe(1);
701
701
  expect(r.json).not.toBeNull();
@@ -705,7 +705,7 @@ describe("Agent Tunnel CLI", () => {
705
705
 
706
706
  test("fs_read with dead server returns error JSON", async () => {
707
707
  const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
708
- KORTIX_API_URL: "http://localhost:1",
708
+ TUNNEL_API_URL: "http://localhost:1",
709
709
  });
710
710
  expect(r.exitCode).toBe(1);
711
711
  expect(r.json!.success).toBe(false);
@@ -738,7 +738,7 @@ describe("Agent Tunnel CLI", () => {
738
738
 
739
739
  test("status with no connections returns empty list", async () => {
740
740
  const r = await runCli("status", undefined, {
741
- KORTIX_API_URL: `http://localhost:${emptyPort}`,
741
+ TUNNEL_API_URL: `http://localhost:${emptyPort}`,
742
742
  });
743
743
  expect(r.exitCode).toBe(0);
744
744
  expect(r.json!.success).toBe(true);
@@ -748,7 +748,7 @@ describe("Agent Tunnel CLI", () => {
748
748
 
749
749
  test("fs_read with no connections returns error", async () => {
750
750
  const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
751
- KORTIX_API_URL: `http://localhost:${emptyPort}`,
751
+ TUNNEL_API_URL: `http://localhost:${emptyPort}`,
752
752
  });
753
753
  expect(r.exitCode).toBe(1);
754
754
  expect(r.json!.success).toBe(false);
package/src/client/cli.ts CHANGED
@@ -56,15 +56,15 @@ function getEnv(key: string): string | undefined {
56
56
  const FALLBACK_API_URL = "http://localhost:8008";
57
57
 
58
58
  function getApiBase(): string {
59
- const raw = getEnv("KORTIX_API_URL") || FALLBACK_API_URL;
59
+ const raw = getEnv("TUNNEL_API_URL") || FALLBACK_API_URL;
60
60
  const url = raw.startsWith("http") ? raw : FALLBACK_API_URL;
61
61
  return url.replace(/\/+$/, "");
62
62
  }
63
63
 
64
64
  const client = new TunnelClient({
65
65
  apiUrl: `${getApiBase()}/v1/tunnel`,
66
- token: getEnv("KORTIX_TOKEN") || "",
67
- tunnelId: getEnv("KORTIX_TUNNEL_ID"),
66
+ token: getEnv("TUNNEL_TOKEN") || "",
67
+ tunnelId: getEnv("TUNNEL_ID"),
68
68
  });
69
69
 
70
70
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -81,6 +81,11 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
81
81
  return;
82
82
  }
83
83
 
84
+ // Send signing key to agent so it never needs the server secret
85
+ try {
86
+ pending.ws.send(JSON.stringify({ type: 'auth_ok', signingKey: result.signingKey }));
87
+ } catch {}
88
+
84
89
  relay.registerAgent(tunnelId, pending.ws, result.signingKey, result.metadata);
85
90
  if (heartbeat) {
86
91
  heartbeat.register(tunnelId);
@@ -1,6 +1,5 @@
1
1
  import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
2
2
 
3
- const SIGNING_KEY_CONTEXT = 'kortix-tunnel-signing-v1';
4
3
  const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
5
4
 
6
5
  function randomAlphanumeric(length: number): string {
@@ -12,8 +11,8 @@ function randomAlphanumeric(length: number): string {
12
11
  return result;
13
12
  }
14
13
 
15
- export function deriveSigningKey(token: string): string {
16
- return createHmac('sha256', SIGNING_KEY_CONTEXT)
14
+ export function deriveSigningKey(token: string, secret: string): string {
15
+ return createHmac('sha256', secret)
17
16
  .update(token)
18
17
  .digest('hex');
19
18
  }