instar 0.6.5 → 0.6.7
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/_demo.mjs +78 -0
- package/dist/commands/init.js +3 -0
- package/dist/publishing/PrivateViewer.d.ts +12 -2
- package/dist/publishing/PrivateViewer.js +138 -2
- package/dist/server/routes.js +48 -2
- package/dist/tunnel/TunnelManager.js +11 -2
- package/package.json +1 -1
package/_demo.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { PrivateViewer } from './dist/publishing/PrivateViewer.js';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { Tunnel } from 'cloudflared';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const PIN = '1234';
|
|
9
|
+
|
|
10
|
+
// Set up viewer
|
|
11
|
+
const viewsDir = path.join(os.tmpdir(), 'instar-demo-views-' + Date.now());
|
|
12
|
+
fs.mkdirSync(viewsDir, { recursive: true });
|
|
13
|
+
const viewer = new PrivateViewer({ viewsDir });
|
|
14
|
+
|
|
15
|
+
// Create test view with PIN
|
|
16
|
+
const view = viewer.create(
|
|
17
|
+
'Secret Test Report',
|
|
18
|
+
'# Hello from Instar\n\nThis is a **PIN-protected** private view.\n\nIf you can see this, the PIN worked!\n\n---\n\n*Served securely via Cloudflare Tunnel + PIN gate.*',
|
|
19
|
+
PIN
|
|
20
|
+
);
|
|
21
|
+
console.log('View created:', view.id);
|
|
22
|
+
|
|
23
|
+
// Start express server
|
|
24
|
+
const app = express();
|
|
25
|
+
app.use(express.json());
|
|
26
|
+
|
|
27
|
+
app.get('/view/:id', (req, res) => {
|
|
28
|
+
const v = viewer.get(req.params.id);
|
|
29
|
+
if (!v) return res.status(404).send('Not found');
|
|
30
|
+
if (v.pinHash) {
|
|
31
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
32
|
+
res.send(viewer.renderPinPage(v));
|
|
33
|
+
} else {
|
|
34
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
35
|
+
res.send(viewer.renderHtml(v));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
app.post('/view/:id/unlock', (req, res) => {
|
|
40
|
+
const v = viewer.get(req.params.id);
|
|
41
|
+
if (!v) return res.status(404).send('Not found');
|
|
42
|
+
const pin = req.body?.pin;
|
|
43
|
+
if (!pin || !viewer.verifyPin(req.params.id, pin)) {
|
|
44
|
+
return res.status(403).json({ error: 'Incorrect PIN' });
|
|
45
|
+
}
|
|
46
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
47
|
+
res.send(viewer.renderHtml(v));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const server = app.listen(0, '127.0.0.1', () => {
|
|
51
|
+
const port = server.address().port;
|
|
52
|
+
console.log('Server on port', port);
|
|
53
|
+
|
|
54
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
55
|
+
// Use explicit --config to prevent ~/.cloudflared/config.yml
|
|
56
|
+
// named tunnel ingress rules from overriding the quick tunnel
|
|
57
|
+
const cfgPath = path.join(os.tmpdir(), 'instar-demo-cf.yml');
|
|
58
|
+
fs.writeFileSync(cfgPath, '# Quick tunnel — no ingress rules\n');
|
|
59
|
+
const t = Tunnel.quick(localUrl, { '--config': cfgPath });
|
|
60
|
+
|
|
61
|
+
t.once('url', (tunnelUrl) => {
|
|
62
|
+
const viewUrl = `${tunnelUrl}/view/${view.id}`;
|
|
63
|
+
console.log('TUNNEL_URL=' + viewUrl);
|
|
64
|
+
console.log('PIN=' + PIN);
|
|
65
|
+
fs.writeFileSync('/tmp/instar-demo-url.txt', viewUrl + '\n' + PIN);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
t.on('error', (err) => {
|
|
69
|
+
console.error('Tunnel error:', err.message);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Keep process alive
|
|
73
|
+
process.on('SIGINT', () => {
|
|
74
|
+
t.stop();
|
|
75
|
+
server.close();
|
|
76
|
+
process.exit();
|
|
77
|
+
});
|
|
78
|
+
});
|
package/dist/commands/init.js
CHANGED
|
@@ -648,12 +648,15 @@ function getDefaultJobs(port) {
|
|
|
648
648
|
6. **Logs**: Check .instar/logs/server.log for recent errors: tail -50 .instar/logs/server.log | grep -i error
|
|
649
649
|
7. **Settings coherence**: Are hooks in .claude/settings.json pointing to files that exist?
|
|
650
650
|
8. **Design friction**: During your recent work, did anything feel unnecessarily difficult, confusing, or broken? Did you work around any issues?
|
|
651
|
+
9. **CI health**: Check if the project has a GitHub repo and if CI is passing. Run: REPO=$(git remote get-url origin 2>/dev/null | sed 's/.*github.com[:/]//;s/.git$//'); if [ -n "$REPO" ]; then FAILURES=$(gh run list --repo "$REPO" --status failure --limit 3 --json databaseId,conclusion,headBranch,name,createdAt 2>/dev/null); if echo "$FAILURES" | python3 -c "import sys,json; runs=json.load(sys.stdin); exit(0 if runs else 1)" 2>/dev/null; then echo "CI FAILURES DETECTED in $REPO"; echo "$FAILURES"; echo ""; echo "FIX THESE NOW: Read the failure logs with 'gh run view RUN_ID --repo $REPO --log-failed', diagnose the issue, fix it, run tests locally, commit and push."; fi; fi
|
|
651
652
|
|
|
652
653
|
For EACH issue found, submit feedback immediately:
|
|
653
654
|
curl -s -X POST http://localhost:${port}/feedback -H 'Content-Type: application/json' -d '{"type":"bug","title":"TITLE","description":"FULL_CONTEXT"}'
|
|
654
655
|
|
|
655
656
|
For improvements (not bugs), use type "improvement" instead.
|
|
656
657
|
|
|
658
|
+
IMPORTANT for CI failures: Don't just report them as feedback — FIX THEM. Read the logs, diagnose the root cause, apply the fix, run tests locally to verify, then commit and push. Only submit feedback if the fix is beyond your capability (e.g., requires credentials or external service changes). CI health is your responsibility as the agent running this project.
|
|
659
|
+
|
|
657
660
|
If everything looks healthy, exit silently. Only report issues.`,
|
|
658
661
|
},
|
|
659
662
|
tags: ['coherence', 'default'],
|
|
@@ -12,6 +12,8 @@ export interface PrivateView {
|
|
|
12
12
|
id: string;
|
|
13
13
|
title: string;
|
|
14
14
|
markdown: string;
|
|
15
|
+
/** SHA-256 hash of the PIN, if PIN-protected */
|
|
16
|
+
pinHash?: string;
|
|
15
17
|
createdAt: string;
|
|
16
18
|
updatedAt?: string;
|
|
17
19
|
}
|
|
@@ -25,9 +27,9 @@ export declare class PrivateViewer {
|
|
|
25
27
|
constructor(config: PrivateViewerConfig);
|
|
26
28
|
/**
|
|
27
29
|
* Store markdown content for private viewing.
|
|
28
|
-
*
|
|
30
|
+
* If a PIN is provided, the view requires PIN entry before content is shown.
|
|
29
31
|
*/
|
|
30
|
-
create(title: string, markdown: string): PrivateView;
|
|
32
|
+
create(title: string, markdown: string, pin?: string): PrivateView;
|
|
31
33
|
/**
|
|
32
34
|
* Update an existing view.
|
|
33
35
|
*/
|
|
@@ -44,6 +46,14 @@ export declare class PrivateViewer {
|
|
|
44
46
|
* Delete a view.
|
|
45
47
|
*/
|
|
46
48
|
delete(id: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Verify a PIN against a view's stored hash.
|
|
51
|
+
*/
|
|
52
|
+
verifyPin(id: string, pin: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Render a PIN entry page for a protected view.
|
|
55
|
+
*/
|
|
56
|
+
renderPinPage(view: PrivateView, error?: boolean): string;
|
|
47
57
|
/**
|
|
48
58
|
* Render a view as self-contained HTML.
|
|
49
59
|
*/
|
|
@@ -24,9 +24,9 @@ export class PrivateViewer {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Store markdown content for private viewing.
|
|
27
|
-
*
|
|
27
|
+
* If a PIN is provided, the view requires PIN entry before content is shown.
|
|
28
28
|
*/
|
|
29
|
-
create(title, markdown) {
|
|
29
|
+
create(title, markdown, pin) {
|
|
30
30
|
const id = crypto.randomUUID();
|
|
31
31
|
// Ensure monotonically increasing timestamps even within same millisecond
|
|
32
32
|
let now = Date.now();
|
|
@@ -40,6 +40,9 @@ export class PrivateViewer {
|
|
|
40
40
|
markdown,
|
|
41
41
|
createdAt: new Date(now).toISOString(),
|
|
42
42
|
};
|
|
43
|
+
if (pin) {
|
|
44
|
+
view.pinHash = crypto.createHash('sha256').update(pin).digest('hex');
|
|
45
|
+
}
|
|
43
46
|
this.save(view);
|
|
44
47
|
return view;
|
|
45
48
|
}
|
|
@@ -105,6 +108,139 @@ export class PrivateViewer {
|
|
|
105
108
|
return false;
|
|
106
109
|
}
|
|
107
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Verify a PIN against a view's stored hash.
|
|
113
|
+
*/
|
|
114
|
+
verifyPin(id, pin) {
|
|
115
|
+
const view = this.get(id);
|
|
116
|
+
if (!view || !view.pinHash)
|
|
117
|
+
return false;
|
|
118
|
+
const hash = crypto.createHash('sha256').update(pin).digest('hex');
|
|
119
|
+
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(view.pinHash, 'hex'));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Render a PIN entry page for a protected view.
|
|
123
|
+
*/
|
|
124
|
+
renderPinPage(view, error = false) {
|
|
125
|
+
return `<!DOCTYPE html>
|
|
126
|
+
<html lang="en">
|
|
127
|
+
<head>
|
|
128
|
+
<meta charset="UTF-8">
|
|
129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
130
|
+
<title>${escapeHtml(view.title)}</title>
|
|
131
|
+
<style>
|
|
132
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
133
|
+
body {
|
|
134
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
135
|
+
background: #f8f9fa;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
min-height: 100vh;
|
|
140
|
+
color: #1a1a2e;
|
|
141
|
+
}
|
|
142
|
+
.pin-box {
|
|
143
|
+
background: #fff;
|
|
144
|
+
border-radius: 12px;
|
|
145
|
+
padding: 2.5rem;
|
|
146
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
147
|
+
max-width: 380px;
|
|
148
|
+
width: 90%;
|
|
149
|
+
text-align: center;
|
|
150
|
+
}
|
|
151
|
+
.pin-box h1 {
|
|
152
|
+
font-size: 1.3rem;
|
|
153
|
+
margin-bottom: 0.5rem;
|
|
154
|
+
color: #16213e;
|
|
155
|
+
}
|
|
156
|
+
.pin-box p {
|
|
157
|
+
font-size: 0.9rem;
|
|
158
|
+
color: #666;
|
|
159
|
+
margin-bottom: 1.5rem;
|
|
160
|
+
}
|
|
161
|
+
.pin-input {
|
|
162
|
+
width: 100%;
|
|
163
|
+
padding: 0.75rem 1rem;
|
|
164
|
+
font-size: 1.5rem;
|
|
165
|
+
letter-spacing: 0.3em;
|
|
166
|
+
text-align: center;
|
|
167
|
+
border: 2px solid #e0e0e0;
|
|
168
|
+
border-radius: 8px;
|
|
169
|
+
outline: none;
|
|
170
|
+
transition: border-color 0.2s;
|
|
171
|
+
}
|
|
172
|
+
.pin-input:focus { border-color: #533483; }
|
|
173
|
+
.pin-input.error { border-color: #e74c3c; }
|
|
174
|
+
.error-msg {
|
|
175
|
+
color: #e74c3c;
|
|
176
|
+
font-size: 0.85rem;
|
|
177
|
+
margin-top: 0.5rem;
|
|
178
|
+
display: ${error ? 'block' : 'none'};
|
|
179
|
+
}
|
|
180
|
+
.submit-btn {
|
|
181
|
+
width: 100%;
|
|
182
|
+
padding: 0.75rem;
|
|
183
|
+
margin-top: 1.25rem;
|
|
184
|
+
background: #16213e;
|
|
185
|
+
color: #fff;
|
|
186
|
+
border: none;
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
font-size: 1rem;
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
transition: background 0.2s;
|
|
191
|
+
}
|
|
192
|
+
.submit-btn:hover { background: #533483; }
|
|
193
|
+
.submit-btn:disabled { background: #aaa; cursor: not-allowed; }
|
|
194
|
+
.lock-icon { font-size: 2rem; margin-bottom: 0.75rem; }
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<div class="pin-box">
|
|
199
|
+
<div class="lock-icon">🔒</div>
|
|
200
|
+
<h1>${escapeHtml(view.title)}</h1>
|
|
201
|
+
<p>This content is PIN-protected.</p>
|
|
202
|
+
<form id="pin-form">
|
|
203
|
+
<input type="password" class="pin-input${error ? ' error' : ''}" id="pin" name="pin"
|
|
204
|
+
placeholder="Enter PIN" autocomplete="off" inputmode="numeric" autofocus>
|
|
205
|
+
<div class="error-msg" id="error-msg">Incorrect PIN. Please try again.</div>
|
|
206
|
+
<button type="submit" class="submit-btn">Unlock</button>
|
|
207
|
+
</form>
|
|
208
|
+
</div>
|
|
209
|
+
<script>
|
|
210
|
+
document.getElementById('pin-form').addEventListener('submit', async (e) => {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
const pin = document.getElementById('pin').value;
|
|
213
|
+
const btn = document.querySelector('.submit-btn');
|
|
214
|
+
btn.disabled = true;
|
|
215
|
+
btn.textContent = 'Verifying...';
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(window.location.pathname + '/unlock', {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify({ pin }),
|
|
221
|
+
});
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
const html = await res.text();
|
|
224
|
+
document.open();
|
|
225
|
+
document.write(html);
|
|
226
|
+
document.close();
|
|
227
|
+
} else {
|
|
228
|
+
document.getElementById('error-msg').style.display = 'block';
|
|
229
|
+
document.getElementById('pin').classList.add('error');
|
|
230
|
+
document.getElementById('pin').value = '';
|
|
231
|
+
document.getElementById('pin').focus();
|
|
232
|
+
btn.disabled = false;
|
|
233
|
+
btn.textContent = 'Unlock';
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
btn.disabled = false;
|
|
237
|
+
btn.textContent = 'Unlock';
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
</script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>`;
|
|
243
|
+
}
|
|
108
244
|
/**
|
|
109
245
|
* Render a view as self-contained HTML.
|
|
110
246
|
*/
|
package/dist/server/routes.js
CHANGED
|
@@ -897,7 +897,7 @@ export function createRoutes(ctx) {
|
|
|
897
897
|
res.status(503).json({ error: 'Private viewer not configured' });
|
|
898
898
|
return;
|
|
899
899
|
}
|
|
900
|
-
const { title, markdown } = req.body;
|
|
900
|
+
const { title, markdown, pin } = req.body;
|
|
901
901
|
if (!title || typeof title !== 'string' || title.length > 256) {
|
|
902
902
|
res.status(400).json({ error: '"title" must be a string under 256 characters' });
|
|
903
903
|
return;
|
|
@@ -910,10 +910,15 @@ export function createRoutes(ctx) {
|
|
|
910
910
|
res.status(400).json({ error: '"markdown" must be under 500KB' });
|
|
911
911
|
return;
|
|
912
912
|
}
|
|
913
|
-
|
|
913
|
+
if (pin !== undefined && (typeof pin !== 'string' || pin.length < 4 || pin.length > 32)) {
|
|
914
|
+
res.status(400).json({ error: '"pin" must be a string between 4 and 32 characters' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const view = ctx.viewer.create(title, markdown, pin);
|
|
914
918
|
res.status(201).json({
|
|
915
919
|
id: view.id,
|
|
916
920
|
title: view.title,
|
|
921
|
+
pinProtected: !!view.pinHash,
|
|
917
922
|
localUrl: `/view/${view.id}`,
|
|
918
923
|
tunnelUrl: viewTunnelUrl(view.id),
|
|
919
924
|
createdAt: view.createdAt,
|
|
@@ -933,11 +938,52 @@ export function createRoutes(ctx) {
|
|
|
933
938
|
res.status(404).json({ error: 'View not found' });
|
|
934
939
|
return;
|
|
935
940
|
}
|
|
941
|
+
// PIN-protected views show PIN entry page
|
|
942
|
+
if (view.pinHash) {
|
|
943
|
+
const html = ctx.viewer.renderPinPage(view);
|
|
944
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
945
|
+
res.send(html);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
936
948
|
// Serve rendered HTML
|
|
937
949
|
const html = ctx.viewer.renderHtml(view);
|
|
938
950
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
939
951
|
res.send(html);
|
|
940
952
|
});
|
|
953
|
+
router.post('/view/:id/unlock', (req, res) => {
|
|
954
|
+
if (!ctx.viewer) {
|
|
955
|
+
res.status(503).json({ error: 'Private viewer not configured' });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!UUID_RE.test(req.params.id)) {
|
|
959
|
+
res.status(400).json({ error: 'Invalid view ID' });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const view = ctx.viewer.get(req.params.id);
|
|
963
|
+
if (!view) {
|
|
964
|
+
res.status(404).json({ error: 'View not found' });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!view.pinHash) {
|
|
968
|
+
// No PIN needed — return content directly
|
|
969
|
+
const html = ctx.viewer.renderHtml(view);
|
|
970
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
971
|
+
res.send(html);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const { pin } = req.body;
|
|
975
|
+
if (!pin || typeof pin !== 'string') {
|
|
976
|
+
res.status(400).json({ error: '"pin" is required' });
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (!ctx.viewer.verifyPin(req.params.id, pin)) {
|
|
980
|
+
res.status(403).json({ error: 'Incorrect PIN' });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const html = ctx.viewer.renderHtml(view);
|
|
984
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
985
|
+
res.send(html);
|
|
986
|
+
});
|
|
941
987
|
router.get('/views', (_req, res) => {
|
|
942
988
|
if (!ctx.viewer) {
|
|
943
989
|
res.json({ views: [] });
|
|
@@ -102,9 +102,18 @@ export class TunnelManager extends EventEmitter {
|
|
|
102
102
|
}
|
|
103
103
|
startQuickTunnel() {
|
|
104
104
|
return new Promise((resolve, reject) => {
|
|
105
|
-
const localUrl = `http://
|
|
105
|
+
const localUrl = `http://127.0.0.1:${this.config.port}`;
|
|
106
106
|
try {
|
|
107
|
-
|
|
107
|
+
// Write an empty config to prevent cloudflared from loading
|
|
108
|
+
// ~/.cloudflared/config.yml, which may contain named tunnel
|
|
109
|
+
// ingress rules that override the quick tunnel's --url proxy.
|
|
110
|
+
const emptyConfig = path.join(this.config.stateDir, 'cloudflared-quick.yml');
|
|
111
|
+
const dir = path.dirname(emptyConfig);
|
|
112
|
+
if (!fs.existsSync(dir)) {
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
fs.writeFileSync(emptyConfig, '# Quick tunnel — no ingress rules\n');
|
|
116
|
+
this.tunnel = Tunnel.quick(localUrl, { '--config': emptyConfig });
|
|
108
117
|
}
|
|
109
118
|
catch (err) {
|
|
110
119
|
reject(new Error(`Failed to start quick tunnel: ${err instanceof Error ? err.message : String(err)}`));
|