@castlekit/castle 0.1.5 → 0.3.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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// channels
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export const channels = sqliteTable("channels", {
|
|
8
|
+
id: text("id").primaryKey(),
|
|
9
|
+
name: text("name").notNull(),
|
|
10
|
+
defaultAgentId: text("default_agent_id").notNull(),
|
|
11
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
12
|
+
updatedAt: integer("updated_at"), // unix ms
|
|
13
|
+
lastAccessedAt: integer("last_accessed_at"), // unix ms — last time user opened this channel
|
|
14
|
+
archivedAt: integer("archived_at"), // unix ms — null if active, set when archived
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// settings (key-value store for user preferences)
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export const settings = sqliteTable("settings", {
|
|
22
|
+
key: text("key").primaryKey(),
|
|
23
|
+
value: text("value").notNull(),
|
|
24
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// agent_statuses (live agent activity state)
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export const agentStatuses = sqliteTable("agent_statuses", {
|
|
32
|
+
agentId: text("agent_id").primaryKey(),
|
|
33
|
+
status: text("status").notNull().default("idle"), // "idle" | "thinking" | "active"
|
|
34
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// channel_agents (many-to-many junction)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export const channelAgents = sqliteTable(
|
|
42
|
+
"channel_agents",
|
|
43
|
+
{
|
|
44
|
+
channelId: text("channel_id")
|
|
45
|
+
.notNull()
|
|
46
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
47
|
+
agentId: text("agent_id").notNull(),
|
|
48
|
+
},
|
|
49
|
+
(table) => [
|
|
50
|
+
primaryKey({ columns: [table.channelId, table.agentId] }),
|
|
51
|
+
]
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// sessions
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export const sessions = sqliteTable(
|
|
59
|
+
"sessions",
|
|
60
|
+
{
|
|
61
|
+
id: text("id").primaryKey(),
|
|
62
|
+
channelId: text("channel_id")
|
|
63
|
+
.notNull()
|
|
64
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
65
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
66
|
+
startedAt: integer("started_at").notNull(), // unix ms
|
|
67
|
+
endedAt: integer("ended_at"), // unix ms, nullable
|
|
68
|
+
summary: text("summary"),
|
|
69
|
+
totalInputTokens: integer("total_input_tokens").default(0),
|
|
70
|
+
totalOutputTokens: integer("total_output_tokens").default(0),
|
|
71
|
+
},
|
|
72
|
+
(table) => [
|
|
73
|
+
index("idx_sessions_channel").on(table.channelId, table.startedAt),
|
|
74
|
+
]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// messages
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
export const messages = sqliteTable(
|
|
82
|
+
"messages",
|
|
83
|
+
{
|
|
84
|
+
id: text("id").primaryKey(),
|
|
85
|
+
channelId: text("channel_id")
|
|
86
|
+
.notNull()
|
|
87
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
88
|
+
sessionId: text("session_id").references(() => sessions.id),
|
|
89
|
+
senderType: text("sender_type").notNull(), // "user" | "agent"
|
|
90
|
+
senderId: text("sender_id").notNull(),
|
|
91
|
+
senderName: text("sender_name"),
|
|
92
|
+
content: text("content").notNull().default(""),
|
|
93
|
+
status: text("status").notNull().default("complete"), // "complete" | "interrupted" | "aborted"
|
|
94
|
+
mentionedAgentId: text("mentioned_agent_id"),
|
|
95
|
+
runId: text("run_id"), // Gateway run ID for streaming correlation
|
|
96
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
97
|
+
inputTokens: integer("input_tokens"),
|
|
98
|
+
outputTokens: integer("output_tokens"),
|
|
99
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
100
|
+
},
|
|
101
|
+
(table) => [
|
|
102
|
+
index("idx_messages_channel").on(table.channelId, table.createdAt),
|
|
103
|
+
index("idx_messages_session").on(table.sessionId, table.createdAt),
|
|
104
|
+
index("idx_messages_run_id").on(table.runId),
|
|
105
|
+
]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// message_attachments
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
export const messageAttachments = sqliteTable(
|
|
113
|
+
"message_attachments",
|
|
114
|
+
{
|
|
115
|
+
id: text("id").primaryKey(),
|
|
116
|
+
messageId: text("message_id")
|
|
117
|
+
.notNull()
|
|
118
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
119
|
+
attachmentType: text("attachment_type").notNull(), // "image" | "audio"
|
|
120
|
+
filePath: text("file_path").notNull(),
|
|
121
|
+
mimeType: text("mime_type"),
|
|
122
|
+
fileSize: integer("file_size"),
|
|
123
|
+
originalName: text("original_name"),
|
|
124
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
125
|
+
},
|
|
126
|
+
(table) => [
|
|
127
|
+
index("idx_attachments_message").on(table.messageId),
|
|
128
|
+
]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// recent_searches
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
export const recentSearches = sqliteTable("recent_searches", {
|
|
136
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
137
|
+
query: text("query").notNull(),
|
|
138
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// message_reactions
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
export const messageReactions = sqliteTable(
|
|
146
|
+
"message_reactions",
|
|
147
|
+
{
|
|
148
|
+
id: text("id").primaryKey(),
|
|
149
|
+
messageId: text("message_id")
|
|
150
|
+
.notNull()
|
|
151
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
152
|
+
agentId: text("agent_id"),
|
|
153
|
+
emoji: text("emoji").notNull(),
|
|
154
|
+
emojiChar: text("emoji_char").notNull(),
|
|
155
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
156
|
+
},
|
|
157
|
+
(table) => [
|
|
158
|
+
index("idx_reactions_message").on(table.messageId),
|
|
159
|
+
]
|
|
160
|
+
);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign, createHash } from "crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { platform } from "os";
|
|
5
|
+
import { getCastleDir, ensureCastleDir } from "./config";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface DeviceIdentity {
|
|
12
|
+
deviceId: string;
|
|
13
|
+
publicKey: string; // PEM-encoded Ed25519 public key
|
|
14
|
+
privateKey: string; // PEM-encoded Ed25519 private key
|
|
15
|
+
createdAt: string; // ISO-8601
|
|
16
|
+
deviceToken?: string;
|
|
17
|
+
pairedAt?: string; // ISO-8601
|
|
18
|
+
gatewayUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DeviceInfo {
|
|
22
|
+
deviceId: string;
|
|
23
|
+
publicKey: string;
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
isPaired: boolean;
|
|
27
|
+
pairedAt?: string;
|
|
28
|
+
gatewayUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Paths
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
function getDevicePath(): string {
|
|
36
|
+
return join(getCastleDir(), "device.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Helpers
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a stored key is PEM format (vs old hex format).
|
|
45
|
+
*/
|
|
46
|
+
function isPem(key: string): boolean {
|
|
47
|
+
return key.startsWith("-----BEGIN ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive device ID from public key per Gateway protocol.
|
|
52
|
+
* Gateway expects: SHA-256 hash of raw Ed25519 public key bytes, hex-encoded.
|
|
53
|
+
*
|
|
54
|
+
* The PEM contains a DER-encoded SPKI structure:
|
|
55
|
+
* [12 bytes algorithm info] + [32 bytes raw Ed25519 key]
|
|
56
|
+
*/
|
|
57
|
+
function deriveDeviceId(publicKeyPem: string): string {
|
|
58
|
+
const base64 = publicKeyPem
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(line => !line.includes("BEGIN") && !line.includes("END") && line.trim())
|
|
61
|
+
.join("");
|
|
62
|
+
const der = Buffer.from(base64, "base64");
|
|
63
|
+
// SPKI for Ed25519: 12-byte header + 32-byte raw key
|
|
64
|
+
const rawPublicKey = der.slice(12);
|
|
65
|
+
return createHash("sha256").update(rawPublicKey).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write identity to disk with restrictive permissions.
|
|
70
|
+
*/
|
|
71
|
+
let _windowsPermWarnShown = false;
|
|
72
|
+
|
|
73
|
+
function persistIdentity(identity: DeviceIdentity): void {
|
|
74
|
+
const devicePath = getDevicePath();
|
|
75
|
+
ensureCastleDir();
|
|
76
|
+
writeFileSync(devicePath, JSON.stringify(identity, null, 2), "utf-8");
|
|
77
|
+
|
|
78
|
+
if (platform() === "win32") {
|
|
79
|
+
// chmod is a no-op on Windows — warn once
|
|
80
|
+
if (!_windowsPermWarnShown) {
|
|
81
|
+
console.warn("[Device] Warning: On Windows, device.json file permissions cannot be restricted.");
|
|
82
|
+
console.warn("[Device] Keep your user account secure to protect your device private key.");
|
|
83
|
+
_windowsPermWarnShown = true;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
chmodSync(devicePath, 0o600);
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore — may fail on some filesystems
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Core API
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load existing device identity or generate a new Ed25519 keypair.
|
|
100
|
+
* Keys are stored in PEM format as required by the Gateway protocol.
|
|
101
|
+
* Identity is persisted in ~/.castle/device.json with mode 0600.
|
|
102
|
+
*/
|
|
103
|
+
export function getOrCreateIdentity(): DeviceIdentity {
|
|
104
|
+
const devicePath = getDevicePath();
|
|
105
|
+
|
|
106
|
+
// Try loading existing identity
|
|
107
|
+
if (existsSync(devicePath)) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
110
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
111
|
+
if (identity.deviceId && identity.publicKey && identity.privateKey) {
|
|
112
|
+
// Auto-upgrade: if keys are in old hex/DER format, regenerate entirely
|
|
113
|
+
if (!isPem(identity.publicKey)) {
|
|
114
|
+
console.log("[Device] Upgrading key format from hex to PEM — regenerating keypair");
|
|
115
|
+
return generateIdentity();
|
|
116
|
+
}
|
|
117
|
+
// Auto-fix: if deviceId is a UUID instead of derived from public key, re-derive
|
|
118
|
+
const expectedId = deriveDeviceId(identity.publicKey);
|
|
119
|
+
if (identity.deviceId !== expectedId) {
|
|
120
|
+
console.log("[Device] Fixing deviceId — deriving from public key per Gateway protocol");
|
|
121
|
+
identity.deviceId = expectedId;
|
|
122
|
+
persistIdentity(identity);
|
|
123
|
+
}
|
|
124
|
+
return identity;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Corrupted file — regenerate
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return generateIdentity();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Generate a new Ed25519 keypair and persist it.
|
|
136
|
+
*/
|
|
137
|
+
function generateIdentity(): DeviceIdentity {
|
|
138
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
139
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
140
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const pubKeyStr = publicKey as unknown as string;
|
|
144
|
+
|
|
145
|
+
const identity: DeviceIdentity = {
|
|
146
|
+
deviceId: deriveDeviceId(pubKeyStr),
|
|
147
|
+
publicKey: pubKeyStr,
|
|
148
|
+
privateKey: privateKey as unknown as string,
|
|
149
|
+
createdAt: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
persistIdentity(identity);
|
|
153
|
+
return identity;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parameters for signing a device auth payload.
|
|
158
|
+
* Must match the Gateway's buildDeviceAuthPayload() format exactly.
|
|
159
|
+
*/
|
|
160
|
+
export interface DeviceAuthSignParams {
|
|
161
|
+
nonce: string;
|
|
162
|
+
clientId: string;
|
|
163
|
+
clientMode: string;
|
|
164
|
+
role: string;
|
|
165
|
+
scopes: string[];
|
|
166
|
+
token: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sign a device auth payload per the Gateway protocol.
|
|
171
|
+
*
|
|
172
|
+
* The Gateway builds a pipe-delimited string:
|
|
173
|
+
* v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
|
174
|
+
* and verifies the Ed25519 signature against that string.
|
|
175
|
+
*
|
|
176
|
+
* Returns { signature (base64url), signedAt (ms) }.
|
|
177
|
+
*/
|
|
178
|
+
export function signDeviceAuth(params: DeviceAuthSignParams): {
|
|
179
|
+
signature: string;
|
|
180
|
+
signedAt: number;
|
|
181
|
+
} {
|
|
182
|
+
const identity = getOrCreateIdentity();
|
|
183
|
+
const signedAt = Date.now();
|
|
184
|
+
|
|
185
|
+
// Build the exact same payload string the Gateway builds
|
|
186
|
+
const payload = [
|
|
187
|
+
"v2", // version (v2 when nonce is present)
|
|
188
|
+
identity.deviceId,
|
|
189
|
+
params.clientId,
|
|
190
|
+
params.clientMode,
|
|
191
|
+
params.role,
|
|
192
|
+
params.scopes.join(","),
|
|
193
|
+
String(signedAt),
|
|
194
|
+
params.token,
|
|
195
|
+
params.nonce,
|
|
196
|
+
].join("|");
|
|
197
|
+
|
|
198
|
+
// Ed25519 doesn't use a digest algorithm (pass null)
|
|
199
|
+
const sig = sign(null, Buffer.from(payload, "utf-8"), identity.privateKey);
|
|
200
|
+
|
|
201
|
+
// Gateway expects base64url encoding
|
|
202
|
+
const signature = sig
|
|
203
|
+
.toString("base64")
|
|
204
|
+
.replaceAll("+", "-")
|
|
205
|
+
.replaceAll("/", "_")
|
|
206
|
+
.replace(/=+$/g, "");
|
|
207
|
+
|
|
208
|
+
return { signature, signedAt };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Save a device token received after successful pairing.
|
|
213
|
+
*/
|
|
214
|
+
export function saveDeviceToken(token: string, gatewayUrl?: string): void {
|
|
215
|
+
const identity = getOrCreateIdentity();
|
|
216
|
+
identity.deviceToken = token;
|
|
217
|
+
identity.pairedAt = new Date().toISOString();
|
|
218
|
+
if (gatewayUrl) {
|
|
219
|
+
identity.gatewayUrl = gatewayUrl;
|
|
220
|
+
}
|
|
221
|
+
persistIdentity(identity);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the saved device token, or null if not yet paired.
|
|
226
|
+
*/
|
|
227
|
+
export function getDeviceToken(): string | null {
|
|
228
|
+
const devicePath = getDevicePath();
|
|
229
|
+
if (!existsSync(devicePath)) return null;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
233
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
234
|
+
return identity.deviceToken || null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Clear the saved device token without deleting the identity.
|
|
242
|
+
* Used when a device token is rejected (e.g. Gateway was reset).
|
|
243
|
+
* The device keypair is preserved so it can re-pair.
|
|
244
|
+
*/
|
|
245
|
+
export function clearDeviceToken(): void {
|
|
246
|
+
const devicePath = getDevicePath();
|
|
247
|
+
if (!existsSync(devicePath)) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
251
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
252
|
+
delete identity.deviceToken;
|
|
253
|
+
delete identity.pairedAt;
|
|
254
|
+
delete identity.gatewayUrl;
|
|
255
|
+
persistIdentity(identity);
|
|
256
|
+
console.log("[Device] Cleared device token");
|
|
257
|
+
} catch {
|
|
258
|
+
// If we can't read/parse, nothing to clear
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Delete device identity entirely. Forces re-pairing on next connection.
|
|
264
|
+
*/
|
|
265
|
+
export function resetIdentity(): boolean {
|
|
266
|
+
const devicePath = getDevicePath();
|
|
267
|
+
if (existsSync(devicePath)) {
|
|
268
|
+
unlinkSync(devicePath);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get a summary of device identity for display (no private key).
|
|
276
|
+
*/
|
|
277
|
+
export function getDeviceInfo(): DeviceInfo | null {
|
|
278
|
+
const devicePath = getDevicePath();
|
|
279
|
+
if (!existsSync(devicePath)) return null;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
283
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
284
|
+
|
|
285
|
+
// Create a fingerprint from the public key
|
|
286
|
+
const fingerprint = createHash("sha256")
|
|
287
|
+
.update(identity.publicKey)
|
|
288
|
+
.digest("hex")
|
|
289
|
+
.slice(0, 16);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
deviceId: identity.deviceId,
|
|
293
|
+
publicKey: identity.publicKey,
|
|
294
|
+
fingerprint,
|
|
295
|
+
createdAt: identity.createdAt,
|
|
296
|
+
isPaired: !!identity.deviceToken,
|
|
297
|
+
pairedAt: identity.pairedAt,
|
|
298
|
+
gatewayUrl: identity.gatewayUrl,
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|