instar 0.6.5 → 0.6.6

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.
@@ -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
- * Returns the view ID.
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
- * Returns the view ID.
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">&#128274;</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
  */
@@ -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
- const view = ctx.viewer.create(title, markdown);
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: [] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",