@online5880/opensession 0.1.0 → 0.1.3

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/src/config.js CHANGED
@@ -1,32 +1,118 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
-
5
- const CONFIG_DIR = path.join(os.homedir(), '.opensession');
6
- const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
7
-
8
- export async function readConfig() {
9
- try {
10
- const raw = await fs.readFile(CONFIG_PATH, 'utf8');
11
- return JSON.parse(raw);
12
- } catch (error) {
13
- if (error.code === 'ENOENT') {
14
- return {};
15
- }
16
- throw error;
17
- }
18
- }
19
-
20
- export async function writeConfig(config) {
21
- await fs.mkdir(CONFIG_DIR, { recursive: true });
22
- await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
23
- return CONFIG_PATH;
24
- }
25
-
26
- export function getConfigPath() {
27
- return CONFIG_PATH;
28
- }
29
-
30
- export function mergeConfig(base, patch) {
31
- return { ...base, ...patch };
32
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), '.opensession');
7
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
8
+ const ENC_PREFIX = 'enc:v1';
9
+ const ENC_ALGO = 'aes-256-gcm';
10
+ const ENC_IV_BYTES = 12;
11
+
12
+ function getKeyMaterial() {
13
+ return `${os.userInfo().username}|${os.hostname()}|${process.platform}|${process.arch}|opensession-config-v1`;
14
+ }
15
+
16
+ function deriveEncryptionKey() {
17
+ const salt = crypto.createHash('sha256').update(getKeyMaterial()).digest();
18
+ return crypto.scryptSync(getKeyMaterial(), salt, 32);
19
+ }
20
+
21
+ function encryptSecret(plainText) {
22
+ if (typeof plainText !== 'string' || plainText.length === 0) {
23
+ return null;
24
+ }
25
+ const iv = crypto.randomBytes(ENC_IV_BYTES);
26
+ const cipher = crypto.createCipheriv(ENC_ALGO, deriveEncryptionKey(), iv);
27
+ const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
28
+ const tag = cipher.getAuthTag();
29
+ return `${ENC_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
30
+ }
31
+
32
+ function decryptSecret(payload) {
33
+ if (typeof payload !== 'string' || !payload.startsWith(`${ENC_PREFIX}:`)) {
34
+ return null;
35
+ }
36
+ const [, , ivBase64, tagBase64, cipherBase64] = payload.split(':');
37
+ if (!ivBase64 || !tagBase64 || !cipherBase64) {
38
+ return null;
39
+ }
40
+ const decipher = crypto.createDecipheriv(ENC_ALGO, deriveEncryptionKey(), Buffer.from(ivBase64, 'base64'));
41
+ decipher.setAuthTag(Buffer.from(tagBase64, 'base64'));
42
+ const decrypted = Buffer.concat([
43
+ decipher.update(Buffer.from(cipherBase64, 'base64')),
44
+ decipher.final()
45
+ ]);
46
+ return decrypted.toString('utf8');
47
+ }
48
+
49
+ function decodeSensitiveConfig(config) {
50
+ if (!config || typeof config !== 'object') {
51
+ return {};
52
+ }
53
+ if (typeof config.supabaseAnonKey === 'string' && config.supabaseAnonKey.length > 0) {
54
+ return config;
55
+ }
56
+ if (typeof config.supabaseAnonKeyEnc !== 'string') {
57
+ return config;
58
+ }
59
+
60
+ try {
61
+ const decrypted = decryptSecret(config.supabaseAnonKeyEnc);
62
+ if (!decrypted) {
63
+ return config;
64
+ }
65
+ return {
66
+ ...config,
67
+ supabaseAnonKey: decrypted
68
+ };
69
+ } catch {
70
+ return config;
71
+ }
72
+ }
73
+
74
+ function encodeSensitiveConfig(config) {
75
+ if (!config || typeof config !== 'object') {
76
+ return {};
77
+ }
78
+ const next = { ...config };
79
+ const secret = typeof next.supabaseAnonKey === 'string' ? next.supabaseAnonKey.trim() : '';
80
+
81
+ if (secret.length > 0) {
82
+ const encrypted = encryptSecret(secret);
83
+ if (encrypted) {
84
+ next.supabaseAnonKeyEnc = encrypted;
85
+ delete next.supabaseAnonKey;
86
+ }
87
+ }
88
+
89
+ return next;
90
+ }
91
+
92
+ export async function readConfig() {
93
+ try {
94
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
95
+ const parsed = JSON.parse(raw);
96
+ return decodeSensitiveConfig(parsed);
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') {
99
+ return {};
100
+ }
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ export async function writeConfig(config) {
106
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
107
+ const serializableConfig = encodeSensitiveConfig(config);
108
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(serializableConfig, null, 2) + '\n', 'utf8');
109
+ return CONFIG_PATH;
110
+ }
111
+
112
+ export function getConfigPath() {
113
+ return CONFIG_PATH;
114
+ }
115
+
116
+ export function mergeConfig(base, patch) {
117
+ return { ...base, ...patch };
118
+ }
@@ -0,0 +1,194 @@
1
+ import http from 'node:http';
2
+ import { dispatchAutomation } from './automation.js';
3
+ import { appendEvent, ensureProject, getSession, listActiveSessions, startSession } from './supabase.js';
4
+
5
+ const MAX_BODY_BYTES = 1024 * 1024;
6
+
7
+ function formatError(error) {
8
+ if (error instanceof Error) {
9
+ return error.message;
10
+ }
11
+ if (error && typeof error === 'object') {
12
+ const message = error.message;
13
+ const details = error.details;
14
+ const hint = error.hint;
15
+ const code = error.code;
16
+ const parts = [message, details, hint, code].filter(Boolean);
17
+ if (parts.length > 0) {
18
+ return parts.join(' | ');
19
+ }
20
+ try {
21
+ return JSON.stringify(error);
22
+ } catch {
23
+ return 'Unknown object error';
24
+ }
25
+ }
26
+ return String(error);
27
+ }
28
+
29
+ function sendJson(res, statusCode, payload) {
30
+ const body = JSON.stringify(payload);
31
+ res.writeHead(statusCode, {
32
+ 'Content-Type': 'application/json; charset=utf-8',
33
+ 'Content-Length': Buffer.byteLength(body)
34
+ });
35
+ res.end(body);
36
+ }
37
+
38
+ async function readRequestBody(req) {
39
+ return new Promise((resolve, reject) => {
40
+ let total = 0;
41
+ const chunks = [];
42
+
43
+ req.on('data', (chunk) => {
44
+ total += chunk.length;
45
+ if (total > MAX_BODY_BYTES) {
46
+ reject(new Error('Request body too large (max 1MB).'));
47
+ req.destroy();
48
+ return;
49
+ }
50
+ chunks.push(chunk);
51
+ });
52
+
53
+ req.on('end', () => {
54
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
55
+ if (!raw) {
56
+ resolve({});
57
+ return;
58
+ }
59
+ try {
60
+ resolve(JSON.parse(raw));
61
+ } catch {
62
+ reject(new Error('Invalid JSON body.'));
63
+ }
64
+ });
65
+
66
+ req.on('error', (error) => {
67
+ reject(error);
68
+ });
69
+ });
70
+ }
71
+
72
+ async function resolveSession({ client, projectKey, actor, requestedSessionId, autoStartSession }) {
73
+ const project = await ensureProject(client, projectKey, projectKey);
74
+
75
+ if (requestedSessionId) {
76
+ const existing = await getSession(client, requestedSessionId);
77
+ if (!existing) {
78
+ throw new Error(`Session not found: ${requestedSessionId}`);
79
+ }
80
+ return { project, session: existing, created: false };
81
+ }
82
+
83
+ const active = await listActiveSessions(client, project.id);
84
+ if (active.length > 0) {
85
+ return { project, session: active[0], created: false };
86
+ }
87
+
88
+ if (!autoStartSession) {
89
+ throw new Error('No active session found and autoStartSession is disabled.');
90
+ }
91
+
92
+ const created = await startSession(client, project.id, actor);
93
+ return { project, session: created, created: true };
94
+ }
95
+
96
+ export async function startHookServer({
97
+ host,
98
+ port,
99
+ secret,
100
+ projectKey,
101
+ actor,
102
+ autoStartSession,
103
+ fixedSessionId,
104
+ client,
105
+ automationConfig
106
+ }) {
107
+ const sharedSecret = typeof secret === 'string' && secret.length > 0 ? secret : null;
108
+
109
+ const server = http.createServer(async (req, res) => {
110
+ try {
111
+ if (req.method === 'GET' && req.url === '/health') {
112
+ sendJson(res, 200, { ok: true });
113
+ return;
114
+ }
115
+
116
+ if (req.method !== 'POST' || req.url !== '/webhooks/event') {
117
+ sendJson(res, 404, { error: 'Not found' });
118
+ return;
119
+ }
120
+
121
+ if (sharedSecret) {
122
+ const provided = String(req.headers['x-opensession-secret'] ?? '');
123
+ if (provided !== sharedSecret) {
124
+ sendJson(res, 401, { error: 'Invalid secret' });
125
+ return;
126
+ }
127
+ }
128
+
129
+ const body = await readRequestBody(req);
130
+ const incomingProjectKey = typeof body.projectKey === 'string' && body.projectKey.trim().length > 0
131
+ ? body.projectKey.trim()
132
+ : projectKey;
133
+
134
+ if (!incomingProjectKey) {
135
+ sendJson(res, 400, { error: 'Missing project key. Provide body.projectKey or --project-key.' });
136
+ return;
137
+ }
138
+
139
+ const incomingActor = typeof body.actor === 'string' && body.actor.trim().length > 0
140
+ ? body.actor.trim()
141
+ : actor;
142
+ const source = typeof body.source === 'string' && body.source.trim().length > 0 ? body.source.trim() : 'custom';
143
+ const eventType = typeof body.eventType === 'string' && body.eventType.trim().length > 0
144
+ ? body.eventType.trim()
145
+ : `${source}.event`;
146
+
147
+ const { project, session, created } = await resolveSession({
148
+ client,
149
+ projectKey: incomingProjectKey,
150
+ actor: incomingActor,
151
+ requestedSessionId: body.sessionId ?? fixedSessionId,
152
+ autoStartSession
153
+ });
154
+
155
+ const payload = {
156
+ source,
157
+ receivedAt: new Date().toISOString(),
158
+ data: body.payload ?? body
159
+ };
160
+
161
+ const event = await appendEvent(client, session.id, eventType, payload);
162
+ const envelope = {
163
+ projectId: project.id,
164
+ projectKey: incomingProjectKey,
165
+ sessionId: session.id,
166
+ eventId: event.id,
167
+ eventType,
168
+ source,
169
+ actor: incomingActor,
170
+ createdSession: created,
171
+ payload
172
+ };
173
+
174
+ const automationResults = await dispatchAutomation(envelope, automationConfig);
175
+ sendJson(res, 202, {
176
+ ok: true,
177
+ projectKey: incomingProjectKey,
178
+ sessionId: session.id,
179
+ eventId: event.id,
180
+ eventType,
181
+ automationResults
182
+ });
183
+ } catch (error) {
184
+ sendJson(res, 500, { error: formatError(error) });
185
+ }
186
+ });
187
+
188
+ await new Promise((resolve, reject) => {
189
+ server.once('error', reject);
190
+ server.listen(port, host, resolve);
191
+ });
192
+
193
+ return { server, url: `http://${host}:${port}` };
194
+ }
@@ -0,0 +1,66 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ function getPendingResumeMap(config) {
4
+ const value = config?.pendingResumeOperations;
5
+ if (!value || typeof value !== 'object') {
6
+ return {};
7
+ }
8
+ return value;
9
+ }
10
+
11
+ function getResumeSlot(sessionId, actor) {
12
+ return `${sessionId}:${actor}`;
13
+ }
14
+
15
+ export function reserveResumeOperation(config, sessionId, actor, requestedOperationId = null) {
16
+ const trimmedRequested = typeof requestedOperationId === 'string' && requestedOperationId.trim().length > 0
17
+ ? requestedOperationId.trim()
18
+ : null;
19
+ const slot = getResumeSlot(sessionId, actor);
20
+ const pending = getPendingResumeMap(config);
21
+
22
+ if (trimmedRequested) {
23
+ return {
24
+ operationId: trimmedRequested,
25
+ nextConfig: {
26
+ ...config,
27
+ pendingResumeOperations: {
28
+ ...pending,
29
+ [slot]: trimmedRequested
30
+ }
31
+ }
32
+ };
33
+ }
34
+
35
+ const existing = pending[slot];
36
+ if (typeof existing === 'string' && existing.trim().length > 0) {
37
+ return { operationId: existing, nextConfig: config };
38
+ }
39
+
40
+ const generated = randomUUID();
41
+ return {
42
+ operationId: generated,
43
+ nextConfig: {
44
+ ...config,
45
+ pendingResumeOperations: {
46
+ ...pending,
47
+ [slot]: generated
48
+ }
49
+ }
50
+ };
51
+ }
52
+
53
+ export function releaseResumeOperation(config, sessionId, actor) {
54
+ const slot = getResumeSlot(sessionId, actor);
55
+ const pending = getPendingResumeMap(config);
56
+ if (!(slot in pending)) {
57
+ return config;
58
+ }
59
+
60
+ const nextPending = { ...pending };
61
+ delete nextPending[slot];
62
+ return {
63
+ ...config,
64
+ pendingResumeOperations: nextPending
65
+ };
66
+ }
package/src/metrics.js ADDED
@@ -0,0 +1,110 @@
1
+ function toTimestamp(value) {
2
+ const time = Date.parse(value);
3
+ if (Number.isNaN(time)) {
4
+ return null;
5
+ }
6
+ return time;
7
+ }
8
+
9
+ export function startOfUtcWeek(dateLike) {
10
+ const date = new Date(dateLike);
11
+ const day = date.getUTCDay();
12
+ const daysFromMonday = (day + 6) % 7;
13
+ const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
14
+ start.setUTCDate(start.getUTCDate() - daysFromMonday);
15
+ return start;
16
+ }
17
+
18
+ export function addDaysUtc(dateLike, days) {
19
+ const date = new Date(dateLike);
20
+ date.setUTCDate(date.getUTCDate() + days);
21
+ return date;
22
+ }
23
+
24
+ export function computeKpis(sessions, events) {
25
+ const safeSessions = Array.isArray(sessions) ? sessions : [];
26
+ const safeEvents = Array.isArray(events) ? events : [];
27
+ const uniqueActors = new Set();
28
+
29
+ for (const session of safeSessions) {
30
+ if (session?.actor) {
31
+ uniqueActors.add(session.actor);
32
+ }
33
+ }
34
+
35
+ const resumedEvents = safeEvents.filter((event) => event?.type === 'resumed').length;
36
+ const totalSessions = safeSessions.length;
37
+ const totalEvents = safeEvents.length;
38
+
39
+ return {
40
+ totalSessions,
41
+ activeSessions: safeSessions.filter((session) => session?.status === 'active').length,
42
+ uniqueActors: uniqueActors.size,
43
+ totalEvents,
44
+ resumedEvents,
45
+ eventsPerSession: totalSessions === 0 ? 0 : totalEvents / totalSessions,
46
+ resumeRate: totalSessions === 0 ? 0 : resumedEvents / totalSessions
47
+ };
48
+ }
49
+
50
+ export function computeWeeklyTrend(sessions, events, weekCount = 6, now = new Date()) {
51
+ const safeWeekCount = Math.max(1, Math.min(26, Number.parseInt(String(weekCount), 10) || 6));
52
+ const safeSessions = Array.isArray(sessions) ? sessions : [];
53
+ const safeEvents = Array.isArray(events) ? events : [];
54
+ const currentWeekStart = startOfUtcWeek(now);
55
+ const firstWeekStart = addDaysUtc(currentWeekStart, (safeWeekCount - 1) * -7);
56
+
57
+ const buckets = [];
58
+ for (let i = 0; i < safeWeekCount; i += 1) {
59
+ const start = addDaysUtc(firstWeekStart, i * 7);
60
+ const end = addDaysUtc(start, 7);
61
+ buckets.push({
62
+ start,
63
+ end,
64
+ label: start.toISOString().slice(0, 10),
65
+ sessions: 0,
66
+ events: 0,
67
+ actors: new Set()
68
+ });
69
+ }
70
+
71
+ for (const session of safeSessions) {
72
+ const ts = toTimestamp(session?.started_at);
73
+ if (ts === null) {
74
+ continue;
75
+ }
76
+ const index = Math.floor((ts - firstWeekStart.getTime()) / (7 * 24 * 60 * 60 * 1000));
77
+ if (index < 0 || index >= buckets.length) {
78
+ continue;
79
+ }
80
+ buckets[index].sessions += 1;
81
+ if (session?.actor) {
82
+ buckets[index].actors.add(session.actor);
83
+ }
84
+ }
85
+
86
+ for (const event of safeEvents) {
87
+ const ts = toTimestamp(event?.created_at);
88
+ if (ts === null) {
89
+ continue;
90
+ }
91
+ const index = Math.floor((ts - firstWeekStart.getTime()) / (7 * 24 * 60 * 60 * 1000));
92
+ if (index < 0 || index >= buckets.length) {
93
+ continue;
94
+ }
95
+ buckets[index].events += 1;
96
+ }
97
+
98
+ return buckets.map((bucket) => ({
99
+ weekStart: bucket.label,
100
+ sessions: bucket.sessions,
101
+ uniqueActors: bucket.actors.size,
102
+ events: bucket.events
103
+ }));
104
+ }
105
+
106
+ export function formatSignedDelta(current, previous) {
107
+ const delta = current - previous;
108
+ const prefix = delta > 0 ? '+' : '';
109
+ return `${prefix}${delta}`;
110
+ }