@wipcomputer/wip-ldm-os 0.4.73-alpha.9 → 0.4.74
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/LICENSE +52 -0
- package/SKILL.md +8 -1
- package/bin/ldm.js +587 -82
- package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
- package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
- package/dist/bridge/cli.js +2 -1
- package/dist/bridge/core.d.ts +13 -1
- package/dist/bridge/core.js +4 -1
- package/dist/bridge/mcp-server.js +52 -7
- package/dist/bridge/openclaw.d.ts +5 -0
- package/dist/bridge/openclaw.js +11 -0
- package/docs/bridge/TECHNICAL.md +86 -0
- package/docs/doc-pipeline/README.md +74 -0
- package/docs/doc-pipeline/TECHNICAL.md +79 -0
- package/lib/deploy.mjs +175 -13
- package/lib/detect.mjs +20 -6
- package/package.json +2 -2
- package/shared/docs/README.md.tmpl +2 -2
- package/shared/docs/how-releases-work.md.tmpl +3 -1
- package/shared/docs/how-worktrees-work.md.tmpl +12 -7
- package/shared/rules/git-conventions.md +3 -3
- package/shared/rules/release-pipeline.md +1 -1
- package/shared/rules/security.md +1 -1
- package/shared/rules/workspace-boundaries.md +1 -1
- package/shared/rules/writing-style.md +1 -1
- package/shared/templates/claude-md-level1.md +7 -3
- package/src/bridge/core.ts +160 -56
- package/src/bridge/mcp-server.ts +93 -8
- package/src/bridge/openclaw.ts +14 -0
- package/src/hooks/inbox-check-hook.mjs +232 -0
- package/src/hooks/inbox-rewake-hook.mjs +388 -0
- package/src/hosted-mcp/.env.example +3 -0
- package/src/hosted-mcp/demo/agent.html +300 -0
- package/src/hosted-mcp/demo/agent.txt +84 -0
- package/src/hosted-mcp/demo/fallback.jpg +0 -0
- package/src/hosted-mcp/demo/footer.js +74 -0
- package/src/hosted-mcp/demo/index.html +1303 -0
- package/src/hosted-mcp/demo/login.html +548 -0
- package/src/hosted-mcp/demo/privacy.html +223 -0
- package/src/hosted-mcp/demo/sprites.jpg +0 -0
- package/src/hosted-mcp/demo/sprites.png +0 -0
- package/src/hosted-mcp/demo/tos.html +198 -0
- package/src/hosted-mcp/deploy.sh +70 -0
- package/src/hosted-mcp/ecosystem.config.cjs +14 -0
- package/src/hosted-mcp/inbox.mjs +64 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
- package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
- package/src/hosted-mcp/package-lock.json +2092 -0
- package/src/hosted-mcp/package.json +23 -0
- package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
- package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
- package/src/hosted-mcp/prisma/schema.prisma +57 -0
- package/src/hosted-mcp/prisma.config.ts +14 -0
- package/src/hosted-mcp/server.mjs +2093 -0
- package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
- package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
- package/src/hosted-mcp/tools.mjs +73 -0
- package/templates/hooks/pre-commit +5 -0
|
@@ -0,0 +1,548 @@
|
|
|
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, viewport-fit=cover">
|
|
6
|
+
<title>Kaleidoscope</title>
|
|
7
|
+
<meta name="description" content="Kaleidoscope by WIP Computer, Inc. Every AI. One experience.">
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #FFFDF5;
|
|
13
|
+
--text: #1a1a1a;
|
|
14
|
+
--text-muted: #8a8580;
|
|
15
|
+
--accent: #0033FF;
|
|
16
|
+
--accent-hover: #0033FF;
|
|
17
|
+
--input-bg: #F5F3ED;
|
|
18
|
+
--input-border: #E0DDD6;
|
|
19
|
+
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html, body {
|
|
23
|
+
height: 100%;
|
|
24
|
+
font-family: var(--font);
|
|
25
|
+
background: var(--bg);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
-webkit-text-size-adjust: 100%;
|
|
28
|
+
-webkit-font-smoothing: antialiased;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@media (min-width: 768px) {
|
|
32
|
+
html, body { overflow: hidden; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.login-page {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
min-height: 100vh;
|
|
41
|
+
min-height: 100dvh;
|
|
42
|
+
padding: 24px;
|
|
43
|
+
padding-top: calc(24px + env(safe-area-inset-top, 0px));
|
|
44
|
+
overflow-y: auto;
|
|
45
|
+
-webkit-overflow-scrolling: touch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.login-card {
|
|
49
|
+
position: relative;
|
|
50
|
+
max-width: 380px;
|
|
51
|
+
width: 100%;
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.login-title {
|
|
56
|
+
font-size: 26px;
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
letter-spacing: -0.02em;
|
|
59
|
+
margin-bottom: 8px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.login-buttons {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: 12px;
|
|
66
|
+
margin-bottom: 16px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.btn {
|
|
70
|
+
display: block;
|
|
71
|
+
width: 100%;
|
|
72
|
+
padding: 18px;
|
|
73
|
+
border: none;
|
|
74
|
+
border-radius: 12px;
|
|
75
|
+
font-size: 18px;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
font-family: var(--font);
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
transition: background 0.15s, transform 0.1s;
|
|
80
|
+
-webkit-tap-highlight-color: transparent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.btn:active {
|
|
84
|
+
transform: scale(0.98);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.btn-primary {
|
|
88
|
+
background: var(--accent);
|
|
89
|
+
color: white;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.btn-primary:hover {
|
|
93
|
+
background: var(--accent-hover);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.btn:disabled {
|
|
97
|
+
opacity: 0.5;
|
|
98
|
+
cursor: not-allowed;
|
|
99
|
+
transform: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.login-status {
|
|
103
|
+
margin-top: 16px;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
padding: 12px 16px;
|
|
106
|
+
border-radius: 10px;
|
|
107
|
+
display: none;
|
|
108
|
+
text-align: left;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.login-status.show { display: block; }
|
|
112
|
+
.login-status.loading { background: #E8EEFF; color: var(--accent); }
|
|
113
|
+
.login-status.error { background: #FFF0F0; color: #D32F2F; }
|
|
114
|
+
.login-status.success { background: #F0FFF4; color: #2E7D32; }
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
|
|
119
|
+
<div class="login-page" id="loginPage">
|
|
120
|
+
<div class="login-card">
|
|
121
|
+
<div style="display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:8px;margin-left:-6px;"><span id="loginIcon" style="width:34px;height:34px;flex-shrink:0;overflow:hidden;"></span><h1 class="login-title" style="margin-bottom:0;">Kaleidoscope</h1></div>
|
|
122
|
+
<p style="color:#8a8580;font-size:16px;margin:0 0 32px 0;letter-spacing:0.2px;">Every AI. One experience.</p>
|
|
123
|
+
|
|
124
|
+
<!-- Signup view (default) -->
|
|
125
|
+
<div id="signup-view">
|
|
126
|
+
<div class="login-buttons">
|
|
127
|
+
<button class="btn btn-primary" id="createBtn" onclick="doCreateAccount()">Enter the Kaleidoscope</button>
|
|
128
|
+
</div>
|
|
129
|
+
<div style="margin-top:12px;">
|
|
130
|
+
<input type="text" id="handleInput" name="kaleidoscope-handle" placeholder="What should Lēsa call you? (optional)" autocapitalize="none" autocorrect="off" autocomplete="off" spellcheck="false" data-1p-ignore="true" data-lpignore="true" onfocus="setTimeout(function(){document.getElementById('handleInput').scrollIntoView({behavior:'smooth',block:'center'})},300)" style="width:100%;padding:16px 18px;border:1px solid #E0DDD6;border-radius:12px;font-size:18px;font-family:var(--font);background:#F5F3ED;color:#1a1a1a;outline:none;text-align:center;" />
|
|
131
|
+
</div>
|
|
132
|
+
<p style="color:#b0aaa4;font-size:13px;font-style:italic;margin:16px 0 0;text-align:center;opacity:0.8;">Use your phone to securely create your account</p>
|
|
133
|
+
<div style="margin-top:12px;text-align:center;">
|
|
134
|
+
<a id="signInBtn" onclick="doSignIn()" style="color:var(--accent);font-size:16px;cursor:pointer;text-decoration:none;">Already have an account? Sign in.</a>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<!-- QR code view (Chrome desktop fallback) -->
|
|
139
|
+
<div id="qr-view" style="display:none">
|
|
140
|
+
<p style="color:#8a8580;font-size:16px;margin:0 0 24px 0;">Scan with your phone to continue.</p>
|
|
141
|
+
<div style="display:flex;justify-content:center;margin-bottom:24px;">
|
|
142
|
+
<img id="qr-image" style="width:200px;height:200px;border-radius:12px;background:#F5F3ED;" />
|
|
143
|
+
</div>
|
|
144
|
+
<p id="qr-status-text" style="color:var(--accent);font-size:14px;">Waiting for phone...</p>
|
|
145
|
+
<div style="margin-top:20px;">
|
|
146
|
+
<a onclick="cancelQrLogin()" style="color:var(--text-muted);font-size:14px;cursor:pointer;text-decoration:none;">Cancel</a>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!-- Success view -->
|
|
151
|
+
<div id="success-view" style="display:none">
|
|
152
|
+
<p style="font-size:18px;margin-bottom:12px;color:var(--text);">Welcome, <span id="welcome-name"></span>.</p>
|
|
153
|
+
<p style="color:var(--text-muted);font-size:15px;margin-bottom:8px;">Your passkey has been saved to your phone.</p>
|
|
154
|
+
<p style="color:var(--text-muted);font-size:15px;margin-bottom:24px;">You can use it to sign in to any WIP Computer service.</p>
|
|
155
|
+
<div class="login-buttons">
|
|
156
|
+
<a href="/demo/" onclick="sessionStorage.clear();" class="btn btn-primary" style="text-decoration:none;text-align:center;">Try the Demo</a>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div id="kscope-footer"></div>
|
|
164
|
+
|
|
165
|
+
<script src="/demo/footer.js"></script>
|
|
166
|
+
<script>
|
|
167
|
+
// ── Random kaleidoscope icon from sprite sheet ──
|
|
168
|
+
var SPRITE_COLS = 8;
|
|
169
|
+
var SPRITE_ROWS = 3;
|
|
170
|
+
var SPRITE_TOTAL = SPRITE_COLS * SPRITE_ROWS;
|
|
171
|
+
|
|
172
|
+
function makeIconHTML(size) {
|
|
173
|
+
var idx = Math.floor(Math.random() * SPRITE_TOTAL);
|
|
174
|
+
var col = idx % SPRITE_COLS;
|
|
175
|
+
var row = Math.floor(idx / SPRITE_COLS);
|
|
176
|
+
var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
|
|
177
|
+
var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
|
|
178
|
+
return '<div style="width:' + size + 'px;height:' + size + 'px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bgPosX + '% ' + bgPosY + '%;"></div></div>';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
var loginIcon = document.getElementById('loginIcon');
|
|
182
|
+
if (loginIcon) loginIcon.innerHTML = makeIconHTML(34);
|
|
183
|
+
|
|
184
|
+
// Rotate icon every 3s
|
|
185
|
+
var loginRotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);
|
|
186
|
+
setInterval(function() {
|
|
187
|
+
loginRotateIdx = (loginRotateIdx + 1) % SPRITE_TOTAL;
|
|
188
|
+
var col = loginRotateIdx % SPRITE_COLS;
|
|
189
|
+
var row = Math.floor(loginRotateIdx / SPRITE_COLS);
|
|
190
|
+
var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
|
|
191
|
+
var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
|
|
192
|
+
if (loginIcon) loginIcon.innerHTML = '<div style="width:34px;height:34px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bgPosX + '% ' + bgPosY + '%;"></div></div>';
|
|
193
|
+
}, 3000);
|
|
194
|
+
|
|
195
|
+
// ── WebAuthn helpers ──
|
|
196
|
+
function b64urlToBytes(b64) {
|
|
197
|
+
var bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - b64.length % 4) % 4));
|
|
198
|
+
return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
|
|
199
|
+
}
|
|
200
|
+
function bytesToB64url(buf) {
|
|
201
|
+
return btoa(String.fromCharCode.apply(null, new Uint8Array(buf)))
|
|
202
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function setStatus(msg, type) {
|
|
206
|
+
var el = document.getElementById('loginStatus');
|
|
207
|
+
el.style.transition = '';
|
|
208
|
+
el.style.opacity = '';
|
|
209
|
+
el.textContent = msg;
|
|
210
|
+
el.className = msg ? 'login-status show ' + type : 'login-status';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function clearStatus() {
|
|
214
|
+
var el = document.getElementById('loginStatus');
|
|
215
|
+
el.style.transition = 'opacity 0.5s';
|
|
216
|
+
el.style.opacity = '0';
|
|
217
|
+
setTimeout(function() { el.className = 'login-status'; el.style.transition = ''; el.style.opacity = ''; }, 500);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Browser detection ──
|
|
221
|
+
function isMobileDevice() {
|
|
222
|
+
return navigator.maxTouchPoints > 0 && window.innerWidth < 768;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isSafariDesktop() {
|
|
226
|
+
if (isMobileDevice()) return false;
|
|
227
|
+
return /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function needsCustomQR() {
|
|
231
|
+
return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Local passkeys toggle (persisted in localStorage) ──
|
|
235
|
+
function isLocalPasskeysOn() {
|
|
236
|
+
return localStorage.getItem('localPasskeys') === 'on';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function toggleLocalPasskeys() {
|
|
240
|
+
var on = isLocalPasskeysOn();
|
|
241
|
+
localStorage.setItem('localPasskeys', on ? 'off' : 'on');
|
|
242
|
+
updatePasskeysDot();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function updatePasskeysDot() {
|
|
246
|
+
var dot = document.getElementById('passkeys-dot');
|
|
247
|
+
var label = document.getElementById('passkeys-label');
|
|
248
|
+
if (!dot) return;
|
|
249
|
+
if (isLocalPasskeysOn()) {
|
|
250
|
+
dot.style.background = '#2E7D32';
|
|
251
|
+
dot.style.opacity = '1';
|
|
252
|
+
if (label) label.textContent = 'Local passkeys on';
|
|
253
|
+
} else {
|
|
254
|
+
dot.style.background = '#D32F2F';
|
|
255
|
+
dot.style.opacity = '0.4';
|
|
256
|
+
if (label) label.textContent = 'Local passkeys off';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── QR Login (Chrome desktop fallback) ──
|
|
261
|
+
var qrLoginPollTimer = null;
|
|
262
|
+
|
|
263
|
+
async function startQrLogin(handle, mode) {
|
|
264
|
+
var btn = document.getElementById('createBtn');
|
|
265
|
+
btn.disabled = true;
|
|
266
|
+
setStatus('', '');
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
var res = await fetch('/api/qr-login', {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({ handle: handle || undefined, mode: mode || 'register' }),
|
|
273
|
+
});
|
|
274
|
+
var data = await res.json();
|
|
275
|
+
|
|
276
|
+
// Switch to QR view
|
|
277
|
+
document.getElementById('signup-view').style.display = 'none';
|
|
278
|
+
document.getElementById('qr-view').style.display = 'block';
|
|
279
|
+
document.getElementById('qr-image').src = data.qrUrl;
|
|
280
|
+
|
|
281
|
+
// Start polling
|
|
282
|
+
qrLoginPollTimer = setInterval(function() {
|
|
283
|
+
pollQrLogin(data.sessionId);
|
|
284
|
+
}, 2000);
|
|
285
|
+
|
|
286
|
+
// Expire after 5 minutes
|
|
287
|
+
setTimeout(function() {
|
|
288
|
+
if (qrLoginPollTimer) {
|
|
289
|
+
cancelQrLogin();
|
|
290
|
+
setStatus('Session expired. Try again.', 'error');
|
|
291
|
+
setTimeout(clearStatus, 3000);
|
|
292
|
+
}
|
|
293
|
+
}, 5 * 60 * 1000);
|
|
294
|
+
|
|
295
|
+
} catch (err) {
|
|
296
|
+
setStatus('Error: ' + err.message, 'error');
|
|
297
|
+
btn.disabled = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function pollQrLogin(sessionId) {
|
|
302
|
+
try {
|
|
303
|
+
var res = await fetch('/api/qr-login/status?s=' + sessionId);
|
|
304
|
+
if (res.status === 404) {
|
|
305
|
+
// Session expired on server
|
|
306
|
+
cancelQrLogin();
|
|
307
|
+
setStatus('Session expired. Try again.', 'error');
|
|
308
|
+
setTimeout(clearStatus, 3000);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
var data = await res.json();
|
|
312
|
+
if (data.status === 'approved') {
|
|
313
|
+
clearInterval(qrLoginPollTimer);
|
|
314
|
+
qrLoginPollTimer = null;
|
|
315
|
+
document.getElementById('qr-view').style.display = 'none';
|
|
316
|
+
document.getElementById('success-view').style.display = 'block';
|
|
317
|
+
document.getElementById('welcome-name').textContent = data.agentId || 'you';
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
// Network error, keep polling
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function cancelQrLogin() {
|
|
325
|
+
if (qrLoginPollTimer) {
|
|
326
|
+
clearInterval(qrLoginPollTimer);
|
|
327
|
+
qrLoginPollTimer = null;
|
|
328
|
+
}
|
|
329
|
+
document.getElementById('qr-view').style.display = 'none';
|
|
330
|
+
document.getElementById('signup-view').style.display = 'block';
|
|
331
|
+
document.getElementById('createBtn').disabled = false;
|
|
332
|
+
document.getElementById('signInBtn').style.pointerEvents = 'auto';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Phone-side QR session detection ──
|
|
336
|
+
var urlParams = new URLSearchParams(window.location.search);
|
|
337
|
+
var qrSessionId = urlParams.get('s');
|
|
338
|
+
var qrHandle = urlParams.get('h');
|
|
339
|
+
var qrMode = urlParams.get('m') || 'register';
|
|
340
|
+
var qrSessionMode = !!qrSessionId;
|
|
341
|
+
|
|
342
|
+
if (qrSessionMode) {
|
|
343
|
+
// Clean the URL so session ID doesn't persist
|
|
344
|
+
history.replaceState(null, '', '/login');
|
|
345
|
+
// Pre-fill handle if provided
|
|
346
|
+
if (qrHandle) document.getElementById('handleInput').value = qrHandle;
|
|
347
|
+
// Auto-start the right flow on phone (no extra tap needed)
|
|
348
|
+
setTimeout(function() {
|
|
349
|
+
if (qrMode === 'signin') {
|
|
350
|
+
doSignIn();
|
|
351
|
+
} else {
|
|
352
|
+
doCreateAccount();
|
|
353
|
+
}
|
|
354
|
+
}, 300);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Create Account ──
|
|
358
|
+
async function doCreateAccount() {
|
|
359
|
+
var username = (document.getElementById('handleInput').value || '').trim().replace(/^@/, '').toLowerCase().replace(/[^a-z0-9\-]/g, '').slice(0, 30);
|
|
360
|
+
var btn = document.getElementById('createBtn');
|
|
361
|
+
btn.disabled = true;
|
|
362
|
+
|
|
363
|
+
// Chrome desktop without local passkeys: use custom QR code
|
|
364
|
+
if (needsCustomQR() && !qrSessionMode) {
|
|
365
|
+
startQrLogin(username);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
setStatus('Preparing...', 'loading');
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
var optRes = await fetch('/webauthn/register-options', {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: { 'Content-Type': 'application/json' },
|
|
375
|
+
body: JSON.stringify({ username: username || undefined }),
|
|
376
|
+
});
|
|
377
|
+
var optData = await optRes.json();
|
|
378
|
+
var challengeId = optData.challengeId;
|
|
379
|
+
var options = optData.options;
|
|
380
|
+
if (!options) throw new Error('Server returned no options');
|
|
381
|
+
|
|
382
|
+
options.challenge = b64urlToBytes(options.challenge);
|
|
383
|
+
options.user.id = b64urlToBytes(options.user.id);
|
|
384
|
+
if (options.excludeCredentials) {
|
|
385
|
+
options.excludeCredentials = options.excludeCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Phone-first by default. If local passkeys toggled on, show all options.
|
|
389
|
+
// On mobile (including QR session mode), always use platform (Face ID directly).
|
|
390
|
+
var mobile = isMobileDevice();
|
|
391
|
+
var localOn = isLocalPasskeysOn();
|
|
392
|
+
if (!options.authenticatorSelection) options.authenticatorSelection = {};
|
|
393
|
+
if (mobile) {
|
|
394
|
+
options.authenticatorSelection.authenticatorAttachment = 'platform';
|
|
395
|
+
} else if (!localOn) {
|
|
396
|
+
options.authenticatorSelection.authenticatorAttachment = 'cross-platform';
|
|
397
|
+
}
|
|
398
|
+
options.authenticatorSelection.residentKey = 'preferred';
|
|
399
|
+
options.authenticatorSelection.userVerification = 'required';
|
|
400
|
+
|
|
401
|
+
setStatus(mobile ? 'Waiting for biometric...' : localOn ? 'Waiting for biometric...' : 'Scan the QR code with your phone...', 'loading');
|
|
402
|
+
var credential = await navigator.credentials.create({ publicKey: options });
|
|
403
|
+
|
|
404
|
+
var reqBody = {
|
|
405
|
+
challengeId: challengeId,
|
|
406
|
+
credential: {
|
|
407
|
+
id: credential.id,
|
|
408
|
+
rawId: bytesToB64url(credential.rawId),
|
|
409
|
+
type: credential.type,
|
|
410
|
+
response: {
|
|
411
|
+
attestationObject: bytesToB64url(credential.response.attestationObject),
|
|
412
|
+
clientDataJSON: bytesToB64url(credential.response.clientDataJSON),
|
|
413
|
+
transports: credential.response.getTransports ? credential.response.getTransports() : [],
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
setStatus('Verifying...', 'loading');
|
|
419
|
+
var verRes = await fetch('/webauthn/register-verify', {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
headers: { 'Content-Type': 'application/json' },
|
|
422
|
+
body: JSON.stringify(reqBody),
|
|
423
|
+
});
|
|
424
|
+
var result = await verRes.json();
|
|
425
|
+
|
|
426
|
+
if (result.success) {
|
|
427
|
+
// If phone completing a desktop QR session, approve it
|
|
428
|
+
if (qrSessionMode && qrSessionId) {
|
|
429
|
+
await fetch('/api/qr-login/approve', {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
headers: { 'Content-Type': 'application/json' },
|
|
432
|
+
body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// Mark that user has an account (persists across sessions)
|
|
436
|
+
localStorage.setItem('kscope-has-account', 'true');
|
|
437
|
+
document.getElementById('signup-view').style.display = 'none';
|
|
438
|
+
document.getElementById('success-view').style.display = 'block';
|
|
439
|
+
document.getElementById('welcome-name').textContent = username || result.agentId || 'you';
|
|
440
|
+
setStatus('', '');
|
|
441
|
+
} else {
|
|
442
|
+
setStatus(result.error || 'Registration failed', 'error');
|
|
443
|
+
btn.disabled = false;
|
|
444
|
+
}
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (err.name === 'NotAllowedError') {
|
|
447
|
+
setStatus('Cancelled. Try again when ready.', 'error');
|
|
448
|
+
setTimeout(function() { clearStatus(); }, 3000);
|
|
449
|
+
} else {
|
|
450
|
+
setStatus('Error: ' + err.message, 'error');
|
|
451
|
+
}
|
|
452
|
+
btn.disabled = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function doSignIn() {
|
|
457
|
+
document.getElementById('signInBtn').style.pointerEvents = 'none';
|
|
458
|
+
document.getElementById('createBtn').disabled = true;
|
|
459
|
+
|
|
460
|
+
// Chrome desktop without local passkeys: use QR code for sign-in.
|
|
461
|
+
// With local passkeys on, let Chrome show native dialog (supports YubiKey).
|
|
462
|
+
if (needsCustomQR() && !qrSessionMode) {
|
|
463
|
+
startQrLogin('', 'signin');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
setStatus('Preparing...', 'loading');
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
var optRes = await fetch('/webauthn/auth-options', {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { 'Content-Type': 'application/json' },
|
|
473
|
+
body: '{}',
|
|
474
|
+
});
|
|
475
|
+
var optData = await optRes.json();
|
|
476
|
+
var challengeId = optData.challengeId;
|
|
477
|
+
var options = optData.options;
|
|
478
|
+
if (!options) throw new Error('Server returned no options');
|
|
479
|
+
|
|
480
|
+
options.challenge = b64urlToBytes(options.challenge);
|
|
481
|
+
if (options.allowCredentials) {
|
|
482
|
+
options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
setStatus('Waiting for biometric...', 'loading');
|
|
486
|
+
var assertion = await navigator.credentials.get({ publicKey: options });
|
|
487
|
+
|
|
488
|
+
var reqBody = {
|
|
489
|
+
challengeId: challengeId,
|
|
490
|
+
credential: {
|
|
491
|
+
id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type,
|
|
492
|
+
response: {
|
|
493
|
+
authenticatorData: bytesToB64url(assertion.response.authenticatorData),
|
|
494
|
+
clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),
|
|
495
|
+
signature: bytesToB64url(assertion.response.signature),
|
|
496
|
+
userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
setStatus('Verifying...', 'loading');
|
|
502
|
+
var verRes = await fetch('/webauthn/auth-verify', {
|
|
503
|
+
method: 'POST',
|
|
504
|
+
headers: { 'Content-Type': 'application/json' },
|
|
505
|
+
body: JSON.stringify(reqBody),
|
|
506
|
+
});
|
|
507
|
+
var result = await verRes.json();
|
|
508
|
+
|
|
509
|
+
if (result.success) {
|
|
510
|
+
// If phone completing a desktop QR session, approve it
|
|
511
|
+
if (qrSessionMode && qrSessionId) {
|
|
512
|
+
await fetch('/api/qr-login/approve', {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
515
|
+
body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
document.getElementById('signup-view').style.display = 'none';
|
|
519
|
+
document.getElementById('success-view').style.display = 'block';
|
|
520
|
+
document.getElementById('welcome-name').textContent = result.agentId || 'you';
|
|
521
|
+
setStatus('', '');
|
|
522
|
+
} else {
|
|
523
|
+
setStatus(result.error || 'Authentication failed', 'error');
|
|
524
|
+
document.getElementById('signInBtn').style.pointerEvents = 'auto';
|
|
525
|
+
document.getElementById('createBtn').disabled = false;
|
|
526
|
+
}
|
|
527
|
+
} catch (err) {
|
|
528
|
+
if (err.name === 'NotAllowedError') {
|
|
529
|
+
setStatus('Cancelled. Try again when ready.', 'error');
|
|
530
|
+
setTimeout(function() { clearStatus(); }, 3000);
|
|
531
|
+
} else {
|
|
532
|
+
setStatus('Error: ' + err.message, 'error');
|
|
533
|
+
}
|
|
534
|
+
document.getElementById('signInBtn').style.pointerEvents = 'auto';
|
|
535
|
+
document.getElementById('createBtn').disabled = false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Enter key on handle input triggers create
|
|
540
|
+
document.getElementById('handleInput').addEventListener('keydown', function(e) {
|
|
541
|
+
if (e.key === 'Enter') doCreateAccount();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Initialize footer dot
|
|
545
|
+
updatePasskeysDot();
|
|
546
|
+
</script>
|
|
547
|
+
</body>
|
|
548
|
+
</html>
|