@checkstack/satellite-backend 0.2.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/CHANGELOG.md +44 -0
- package/drizzle/0000_melted_gargoyle.sql +10 -0
- package/drizzle/meta/0000_snapshot.json +83 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +35 -0
- package/src/config-relay.ts +31 -0
- package/src/heartbeat-monitor.test.ts +175 -0
- package/src/heartbeat-monitor.ts +70 -0
- package/src/hooks.ts +17 -0
- package/src/index.ts +159 -0
- package/src/router.ts +83 -0
- package/src/satellite-ws-handler.test.ts +265 -0
- package/src/satellite-ws-handler.ts +222 -0
- package/src/schema.ts +27 -0
- package/src/service.test.ts +292 -0
- package/src/service.ts +171 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { SatelliteService } from "./service";
|
|
3
|
+
import { OFFLINE_THRESHOLD_MS } from "@checkstack/satellite-common";
|
|
4
|
+
|
|
5
|
+
// Cast helper - creates a mock DB that satisfies the service's type requirements
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test utility for creating mock DB chains
|
|
7
|
+
type MockDb = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
function createServiceWithMockDb(overrides?: {
|
|
10
|
+
selectResult?: unknown[];
|
|
11
|
+
insertResult?: unknown[];
|
|
12
|
+
}) {
|
|
13
|
+
const selectResult = overrides?.selectResult ?? [];
|
|
14
|
+
const insertResult = overrides?.insertResult ?? [];
|
|
15
|
+
|
|
16
|
+
const db = {
|
|
17
|
+
select: mock(() => ({
|
|
18
|
+
from: mock(() => ({
|
|
19
|
+
where: mock(() => Promise.resolve(selectResult)),
|
|
20
|
+
})),
|
|
21
|
+
})),
|
|
22
|
+
insert: mock(() => ({
|
|
23
|
+
values: mock(() => ({
|
|
24
|
+
returning: mock(() => Promise.resolve(insertResult)),
|
|
25
|
+
})),
|
|
26
|
+
})),
|
|
27
|
+
update: mock(() => ({
|
|
28
|
+
set: mock(() => ({
|
|
29
|
+
where: mock(() => Promise.resolve()),
|
|
30
|
+
})),
|
|
31
|
+
})),
|
|
32
|
+
delete: mock(() => ({
|
|
33
|
+
where: mock(() => Promise.resolve()),
|
|
34
|
+
})),
|
|
35
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
36
|
+
|
|
37
|
+
return { service: new SatelliteService(db), db };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Unit tests for SatelliteService.
|
|
42
|
+
* Uses lightweight mock DB focused on testing business logic.
|
|
43
|
+
*/
|
|
44
|
+
describe("SatelliteService", () => {
|
|
45
|
+
describe("createSatellite", () => {
|
|
46
|
+
it("should generate a token with the csat_ prefix", async () => {
|
|
47
|
+
const mockRow = {
|
|
48
|
+
id: "test-uuid-1",
|
|
49
|
+
name: "EU West",
|
|
50
|
+
region: "eu-west-1",
|
|
51
|
+
tags: { provider: "aws" },
|
|
52
|
+
tokenHash: "hashed",
|
|
53
|
+
lastHeartbeatAt: null,
|
|
54
|
+
version: null,
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const { service } = createServiceWithMockDb({
|
|
59
|
+
insertResult: [mockRow],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await service.createSatellite({
|
|
63
|
+
name: "EU West",
|
|
64
|
+
region: "eu-west-1",
|
|
65
|
+
tags: { provider: "aws" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.plaintextToken).toMatch(/^csat_/);
|
|
69
|
+
expect(result.plaintextToken.length).toBeGreaterThan(10);
|
|
70
|
+
expect(result.satellite.name).toBe("EU West");
|
|
71
|
+
expect(result.satellite.region).toBe("eu-west-1");
|
|
72
|
+
expect(result.satellite.status).toBe("offline");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should generate unique tokens for different satellites", async () => {
|
|
76
|
+
const makeRow = (name: string) => ({
|
|
77
|
+
id: `uuid-${name}`,
|
|
78
|
+
name,
|
|
79
|
+
region: "us-east-1",
|
|
80
|
+
tags: {},
|
|
81
|
+
tokenHash: "hashed",
|
|
82
|
+
lastHeartbeatAt: null,
|
|
83
|
+
version: null,
|
|
84
|
+
createdAt: new Date(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let callCount = 0;
|
|
88
|
+
const db = {
|
|
89
|
+
select: mock(() => ({
|
|
90
|
+
from: mock(() => ({
|
|
91
|
+
where: mock(() => Promise.resolve([])),
|
|
92
|
+
})),
|
|
93
|
+
})),
|
|
94
|
+
insert: mock(() => ({
|
|
95
|
+
values: mock(() => ({
|
|
96
|
+
returning: mock(() => {
|
|
97
|
+
callCount++;
|
|
98
|
+
return Promise.resolve([
|
|
99
|
+
makeRow(callCount === 1 ? "Sat1" : "Sat2"),
|
|
100
|
+
]);
|
|
101
|
+
}),
|
|
102
|
+
})),
|
|
103
|
+
})),
|
|
104
|
+
update: mock(() => ({
|
|
105
|
+
set: mock(() => ({
|
|
106
|
+
where: mock(() => Promise.resolve()),
|
|
107
|
+
})),
|
|
108
|
+
})),
|
|
109
|
+
delete: mock(() => ({
|
|
110
|
+
where: mock(() => Promise.resolve()),
|
|
111
|
+
})),
|
|
112
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
113
|
+
|
|
114
|
+
const service = new SatelliteService(db);
|
|
115
|
+
|
|
116
|
+
const result1 = await service.createSatellite({
|
|
117
|
+
name: "Sat1",
|
|
118
|
+
region: "us-east-1",
|
|
119
|
+
tags: {},
|
|
120
|
+
});
|
|
121
|
+
const result2 = await service.createSatellite({
|
|
122
|
+
name: "Sat2",
|
|
123
|
+
region: "us-east-1",
|
|
124
|
+
tags: {},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result1.plaintextToken).not.toBe(result2.plaintextToken);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("validateToken", () => {
|
|
132
|
+
it("should validate a correct clientId + token pair", async () => {
|
|
133
|
+
const testToken = "csat_test-token-for-validation";
|
|
134
|
+
const hash = await Bun.password.hash(testToken, {
|
|
135
|
+
algorithm: "bcrypt",
|
|
136
|
+
cost: 10,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const { service } = createServiceWithMockDb({
|
|
140
|
+
selectResult: [
|
|
141
|
+
{
|
|
142
|
+
id: "sat-id-1",
|
|
143
|
+
name: "Test Sat",
|
|
144
|
+
region: "us-east-1",
|
|
145
|
+
tags: {},
|
|
146
|
+
tokenHash: hash,
|
|
147
|
+
lastHeartbeatAt: null,
|
|
148
|
+
version: null,
|
|
149
|
+
createdAt: new Date(),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await service.validateToken({
|
|
155
|
+
clientId: "sat-id-1",
|
|
156
|
+
token: testToken,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result).toBeDefined();
|
|
160
|
+
expect(result!.id).toBe("sat-id-1");
|
|
161
|
+
expect(result!.name).toBe("Test Sat");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should reject an invalid token for a valid clientId", async () => {
|
|
165
|
+
const hash = await Bun.password.hash("csat_correct-token", {
|
|
166
|
+
algorithm: "bcrypt",
|
|
167
|
+
cost: 10,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const { service } = createServiceWithMockDb({
|
|
171
|
+
selectResult: [
|
|
172
|
+
{
|
|
173
|
+
id: "sat-id-2",
|
|
174
|
+
name: "Sat",
|
|
175
|
+
region: "eu",
|
|
176
|
+
tags: {},
|
|
177
|
+
tokenHash: hash,
|
|
178
|
+
lastHeartbeatAt: null,
|
|
179
|
+
version: null,
|
|
180
|
+
createdAt: new Date(),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = await service.validateToken({
|
|
186
|
+
clientId: "sat-id-2",
|
|
187
|
+
token: "csat_wrong-token",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should reject a non-existent clientId", async () => {
|
|
194
|
+
const { service } = createServiceWithMockDb({
|
|
195
|
+
selectResult: [],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await service.validateToken({
|
|
199
|
+
clientId: "non-existent",
|
|
200
|
+
token: "csat_any-token",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("listSatellites", () => {
|
|
208
|
+
it("should compute online status from heartbeat timestamp", async () => {
|
|
209
|
+
const now = new Date();
|
|
210
|
+
const recentHeartbeat = new Date(now.getTime() - 10_000);
|
|
211
|
+
const staleHeartbeat = new Date(
|
|
212
|
+
now.getTime() - OFFLINE_THRESHOLD_MS - 1000,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const db = {
|
|
216
|
+
select: mock(() => ({
|
|
217
|
+
from: mock(() =>
|
|
218
|
+
Promise.resolve([
|
|
219
|
+
{
|
|
220
|
+
id: "online-sat",
|
|
221
|
+
name: "Online",
|
|
222
|
+
region: "us-east-1",
|
|
223
|
+
tags: {},
|
|
224
|
+
tokenHash: "hash",
|
|
225
|
+
lastHeartbeatAt: recentHeartbeat,
|
|
226
|
+
version: "1.0.0",
|
|
227
|
+
createdAt: now,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "offline-sat",
|
|
231
|
+
name: "Offline",
|
|
232
|
+
region: "eu-west-1",
|
|
233
|
+
tags: {},
|
|
234
|
+
tokenHash: "hash",
|
|
235
|
+
lastHeartbeatAt: staleHeartbeat,
|
|
236
|
+
version: "1.0.0",
|
|
237
|
+
createdAt: now,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "never-connected",
|
|
241
|
+
name: "Never",
|
|
242
|
+
region: "ap-south-1",
|
|
243
|
+
tags: {},
|
|
244
|
+
tokenHash: "hash",
|
|
245
|
+
lastHeartbeatAt: null,
|
|
246
|
+
version: null,
|
|
247
|
+
createdAt: now,
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
),
|
|
251
|
+
})),
|
|
252
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
253
|
+
|
|
254
|
+
const service = new SatelliteService(db);
|
|
255
|
+
const list = await service.listSatellites();
|
|
256
|
+
|
|
257
|
+
expect(list).toHaveLength(3);
|
|
258
|
+
expect(list[0].status).toBe("online");
|
|
259
|
+
expect(list[1].status).toBe("offline");
|
|
260
|
+
expect(list[2].status).toBe("offline");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("getOnlineSatelliteIds", () => {
|
|
265
|
+
it("should only return IDs of satellites with recent heartbeats", async () => {
|
|
266
|
+
const now = new Date();
|
|
267
|
+
const recentHeartbeat = new Date(now.getTime() - 5000);
|
|
268
|
+
|
|
269
|
+
const db = {
|
|
270
|
+
select: mock(() => ({
|
|
271
|
+
from: mock(() =>
|
|
272
|
+
Promise.resolve([
|
|
273
|
+
{ id: "online-1", lastHeartbeatAt: recentHeartbeat },
|
|
274
|
+
{ id: "offline-1", lastHeartbeatAt: null },
|
|
275
|
+
{
|
|
276
|
+
id: "offline-2",
|
|
277
|
+
lastHeartbeatAt: new Date(
|
|
278
|
+
now.getTime() - OFFLINE_THRESHOLD_MS - 1000,
|
|
279
|
+
),
|
|
280
|
+
},
|
|
281
|
+
]),
|
|
282
|
+
),
|
|
283
|
+
})),
|
|
284
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
285
|
+
|
|
286
|
+
const service = new SatelliteService(db);
|
|
287
|
+
const ids = await service.getOnlineSatelliteIds();
|
|
288
|
+
|
|
289
|
+
expect(ids).toEqual(["online-1"]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { satellites } from "./schema";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
5
|
+
import type {
|
|
6
|
+
SatelliteWithStatus,
|
|
7
|
+
SatelliteStatus,
|
|
8
|
+
} from "@checkstack/satellite-common";
|
|
9
|
+
import { OFFLINE_THRESHOLD_MS } from "@checkstack/satellite-common";
|
|
10
|
+
|
|
11
|
+
// Drizzle type helper
|
|
12
|
+
type Db = SafeDatabase<typeof schema>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute satellite status from lastHeartbeatAt timestamp.
|
|
16
|
+
*/
|
|
17
|
+
function computeStatus(lastHeartbeatAt: Date | null): SatelliteStatus {
|
|
18
|
+
if (!lastHeartbeatAt) return "offline";
|
|
19
|
+
const elapsed = Date.now() - lastHeartbeatAt.getTime();
|
|
20
|
+
return elapsed <= OFFLINE_THRESHOLD_MS ? "online" : "offline";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Service for managing satellite records.
|
|
25
|
+
*/
|
|
26
|
+
export class SatelliteService {
|
|
27
|
+
constructor(private db: Db) {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a new satellite.
|
|
31
|
+
* Generates a cryptographically random token, stores a bcrypt hash,
|
|
32
|
+
* and returns the plaintext token (shown once to the user).
|
|
33
|
+
*/
|
|
34
|
+
async createSatellite(props: {
|
|
35
|
+
name: string;
|
|
36
|
+
region: string;
|
|
37
|
+
tags: Record<string, string>;
|
|
38
|
+
}): Promise<{ satellite: SatelliteWithStatus; plaintextToken: string }> {
|
|
39
|
+
const { name, region, tags } = props;
|
|
40
|
+
|
|
41
|
+
// Generate a cryptographically random token with a recognizable prefix
|
|
42
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
43
|
+
const tokenBody = Buffer.from(randomBytes).toString("base64url");
|
|
44
|
+
const plaintextToken = `csat_${tokenBody}`;
|
|
45
|
+
|
|
46
|
+
// Hash the token using bcrypt
|
|
47
|
+
const tokenHash = await Bun.password.hash(plaintextToken, {
|
|
48
|
+
algorithm: "bcrypt",
|
|
49
|
+
cost: 10,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [row] = await this.db
|
|
53
|
+
.insert(satellites)
|
|
54
|
+
.values({
|
|
55
|
+
name,
|
|
56
|
+
region,
|
|
57
|
+
tags,
|
|
58
|
+
tokenHash,
|
|
59
|
+
})
|
|
60
|
+
.returning();
|
|
61
|
+
|
|
62
|
+
const satellite: SatelliteWithStatus = {
|
|
63
|
+
id: row.id,
|
|
64
|
+
name: row.name,
|
|
65
|
+
region: row.region,
|
|
66
|
+
tags: row.tags,
|
|
67
|
+
lastHeartbeatAt: row.lastHeartbeatAt ?? undefined,
|
|
68
|
+
version: row.version ?? undefined,
|
|
69
|
+
createdAt: row.createdAt,
|
|
70
|
+
status: "offline",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return { satellite, plaintextToken };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Delete a satellite by ID.
|
|
78
|
+
*/
|
|
79
|
+
async deleteSatellite(id: string): Promise<void> {
|
|
80
|
+
await this.db.delete(satellites).where(eq(satellites.id, id));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List all satellites with computed online/offline status.
|
|
85
|
+
*/
|
|
86
|
+
async listSatellites(): Promise<SatelliteWithStatus[]> {
|
|
87
|
+
const rows = await this.db.select().from(satellites);
|
|
88
|
+
return rows.map((row) => this.toSatelliteWithStatus(row));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get a single satellite by ID.
|
|
93
|
+
*/
|
|
94
|
+
async getSatellite(id: string): Promise<SatelliteWithStatus | undefined> {
|
|
95
|
+
const [row] = await this.db
|
|
96
|
+
.select()
|
|
97
|
+
.from(satellites)
|
|
98
|
+
.where(eq(satellites.id, id));
|
|
99
|
+
return row ? this.toSatelliteWithStatus(row) : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate a satellite token using clientId for O(1) lookup.
|
|
104
|
+
* Returns the satellite record if valid, undefined otherwise.
|
|
105
|
+
*/
|
|
106
|
+
async validateToken(props: {
|
|
107
|
+
clientId: string;
|
|
108
|
+
token: string;
|
|
109
|
+
}): Promise<SatelliteWithStatus | undefined> {
|
|
110
|
+
const { clientId, token } = props;
|
|
111
|
+
|
|
112
|
+
const [row] = await this.db
|
|
113
|
+
.select()
|
|
114
|
+
.from(satellites)
|
|
115
|
+
.where(eq(satellites.id, clientId));
|
|
116
|
+
|
|
117
|
+
if (!row) return undefined;
|
|
118
|
+
|
|
119
|
+
const isValid = await Bun.password.verify(token, row.tokenHash);
|
|
120
|
+
if (!isValid) return undefined;
|
|
121
|
+
|
|
122
|
+
return this.toSatelliteWithStatus(row);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Update heartbeat timestamp and version for a satellite.
|
|
127
|
+
*/
|
|
128
|
+
async updateHeartbeat(
|
|
129
|
+
id: string,
|
|
130
|
+
props: { version?: string },
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
await this.db
|
|
133
|
+
.update(satellites)
|
|
134
|
+
.set({
|
|
135
|
+
lastHeartbeatAt: new Date(),
|
|
136
|
+
version: props.version ?? undefined,
|
|
137
|
+
})
|
|
138
|
+
.where(eq(satellites.id, id));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get IDs of all satellites currently considered online.
|
|
143
|
+
*/
|
|
144
|
+
async getOnlineSatelliteIds(): Promise<string[]> {
|
|
145
|
+
const rows = await this.db
|
|
146
|
+
.select({ id: satellites.id, lastHeartbeatAt: satellites.lastHeartbeatAt })
|
|
147
|
+
.from(satellites);
|
|
148
|
+
|
|
149
|
+
return rows
|
|
150
|
+
.filter((row) => computeStatus(row.lastHeartbeatAt) === "online")
|
|
151
|
+
.map((row) => row.id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Map a database row to SatelliteWithStatus (excludes tokenHash).
|
|
156
|
+
*/
|
|
157
|
+
private toSatelliteWithStatus(
|
|
158
|
+
row: typeof satellites.$inferSelect,
|
|
159
|
+
): SatelliteWithStatus {
|
|
160
|
+
return {
|
|
161
|
+
id: row.id,
|
|
162
|
+
name: row.name,
|
|
163
|
+
region: row.region,
|
|
164
|
+
tags: row.tags,
|
|
165
|
+
lastHeartbeatAt: row.lastHeartbeatAt ?? undefined,
|
|
166
|
+
version: row.version ?? undefined,
|
|
167
|
+
createdAt: row.createdAt,
|
|
168
|
+
status: computeStatus(row.lastHeartbeatAt),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|