purmemo-mcp 14.0.0 → 14.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/package.json +1 -1
- package/src/remote/connection-monitor.js +170 -0
- package/src/remote/login.html +295 -0
- package/src/remote/oauth-simple.js +84 -0
- package/src/remote/success.html +91 -0
- package/src/remote/widgets/context.html +216 -0
- package/src/remote/widgets/discover.html +215 -0
- package/src/remote/widgets/memory-detail.html +181 -0
- package/src/remote/widgets/recall.html +369 -0
- package/src/remote/widgets/save.html +170 -0
- package/src/server.js +486 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purmemo-mcp",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.2.0",
|
|
4
4
|
"mcpName": "io.github.purmemo-ai/purmemo",
|
|
5
5
|
"description": "MCP server for pūrmemo - AI conversation memory that works everywhere. Save and recall conversations across Claude Desktop, Cursor, and other MCP-compatible platforms. Intelligent context extraction, smart titles, living documents.",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Monitor — tracks active connections, rates, alerts
|
|
3
|
+
* Port of Python connection_monitor.py
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ConnectionMonitor {
|
|
7
|
+
constructor(alertThreshold = 100) {
|
|
8
|
+
this.activeConnections = new Map();
|
|
9
|
+
this.connectionEvents = []; // last 300 events (~5 min)
|
|
10
|
+
this.authFailures = []; // last 100
|
|
11
|
+
this.toolUsage = new Map(); // conn_id → { tool → count }
|
|
12
|
+
this.totalConnections = 0;
|
|
13
|
+
this.successfulConnections = 0;
|
|
14
|
+
this.failedConnections = 0;
|
|
15
|
+
this.totalAuthFailures = 0;
|
|
16
|
+
this.alertThreshold = alertThreshold;
|
|
17
|
+
this.alertsSent = new Set();
|
|
18
|
+
this._monitorInterval = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
start() {
|
|
22
|
+
this._monitorInterval = setInterval(() => this._checkAlerts(), 30000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
stop() {
|
|
26
|
+
if (this._monitorInterval) clearInterval(this._monitorInterval);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
trackConnection(connId, info = {}) {
|
|
30
|
+
this.totalConnections++;
|
|
31
|
+
this.successfulConnections++;
|
|
32
|
+
this.activeConnections.set(connId, {
|
|
33
|
+
...info,
|
|
34
|
+
connectedAt: Date.now(),
|
|
35
|
+
lastActivity: Date.now(),
|
|
36
|
+
toolCalls: {},
|
|
37
|
+
errors: 0
|
|
38
|
+
});
|
|
39
|
+
this._addEvent({ type: 'connect', connId, success: true });
|
|
40
|
+
this.toolUsage.set(connId, {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
trackDisconnection(connId) {
|
|
44
|
+
const conn = this.activeConnections.get(connId);
|
|
45
|
+
if (!conn) return;
|
|
46
|
+
this._addEvent({ type: 'disconnect', connId, duration: Date.now() - conn.connectedAt });
|
|
47
|
+
this.activeConnections.delete(connId);
|
|
48
|
+
this.toolUsage.delete(connId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
trackAuthFailure(info = {}) {
|
|
52
|
+
this.totalAuthFailures++;
|
|
53
|
+
this.failedConnections++;
|
|
54
|
+
this.authFailures.push({ ...info, timestamp: Date.now() });
|
|
55
|
+
if (this.authFailures.length > 100) this.authFailures.shift();
|
|
56
|
+
this._addEvent({ type: 'connect', success: false, reason: 'auth_failure' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
trackToolCall(connId, toolName, success = true) {
|
|
60
|
+
const conn = this.activeConnections.get(connId);
|
|
61
|
+
if (conn) {
|
|
62
|
+
conn.lastActivity = Date.now();
|
|
63
|
+
conn.toolCalls[toolName] = (conn.toolCalls[toolName] || 0) + 1;
|
|
64
|
+
if (!success) conn.errors++;
|
|
65
|
+
}
|
|
66
|
+
const usage = this.toolUsage.get(connId) || {};
|
|
67
|
+
usage[toolName] = (usage[toolName] || 0) + 1;
|
|
68
|
+
this.toolUsage.set(connId, usage);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_addEvent(event) {
|
|
72
|
+
this.connectionEvents.push({ ...event, timestamp: Date.now() });
|
|
73
|
+
if (this.connectionEvents.length > 300) this.connectionEvents.shift();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getConnectionRate(windowSec = 300) {
|
|
77
|
+
const cutoff = Date.now() - windowSec * 1000;
|
|
78
|
+
const events = this.connectionEvents.filter(e => e.timestamp > cutoff);
|
|
79
|
+
const connects = events.filter(e => e.type === 'connect' && e.success).length;
|
|
80
|
+
const failures = events.filter(e => e.type === 'connect' && !e.success).length;
|
|
81
|
+
const disconnects = events.filter(e => e.type === 'disconnect').length;
|
|
82
|
+
const total = connects + failures;
|
|
83
|
+
return {
|
|
84
|
+
window_seconds: windowSec,
|
|
85
|
+
connects,
|
|
86
|
+
failures,
|
|
87
|
+
disconnects,
|
|
88
|
+
success_rate: total > 0 ? Math.round(connects / total * 100) : 100,
|
|
89
|
+
connections_per_minute: Math.round(connects / (windowSec / 60) * 10) / 10
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getMetrics() {
|
|
94
|
+
const last5min = this.getConnectionRate(300);
|
|
95
|
+
const last1min = this.getConnectionRate(60);
|
|
96
|
+
const recentAuthFailures = this.authFailures.filter(f => f.timestamp > Date.now() - 300000).length;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
active_connections: this.activeConnections.size,
|
|
100
|
+
total_connections: this.totalConnections,
|
|
101
|
+
successful_connections: this.successfulConnections,
|
|
102
|
+
failed_connections: this.failedConnections,
|
|
103
|
+
connection_rates: { last_1min: last1min, last_5min: last5min },
|
|
104
|
+
auth_failures: {
|
|
105
|
+
total: this.totalAuthFailures,
|
|
106
|
+
last_5min: recentAuthFailures
|
|
107
|
+
},
|
|
108
|
+
alerts: {
|
|
109
|
+
connection_surge: this.activeConnections.size > this.alertThreshold,
|
|
110
|
+
high_failure_rate: last5min.success_rate < 80,
|
|
111
|
+
auth_failure_surge: recentAuthFailures > 10
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getSummary() {
|
|
117
|
+
const rate = this.getConnectionRate(300);
|
|
118
|
+
const conns = [...this.activeConnections.entries()].slice(0, 10).map(([id, c]) => ({
|
|
119
|
+
id: id.substring(0, 8),
|
|
120
|
+
duration_seconds: Math.floor((Date.now() - c.connectedAt) / 1000),
|
|
121
|
+
tool_calls: Object.values(c.toolCalls).reduce((a, b) => a + b, 0),
|
|
122
|
+
errors: c.errors
|
|
123
|
+
}));
|
|
124
|
+
return {
|
|
125
|
+
active: this.activeConnections.size,
|
|
126
|
+
total: this.totalConnections,
|
|
127
|
+
success_rate: rate.success_rate,
|
|
128
|
+
connections: conns
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_checkAlerts() {
|
|
133
|
+
const metrics = this.getMetrics();
|
|
134
|
+
const now = new Date();
|
|
135
|
+
const dayKey = `${now.getFullYear()}${now.getMonth()}${now.getDate()}`;
|
|
136
|
+
const hourKey = `${dayKey}${now.getHours()}`;
|
|
137
|
+
|
|
138
|
+
if (metrics.alerts.connection_surge) {
|
|
139
|
+
const key = `surge_${dayKey}`;
|
|
140
|
+
if (!this.alertsSent.has(key)) {
|
|
141
|
+
this.alertsSent.add(key);
|
|
142
|
+
this._log('warning', `Connection surge: ${this.activeConnections.size} active`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (metrics.alerts.high_failure_rate) {
|
|
146
|
+
const key = `failure_${hourKey}`;
|
|
147
|
+
if (!this.alertsSent.has(key)) {
|
|
148
|
+
this.alertsSent.add(key);
|
|
149
|
+
this._log('critical', `High failure rate: ${metrics.connection_rates.last_5min.success_rate}%`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (metrics.alerts.auth_failure_surge) {
|
|
153
|
+
const key = `auth_${hourKey}`;
|
|
154
|
+
if (!this.alertsSent.has(key)) {
|
|
155
|
+
this.alertsSent.add(key);
|
|
156
|
+
this._log('critical', `Auth failure surge: ${metrics.auth_failures.last_5min} in 5min`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_log(severity, message) {
|
|
162
|
+
console.error(JSON.stringify({
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
164
|
+
level: severity === 'critical' ? 'ERROR' : 'WARN',
|
|
165
|
+
message: `[ConnectionMonitor] ${message}`,
|
|
166
|
+
severity,
|
|
167
|
+
metrics: this.getSummary()
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>pūrmemo — connect</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0a0a0a 100%);
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
padding: 16px;
|
|
17
|
+
}
|
|
18
|
+
.wrap {
|
|
19
|
+
width: 100%;
|
|
20
|
+
max-width: 24rem;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: 10px;
|
|
24
|
+
}
|
|
25
|
+
.tagline {
|
|
26
|
+
color: rgba(255,255,255,0.4);
|
|
27
|
+
font-size: 12px;
|
|
28
|
+
text-align: center;
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
letter-spacing: 0.01em;
|
|
31
|
+
}
|
|
32
|
+
.badge {
|
|
33
|
+
width: 100%;
|
|
34
|
+
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
|
|
35
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
36
|
+
border-radius: 12px;
|
|
37
|
+
height: 80px;
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
}
|
|
42
|
+
.badge img {
|
|
43
|
+
height: 56px;
|
|
44
|
+
width: auto;
|
|
45
|
+
mix-blend-mode: screen;
|
|
46
|
+
}
|
|
47
|
+
.card {
|
|
48
|
+
width: 100%;
|
|
49
|
+
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
|
|
50
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
51
|
+
border-radius: 12px;
|
|
52
|
+
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.8);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
}
|
|
55
|
+
.card-inner { padding: 28px 28px 24px; }
|
|
56
|
+
.step { display: none; flex-direction: column; gap: 20px; }
|
|
57
|
+
.step.active { display: flex; }
|
|
58
|
+
.heading { color: #fff; font-size: 1.125rem; font-weight: 700; text-align: center; }
|
|
59
|
+
.subheading { color: rgba(255,255,255,0.5); font-size: 13px; text-align: center; margin-top: 2px; }
|
|
60
|
+
.oauth-row { display: flex; flex-direction: column; gap: 10px; }
|
|
61
|
+
.oauth-btn {
|
|
62
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
63
|
+
padding: 11px 16px; border: 1px solid rgba(255,255,255,0.1);
|
|
64
|
+
border-radius: 8px; background: rgba(255,255,255,0.04);
|
|
65
|
+
color: #fff; font-size: 14px; font-weight: 500;
|
|
66
|
+
text-decoration: none; cursor: pointer;
|
|
67
|
+
transition: background 0.15s, border-color 0.15s;
|
|
68
|
+
}
|
|
69
|
+
.oauth-btn:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.2); }
|
|
70
|
+
.oauth-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
|
|
71
|
+
.divider { position: relative; text-align: center; }
|
|
72
|
+
.divider::before {
|
|
73
|
+
content: ''; position: absolute; top: 50%; left: 0; right: 0;
|
|
74
|
+
height: 1px; background: rgba(255,255,255,0.08);
|
|
75
|
+
}
|
|
76
|
+
.divider span {
|
|
77
|
+
position: relative; background: #141414; padding: 0 10px;
|
|
78
|
+
color: rgba(255,255,255,0.35); font-size: 12px;
|
|
79
|
+
}
|
|
80
|
+
input[type="email"], input[type="password"] {
|
|
81
|
+
width: 100%; padding: 12px 14px;
|
|
82
|
+
background: rgba(0,0,0,0.4); color: #fff;
|
|
83
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
84
|
+
border-radius: 8px; font-size: 15px;
|
|
85
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
86
|
+
outline: none;
|
|
87
|
+
-webkit-text-fill-color: #fff;
|
|
88
|
+
}
|
|
89
|
+
input:-webkit-autofill,
|
|
90
|
+
input:-webkit-autofill:hover,
|
|
91
|
+
input:-webkit-autofill:focus {
|
|
92
|
+
-webkit-box-shadow: 0 0 0 1000px #0d0d0d inset !important;
|
|
93
|
+
-webkit-text-fill-color: #fff !important;
|
|
94
|
+
border-color: rgba(255,255,255,0.15) !important;
|
|
95
|
+
}
|
|
96
|
+
input::placeholder { color: rgba(255,255,255,0.25); }
|
|
97
|
+
input:focus {
|
|
98
|
+
border-color: rgba(255,255,255,0.3);
|
|
99
|
+
box-shadow: 0 0 0 1px rgba(255,255,255,0.15);
|
|
100
|
+
}
|
|
101
|
+
.btn-primary {
|
|
102
|
+
width: 100%; padding: 12px;
|
|
103
|
+
background: #fff; color: #000;
|
|
104
|
+
border: none; border-radius: 8px;
|
|
105
|
+
font-size: 14px; font-weight: 600;
|
|
106
|
+
cursor: pointer; transition: opacity 0.15s;
|
|
107
|
+
}
|
|
108
|
+
.btn-primary:hover { opacity: 0.9; }
|
|
109
|
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
110
|
+
.back-link { text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }
|
|
111
|
+
.back-link a { color: rgba(255,255,255,0.7); text-decoration: none; font-weight: 500; cursor: pointer; }
|
|
112
|
+
.back-link a:hover { color: #fff; }
|
|
113
|
+
.error-box {
|
|
114
|
+
background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.25);
|
|
115
|
+
border-radius: 8px; padding: 10px 12px;
|
|
116
|
+
color: #fca5a5; font-size: 13px; display: none;
|
|
117
|
+
}
|
|
118
|
+
.success-banner {
|
|
119
|
+
background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2);
|
|
120
|
+
border-radius: 8px; padding: 10px 12px;
|
|
121
|
+
color: #86efac; font-size: 13px; text-align: center;
|
|
122
|
+
}
|
|
123
|
+
.terms {
|
|
124
|
+
font-size: 11px; color: rgba(255,255,255,0.3); text-align: center; line-height: 1.5;
|
|
125
|
+
}
|
|
126
|
+
.terms a { color: rgba(255,255,255,0.5); text-decoration: none; }
|
|
127
|
+
.terms a:hover { color: #fff; }
|
|
128
|
+
.field-group { display: flex; flex-direction: column; gap: 12px; }
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<div class="wrap">
|
|
133
|
+
<div class="badge">
|
|
134
|
+
<img src="https://app.purmemo.ai/purmemo-logo-3d.png" alt="pūrmemo">
|
|
135
|
+
</div>
|
|
136
|
+
<p class="tagline">Bridge all your AI conversations to<br>build your personal AI intelligence.</p>
|
|
137
|
+
<div class="card">
|
|
138
|
+
<div class="card-inner">
|
|
139
|
+
<!-- SIGNUP_BANNER -->
|
|
140
|
+
<div class="error-box" id="err"></div>
|
|
141
|
+
|
|
142
|
+
<!-- STEP: entry -->
|
|
143
|
+
<div class="step active" id="step-entry">
|
|
144
|
+
<div>
|
|
145
|
+
<div class="heading">Connect pūrmemo</div>
|
|
146
|
+
<div class="subheading">Sign in or create your account</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="oauth-row">
|
|
149
|
+
<a href="/oauth/google/login?params=<!-- PARAMS -->" class="oauth-btn">
|
|
150
|
+
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
|
151
|
+
Continue with Google
|
|
152
|
+
</a>
|
|
153
|
+
<a href="/oauth/github/login?params=<!-- PARAMS -->" class="oauth-btn">
|
|
154
|
+
<svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/></svg>
|
|
155
|
+
Continue with GitHub
|
|
156
|
+
</a>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="divider"><span>or continue with email</span></div>
|
|
159
|
+
<input type="email" id="entry-email" placeholder="your@email.com" autocomplete="email">
|
|
160
|
+
<button class="btn-primary" id="entry-continue-btn" onclick="handleEmailContinue()">Continue</button>
|
|
161
|
+
<div class="terms">By continuing, you agree to our <a href="https://app.purmemo.ai/terms" target="_blank">Terms</a> and <a href="https://app.purmemo.ai/privacy" target="_blank">Privacy Policy</a>.</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- STEP: password (existing user) -->
|
|
165
|
+
<div class="step" id="step-password">
|
|
166
|
+
<div>
|
|
167
|
+
<div class="heading">Welcome back</div>
|
|
168
|
+
<div class="subheading" id="password-email-label"></div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="field-group">
|
|
171
|
+
<input type="password" id="pw-input" placeholder="Password" autocomplete="current-password">
|
|
172
|
+
<button class="btn-primary" id="pw-signin-btn" onclick="handleSignIn()">Sign in</button>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="back-link"><a onclick="goBack()">← Use a different email</a></div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<!-- STEP: register (new user) -->
|
|
178
|
+
<div class="step" id="step-register">
|
|
179
|
+
<div>
|
|
180
|
+
<div class="heading">Create your account</div>
|
|
181
|
+
<div class="subheading" id="register-email-label"></div>
|
|
182
|
+
</div>
|
|
183
|
+
<div class="field-group">
|
|
184
|
+
<input type="password" id="reg-pw-input" placeholder="Choose a password (min 8 chars)" autocomplete="new-password">
|
|
185
|
+
<button class="btn-primary" id="reg-btn" onclick="handleRegister()">Create account</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="back-link"><a onclick="goBack()">← Use a different email</a></div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Hidden forms for POST (preserves PKCE flow) -->
|
|
195
|
+
<form method="POST" action="/login" id="login-form" style="display:none">
|
|
196
|
+
<input type="hidden" name="params" value="<!-- PARAMS -->">
|
|
197
|
+
<input type="hidden" name="email" id="form-email">
|
|
198
|
+
<input type="hidden" name="password" id="form-password">
|
|
199
|
+
</form>
|
|
200
|
+
<form method="POST" action="/register" id="register-form" style="display:none">
|
|
201
|
+
<input type="hidden" name="params" value="<!-- PARAMS -->">
|
|
202
|
+
<input type="hidden" name="email" id="reg-form-email">
|
|
203
|
+
<input type="hidden" name="password" id="reg-form-password">
|
|
204
|
+
</form>
|
|
205
|
+
|
|
206
|
+
<script>
|
|
207
|
+
var currentEmail = '';
|
|
208
|
+
|
|
209
|
+
function showStep(id) {
|
|
210
|
+
document.querySelectorAll('.step').forEach(function(el) { el.classList.remove('active'); });
|
|
211
|
+
document.getElementById(id).classList.add('active');
|
|
212
|
+
clearError();
|
|
213
|
+
}
|
|
214
|
+
function showError(msg) {
|
|
215
|
+
var el = document.getElementById('err');
|
|
216
|
+
el.textContent = msg; el.style.display = 'block';
|
|
217
|
+
}
|
|
218
|
+
function clearError() {
|
|
219
|
+
var el = document.getElementById('err');
|
|
220
|
+
el.style.display = 'none'; el.textContent = '';
|
|
221
|
+
}
|
|
222
|
+
function goBack() {
|
|
223
|
+
showStep('step-entry');
|
|
224
|
+
document.getElementById('entry-email').focus();
|
|
225
|
+
}
|
|
226
|
+
function setLoading(btnId, loading, defaultText) {
|
|
227
|
+
var btn = document.getElementById(btnId);
|
|
228
|
+
btn.disabled = loading;
|
|
229
|
+
btn.textContent = loading ? 'Please wait...' : defaultText;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleEmailContinue() {
|
|
233
|
+
var email = document.getElementById('entry-email').value.trim();
|
|
234
|
+
if (!email) { document.getElementById('entry-email').focus(); return; }
|
|
235
|
+
currentEmail = email;
|
|
236
|
+
setLoading('entry-continue-btn', true, 'Continue');
|
|
237
|
+
clearError();
|
|
238
|
+
try {
|
|
239
|
+
var res = await fetch('/check-email', {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: {'Content-Type': 'application/json'},
|
|
242
|
+
body: JSON.stringify({email: email})
|
|
243
|
+
});
|
|
244
|
+
var data = await res.json();
|
|
245
|
+
if (data.exists) {
|
|
246
|
+
document.getElementById('password-email-label').textContent = email;
|
|
247
|
+
showStep('step-password');
|
|
248
|
+
setTimeout(function() { document.getElementById('pw-input').focus(); }, 50);
|
|
249
|
+
} else {
|
|
250
|
+
document.getElementById('register-email-label').textContent = email;
|
|
251
|
+
showStep('step-register');
|
|
252
|
+
setTimeout(function() { document.getElementById('reg-pw-input').focus(); }, 50);
|
|
253
|
+
}
|
|
254
|
+
} catch(e) {
|
|
255
|
+
showError('Unable to reach server. Please try again.');
|
|
256
|
+
} finally {
|
|
257
|
+
setLoading('entry-continue-btn', false, 'Continue');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function handleSignIn() {
|
|
262
|
+
var password = document.getElementById('pw-input').value;
|
|
263
|
+
if (!password) { document.getElementById('pw-input').focus(); return; }
|
|
264
|
+
setLoading('pw-signin-btn', true, 'Sign in');
|
|
265
|
+
clearError();
|
|
266
|
+
document.getElementById('form-email').value = currentEmail;
|
|
267
|
+
document.getElementById('form-password').value = password;
|
|
268
|
+
document.getElementById('login-form').submit();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleRegister() {
|
|
272
|
+
var password = document.getElementById('reg-pw-input').value;
|
|
273
|
+
if (!password || password.length < 8) {
|
|
274
|
+
showError('Password must be at least 8 characters.');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
setLoading('reg-btn', true, 'Create account');
|
|
278
|
+
clearError();
|
|
279
|
+
document.getElementById('reg-form-email').value = currentEmail;
|
|
280
|
+
document.getElementById('reg-form-password').value = password;
|
|
281
|
+
document.getElementById('register-form').submit();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
document.getElementById('entry-email').addEventListener('keydown', function(e) {
|
|
285
|
+
if (e.key === 'Enter') handleEmailContinue();
|
|
286
|
+
});
|
|
287
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
288
|
+
var pw = document.getElementById('pw-input');
|
|
289
|
+
var rg = document.getElementById('reg-pw-input');
|
|
290
|
+
if (pw) pw.addEventListener('keydown', function(e) { if (e.key === 'Enter') handleSignIn(); });
|
|
291
|
+
if (rg) rg.addEventListener('keydown', function(e) { if (e.key === 'Enter') handleRegister(); });
|
|
292
|
+
});
|
|
293
|
+
</script>
|
|
294
|
+
</body>
|
|
295
|
+
</html>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified OAuth implementation — in-memory, no database
|
|
3
|
+
* Port of Python oauth_simple.py
|
|
4
|
+
*
|
|
5
|
+
* Stores auth codes in memory. Single-instance only (codes lost on restart).
|
|
6
|
+
* This is fine because the OAuth flow completes in seconds — if the server
|
|
7
|
+
* restarts mid-flow, the user just re-authenticates.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
// In-memory storage
|
|
13
|
+
const oauthCodes = new Map();
|
|
14
|
+
|
|
15
|
+
/** Remove expired codes */
|
|
16
|
+
function cleanupExpired() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
for (const [code, data] of oauthCodes) {
|
|
19
|
+
if (data.expiresAt < now) oauthCodes.delete(code);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Generate a secure authorization code */
|
|
24
|
+
export function generateCode() {
|
|
25
|
+
return `code_${crypto.randomBytes(16).toString('base64url')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Verify PKCE code challenge (S256 or plain) */
|
|
29
|
+
export function verifyCodeChallenge(verifier, challenge, method = 'S256') {
|
|
30
|
+
if (method === 'plain') return verifier === challenge;
|
|
31
|
+
if (method === 'S256') {
|
|
32
|
+
const digest = crypto.createHash('sha256').update(verifier, 'utf8').digest();
|
|
33
|
+
const computed = digest.toString('base64url');
|
|
34
|
+
// Compare with and without trailing '=' padding
|
|
35
|
+
return computed === challenge || computed.replace(/=+$/, '') === challenge.replace(/=+$/, '');
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Store authorization code with associated API key and OAuth params */
|
|
41
|
+
export function storeAuthCode({
|
|
42
|
+
code, apiKey, clientId, redirectUri,
|
|
43
|
+
codeChallenge, codeChallengeMethod,
|
|
44
|
+
scope = null, state = null, refreshToken = null
|
|
45
|
+
}) {
|
|
46
|
+
cleanupExpired();
|
|
47
|
+
oauthCodes.set(code, {
|
|
48
|
+
apiKey,
|
|
49
|
+
refreshToken,
|
|
50
|
+
clientId,
|
|
51
|
+
redirectUri,
|
|
52
|
+
codeChallenge,
|
|
53
|
+
codeChallengeMethod,
|
|
54
|
+
scope,
|
|
55
|
+
state,
|
|
56
|
+
createdAt: Date.now(),
|
|
57
|
+
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
|
58
|
+
used: false
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Exchange authorization code for access token
|
|
64
|
+
* Returns [apiKey, refreshToken] or null if invalid
|
|
65
|
+
*/
|
|
66
|
+
export function exchangeCodeForToken({ code, clientId, redirectUri, codeVerifier }) {
|
|
67
|
+
cleanupExpired();
|
|
68
|
+
|
|
69
|
+
const data = oauthCodes.get(code);
|
|
70
|
+
if (!data) return null;
|
|
71
|
+
if (data.expiresAt < Date.now()) { oauthCodes.delete(code); return null; }
|
|
72
|
+
if (data.used) { oauthCodes.delete(code); return null; }
|
|
73
|
+
if (clientId && clientId !== data.clientId) return null;
|
|
74
|
+
if (redirectUri !== data.redirectUri) return null;
|
|
75
|
+
if (!verifyCodeChallenge(codeVerifier, data.codeChallenge, data.codeChallengeMethod)) return null;
|
|
76
|
+
|
|
77
|
+
// Mark used and delete
|
|
78
|
+
data.used = true;
|
|
79
|
+
const apiKey = data.apiKey;
|
|
80
|
+
const refreshToken = data.refreshToken;
|
|
81
|
+
oauthCodes.delete(code);
|
|
82
|
+
|
|
83
|
+
return [apiKey, refreshToken];
|
|
84
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Authentication Successful - pūrmemo</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
11
|
+
background: #0a0a0a;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
padding: 20px;
|
|
17
|
+
}
|
|
18
|
+
.container {
|
|
19
|
+
background: rgba(18, 18, 18, 0.95);
|
|
20
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
21
|
+
border-radius: 20px;
|
|
22
|
+
padding: 40px;
|
|
23
|
+
width: 100%;
|
|
24
|
+
max-width: 420px;
|
|
25
|
+
text-align: center;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 20px;
|
|
30
|
+
animation: fadeUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
|
|
31
|
+
}
|
|
32
|
+
@keyframes fadeUp {
|
|
33
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
34
|
+
to { opacity: 1; transform: translateY(0); }
|
|
35
|
+
}
|
|
36
|
+
.ring-wrap { position: relative; width: 64px; height: 64px; }
|
|
37
|
+
.ring-svg { position: absolute; inset: 0; transform: rotate(-90deg); overflow: visible; }
|
|
38
|
+
.ring-track { fill: none; stroke: rgba(16, 185, 129, 0.12); stroke-width: 2.5; }
|
|
39
|
+
.ring-arc {
|
|
40
|
+
fill: none; stroke: #10b981; stroke-width: 2.5;
|
|
41
|
+
stroke-linecap: round; stroke-dasharray: 182.2; stroke-dashoffset: 182.2;
|
|
42
|
+
transition: stroke-dashoffset 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
|
43
|
+
}
|
|
44
|
+
.ring-arc.complete { stroke-dashoffset: 0; }
|
|
45
|
+
.check-inner {
|
|
46
|
+
position: absolute; inset: 0; display: flex;
|
|
47
|
+
align-items: center; justify-content: center;
|
|
48
|
+
opacity: 0; transform: scale(0.6);
|
|
49
|
+
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
50
|
+
}
|
|
51
|
+
.check-inner.visible { opacity: 1; transform: scale(1); }
|
|
52
|
+
.check-inner svg { width: 22px; height: 22px; color: #10b981; }
|
|
53
|
+
h1 {
|
|
54
|
+
font-size: 17px; font-weight: 700; color: #ffffff;
|
|
55
|
+
letter-spacing: -0.02em; opacity: 0; transform: translateY(6px);
|
|
56
|
+
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
57
|
+
}
|
|
58
|
+
h1.visible { opacity: 1; transform: translateY(0); }
|
|
59
|
+
.status { color: rgba(255, 255, 255, 0.4); font-size: 13px; opacity: 0; transition: opacity 0.4s ease 0.1s; }
|
|
60
|
+
.status.visible { opacity: 1; }
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div class="container">
|
|
65
|
+
<div class="ring-wrap">
|
|
66
|
+
<svg class="ring-svg" viewBox="0 0 64 64" width="64" height="64">
|
|
67
|
+
<circle class="ring-track" cx="32" cy="32" r="29"/>
|
|
68
|
+
<circle class="ring-arc" id="ringArc" cx="32" cy="32" r="29"/>
|
|
69
|
+
</svg>
|
|
70
|
+
<div class="check-inner" id="checkInner">
|
|
71
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
72
|
+
<polyline points="20 6 9 17 4 12"/>
|
|
73
|
+
</svg>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<h1 id="heading">Connected.</h1>
|
|
77
|
+
<p class="status" id="status">Returning to Claude...</p>
|
|
78
|
+
<p class="status" id="close-hint">You can close this tab.</p>
|
|
79
|
+
</div>
|
|
80
|
+
<script>
|
|
81
|
+
setTimeout(function() { document.getElementById('ringArc').classList.add('complete'); }, 30);
|
|
82
|
+
setTimeout(function() { document.getElementById('checkInner').classList.add('visible'); }, 500);
|
|
83
|
+
setTimeout(function() {
|
|
84
|
+
document.getElementById('heading').classList.add('visible');
|
|
85
|
+
document.getElementById('status').classList.add('visible');
|
|
86
|
+
}, 650);
|
|
87
|
+
setTimeout(function() { window.location.href = "<!-- REDIRECT_URL -->"; }, 1200);
|
|
88
|
+
setTimeout(function() { document.getElementById('close-hint').classList.add('visible'); }, 2500);
|
|
89
|
+
</script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|