@dtudury/streamo 4.0.0 → 4.0.1
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtudury/streamo",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "peer-to-peer sync where your data and identity belong to you, not the server",
|
|
5
5
|
"keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
|
|
6
6
|
"repository": "git@github.com:dtudury/streamo.git",
|
package/public/apps/chat/main.js
CHANGED
|
@@ -13,8 +13,10 @@ function fmt (ts) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function Msg ({ name, text, at, mine }) {
|
|
16
|
+
// +at coerces both Date and number to ms — stable key across old (number)
|
|
17
|
+
// and new (Date) message records as we transition.
|
|
16
18
|
return h`
|
|
17
|
-
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
|
|
19
|
+
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${+at}>
|
|
18
20
|
${!mine ? h`<div class="sender">${name}</div>` : null}
|
|
19
21
|
<div class="text">${text}</div>
|
|
20
22
|
<div class="time">${fmt(at)}</div>
|
|
@@ -129,7 +131,7 @@ joinBtn.onclick = async () => {
|
|
|
129
131
|
const messages = myRepo.get('messages') ?? []
|
|
130
132
|
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
131
133
|
myRepo.defaultMessage = `"${preview}" (web)`
|
|
132
|
-
myRepo.set({ name: username, messages: [...messages, { text, at: Date
|
|
134
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
sendBtn.onclick = sendMessage
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
text-align: center;
|
|
52
52
|
align-self: center;
|
|
53
53
|
}
|
|
54
|
-
.row.commit .kind
|
|
55
|
-
.row.signature .kind
|
|
56
|
-
.row.signed-commit .kind
|
|
57
|
-
.row.
|
|
54
|
+
.row.commit .kind { color: var(--accent); border-color: var(--accent); }
|
|
55
|
+
.row.signature .kind { color: var(--warn); border-color: var(--warn); }
|
|
56
|
+
.row.signed-commit .kind { color: #16a34a; border-color: #16a34a; }
|
|
57
|
+
.row.signed-commit.unsigned .kind { color: var(--ink-dim); border-color: var(--ink-dim); }
|
|
58
58
|
|
|
59
59
|
/* HEAD card — the most-recent signed commit, prominent and self-orienting. */
|
|
60
60
|
.row.signed-commit.head-card {
|
|
@@ -64,6 +64,22 @@
|
|
|
64
64
|
}
|
|
65
65
|
.row.signed-commit.head-card .msg { font-size: 1rem; font-weight: 500; }
|
|
66
66
|
|
|
67
|
+
/* Detached card — same layout as the head-card but neutral styling.
|
|
68
|
+
Shown as the selector summary when the current address isn't a sig
|
|
69
|
+
(you've drilled into raw memory). The dropdown body is still the
|
|
70
|
+
way back — pick a real commit and you re-attach. */
|
|
71
|
+
.row.signed-commit.detached-card {
|
|
72
|
+
border: 1.5px dashed var(--rule);
|
|
73
|
+
background: transparent;
|
|
74
|
+
padding: 0.85rem;
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
}
|
|
77
|
+
.row.signed-commit.detached-card .kind {
|
|
78
|
+
color: var(--ink-dim);
|
|
79
|
+
border-color: var(--ink-dim);
|
|
80
|
+
}
|
|
81
|
+
.row.signed-commit.detached-card .msg { font-size: 0.95rem; }
|
|
82
|
+
|
|
67
83
|
/* Commit selector: a real dropdown widget. Summary = currently-selected
|
|
68
84
|
commit (HEAD by default), styled as the green head-card. Body =
|
|
69
85
|
full list of signed commits, with the selected one marked. */
|
|
@@ -113,17 +129,25 @@
|
|
|
113
129
|
}
|
|
114
130
|
details.other-storage[open] > summary { color: var(--ink); }
|
|
115
131
|
|
|
116
|
-
/*
|
|
117
|
-
|
|
132
|
+
/* "What this is" banner — top of every value tab. Default neutral
|
|
133
|
+
border for storage codecs; green .verified for commits or sigs
|
|
134
|
+
backed by a valid signature; dim .unsigned for commits awaiting
|
|
135
|
+
a signature. */
|
|
136
|
+
.kind-banner {
|
|
118
137
|
display: flex; align-items: center; gap: 0.5rem;
|
|
119
138
|
padding: 0.65rem 0.85rem; margin: 0.5rem 0 1rem;
|
|
120
|
-
border: 1.5px solid
|
|
139
|
+
border: 1.5px solid var(--rule); border-radius: var(--radius);
|
|
140
|
+
}
|
|
141
|
+
.kind-banner.verified {
|
|
142
|
+
border-color: #16a34a;
|
|
121
143
|
background: rgba(22, 163, 74, 0.06);
|
|
122
144
|
}
|
|
123
|
-
.
|
|
145
|
+
.kind-banner.unsigned { border-style: dashed; }
|
|
146
|
+
.kind-banner .kind-label {
|
|
124
147
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
|
125
|
-
font-weight: 600; color:
|
|
148
|
+
font-weight: 600; color: var(--ink-dim);
|
|
126
149
|
}
|
|
150
|
+
.kind-banner.verified .kind-label { color: #16a34a; }
|
|
127
151
|
.commit-card {
|
|
128
152
|
padding: 0.6rem 0.85rem; margin: 0.4rem 0;
|
|
129
153
|
border: 1px solid var(--rule); border-radius: var(--radius);
|
|
@@ -134,58 +134,49 @@ function safeJSON (value) {
|
|
|
134
134
|
}, 2)
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
// Walk every chunk newest-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// Commits
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
// storage section.
|
|
148
|
-
function * signedCommits (repo) {
|
|
137
|
+
// Walk every chunk newest-first, yielding one entry per commit (with
|
|
138
|
+
// its covering signature attached) and one 'other' entry per non-commit
|
|
139
|
+
// non-sig chunk. A signature is part of *how* a commit is verified, not
|
|
140
|
+
// a thing of its own — so the user-level unit is the commit. Walking
|
|
141
|
+
// newest-first, we encounter each sig before the commits it covers
|
|
142
|
+
// (sig has higher address than the bytes it signed); we track the
|
|
143
|
+
// most-recently-seen sig and attach it to subsequent commits as their
|
|
144
|
+
// 'covering'. Commits encountered before any sig are uncovered (sign
|
|
145
|
+
// in flight or none yet) — those have covering: null.
|
|
146
|
+
function * commitsNewestFirst (repo) {
|
|
149
147
|
const len = repo.byteLength
|
|
150
148
|
if (len <= 0) return
|
|
151
149
|
let addr = len - 1
|
|
152
|
-
let
|
|
153
|
-
let pendingCommits = [] // commits since the last yielded sig (newest first)
|
|
154
|
-
|
|
155
|
-
const flush = () => {
|
|
156
|
-
if (pendingSig) return [{ kind: 'signedCommit', ...pendingSig, commits: pendingCommits }]
|
|
157
|
-
return pendingCommits.map(c => ({ kind: 'unsignedCommit', ...c }))
|
|
158
|
-
}
|
|
159
|
-
|
|
150
|
+
let covering = null // most-recent sig encountered in this walk
|
|
160
151
|
while (addr >= 0) {
|
|
161
152
|
const code = repo.resolve(addr)
|
|
162
153
|
if (!code || !code.length) break
|
|
163
154
|
const type = repo.footerToCodec[code.at(-1)]?.type
|
|
164
|
-
|
|
165
155
|
if (type === 'SIGNATURE') {
|
|
166
|
-
for (const e of flush()) yield e
|
|
167
156
|
let sig
|
|
168
157
|
try { sig = repo.decode(addr) } catch { sig = null }
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
158
|
+
if (sig) {
|
|
159
|
+
covering = {
|
|
160
|
+
sigAddress: addr,
|
|
161
|
+
signedFrom: sig.address,
|
|
162
|
+
signedTo: addr - code.length,
|
|
163
|
+
sigHex: truncHex(sig.compactRawBytes, 12)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
yield { kind: 'sig', address: addr, codecType: type }
|
|
178
167
|
} else if (type === 'OBJECT') {
|
|
179
168
|
let value
|
|
180
169
|
try { value = repo.decode(addr) } catch { value = null }
|
|
181
170
|
if (isCommitShape(value)) {
|
|
182
|
-
|
|
171
|
+
yield {
|
|
172
|
+
kind: 'commit',
|
|
183
173
|
address: addr,
|
|
184
174
|
message: value.message,
|
|
185
175
|
date: value.date,
|
|
186
176
|
dataAddress: value.dataAddress,
|
|
187
|
-
parent: value.parent
|
|
188
|
-
|
|
177
|
+
parent: value.parent,
|
|
178
|
+
covering
|
|
179
|
+
}
|
|
189
180
|
} else {
|
|
190
181
|
yield { kind: 'other', address: addr, codecType: type }
|
|
191
182
|
}
|
|
@@ -194,67 +185,47 @@ function * signedCommits (repo) {
|
|
|
194
185
|
}
|
|
195
186
|
addr -= code.length
|
|
196
187
|
}
|
|
197
|
-
for (const e of flush()) yield e
|
|
198
188
|
}
|
|
199
189
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// commit
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
190
|
+
// Find the covering signature for a commit — the first signature chunk
|
|
191
|
+
// newer than the commit whose [signedFrom, signedTo] range includes its
|
|
192
|
+
// address. Returns { sigAddress, signedFrom, signedTo, decoded } or null
|
|
193
|
+
// if the commit is uncovered (sign in flight or pending).
|
|
194
|
+
function findCoveringSig (repo, commitAddr) {
|
|
195
|
+
let scan = repo.byteLength - 1
|
|
196
|
+
while (scan > commitAddr) {
|
|
197
|
+
const code = repo.resolve(scan)
|
|
198
|
+
if (!code || !code.length) break
|
|
199
|
+
if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
|
|
200
|
+
let sig
|
|
201
|
+
try { sig = repo.decode(scan) } catch { sig = null }
|
|
202
|
+
if (sig && sig.address <= commitAddr && (scan - code.length) >= commitAddr) {
|
|
203
|
+
return { sigAddress: scan, signedFrom: sig.address, signedTo: scan - code.length, decoded: sig }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
scan -= code.length
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Sig-detail view — when you're at a sig chunk directly (e.g., from
|
|
212
|
+
// drilling through storage). Sigs are auxiliary in the new model — the
|
|
213
|
+
// user-level unit is the commit — so this page shows the sig's content
|
|
214
|
+
// without trying to be the "polished signed commit" page. The kindBanner
|
|
215
|
+
// is rendered by the caller (AtView) so its variant matches the rest of
|
|
216
|
+
// the value-tab branches.
|
|
217
|
+
function sigDetailBody (repo, keyHex, sigAddress, decoded) {
|
|
208
218
|
const chunk = repo.resolve(sigAddress)
|
|
209
219
|
const chunkLen = chunk.length
|
|
210
220
|
const signedTo = sigAddress - chunkLen
|
|
211
221
|
const sigChunkStart = sigAddress - chunkLen + 1
|
|
212
222
|
const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
|
|
213
|
-
const head = covered[0]
|
|
214
223
|
return h`
|
|
215
|
-
<div class="signed-commit-banner">
|
|
216
|
-
<span class="signed-label">signed commit</span>
|
|
217
|
-
${() => {
|
|
218
|
-
dep()
|
|
219
|
-
const status = verifyStatus(repo, keyHex, decoded, sigAddress)
|
|
220
|
-
const label = status === 'valid' ? 'verified — bytes match this repo’s public key'
|
|
221
|
-
: status === 'invalid' ? 'NOT VERIFIED — bytes do not match the repo key'
|
|
222
|
-
: status === 'pending' ? 'verifying…'
|
|
223
|
-
: `error: ${status?.error ?? 'unknown'}`
|
|
224
|
-
return h`${verifyBadge(status)} <span class="dim">${label}</span>`
|
|
225
|
-
}}
|
|
226
|
-
</div>
|
|
227
|
-
${covered.length === 0
|
|
228
|
-
? h`<div class="empty">this signature covers no commits (would only happen if a sign produced over a non-commit range)</div>`
|
|
229
|
-
: h`
|
|
230
|
-
<h3>${covered.length === 1 ? 'message' : `${covered.length} messages (batched into one signature)`}</h3>
|
|
231
|
-
${covered.map(c => h`
|
|
232
|
-
<div class="commit-card" data-key=${`cc${c.address}`}>
|
|
233
|
-
<div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
|
|
234
|
-
<div class="commit-meta dim">
|
|
235
|
-
<span>${fmtDate(c.date)}</span>
|
|
236
|
-
<span> · commit chunk <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.address}>@${c.address}</a></span>
|
|
237
|
-
<span> · value <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.dataAddress}>@${c.dataAddress}</a></span>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
`)}
|
|
241
|
-
${head ? (() => {
|
|
242
|
-
let value
|
|
243
|
-
try { value = repo.decode(head.dataAddress) } catch { value = undefined }
|
|
244
|
-
return h`
|
|
245
|
-
<h3>value at this point</h3>
|
|
246
|
-
<pre class="value">${value === undefined ? '(decode error)' : safeJSON(value)}</pre>
|
|
247
|
-
`
|
|
248
|
-
})() : null}
|
|
249
|
-
`}
|
|
250
|
-
<h3>signature</h3>
|
|
251
224
|
<table class="kv">
|
|
252
225
|
<tbody>
|
|
253
226
|
<tr>
|
|
254
227
|
<td>covers</td>
|
|
255
|
-
<td
|
|
256
|
-
data-keyhex=${keyHex} data-addr=${decoded.address}
|
|
257
|
-
>@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
|
|
228
|
+
<td>@${decoded.address} through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
|
|
258
229
|
</tr>
|
|
259
230
|
<tr>
|
|
260
231
|
<td>sig chunk</td>
|
|
@@ -263,6 +234,19 @@ function signedCommitDetail (repo, keyHex, sigAddress) {
|
|
|
263
234
|
<tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
|
|
264
235
|
</tbody>
|
|
265
236
|
</table>
|
|
237
|
+
${covered.length ? h`
|
|
238
|
+
<h3>commits in this signature ${covered.length > 1 ? h`<span class="dim">(${covered.length}, batched in one sign)</span>` : null}</h3>
|
|
239
|
+
${covered.map(c => h`
|
|
240
|
+
<div class="commit-card" data-key=${`cc${c.address}`} data-action="open-at"
|
|
241
|
+
data-keyhex=${keyHex} data-addr=${c.address}>
|
|
242
|
+
<div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
|
|
243
|
+
<div class="commit-meta dim">
|
|
244
|
+
<span>${fmtDate(c.date)}</span>
|
|
245
|
+
<span> · @${c.address}</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
`)}
|
|
249
|
+
` : null}
|
|
266
250
|
`
|
|
267
251
|
}
|
|
268
252
|
|
|
@@ -328,101 +312,108 @@ function RegistryView () {
|
|
|
328
312
|
`
|
|
329
313
|
}
|
|
330
314
|
|
|
331
|
-
// Resolve the symbolic HEAD address to the most-recent
|
|
332
|
-
// address
|
|
315
|
+
// Resolve the symbolic HEAD address to the most-recent COMMIT chunk's
|
|
316
|
+
// address — not the most-recent signature. The user-level unit is the
|
|
317
|
+
// commit; sigs are how it's verified, but HEAD-as-a-commit is what
|
|
318
|
+
// people mean by "the latest." Returns undefined if there are no commits.
|
|
333
319
|
function resolveHead (repo) {
|
|
334
320
|
let walk = repo.byteLength - 1
|
|
335
321
|
while (walk >= 0) {
|
|
336
322
|
const code = repo.resolve(walk)
|
|
337
323
|
if (!code || !code.length) break
|
|
338
|
-
if (repo.footerToCodec[code.at(-1)]?.type === '
|
|
324
|
+
if (repo.footerToCodec[code.at(-1)]?.type === 'OBJECT') {
|
|
325
|
+
let value
|
|
326
|
+
try { value = repo.decode(walk) } catch { value = null }
|
|
327
|
+
if (isCommitShape(value)) return walk
|
|
328
|
+
}
|
|
339
329
|
walk -= code.length
|
|
340
330
|
}
|
|
341
331
|
return undefined
|
|
342
332
|
}
|
|
343
333
|
|
|
344
|
-
// Commit selector dropdown —
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
334
|
+
// Commit selector dropdown — always rendered at the top of an at-view
|
|
335
|
+
// when the repo has any commits. The dropdown enumerates COMMITS (not
|
|
336
|
+
// sigs), since the commit is the user-level unit. Each entry's verify
|
|
337
|
+
// badge comes from its covering sig; uncovered commits show a "pending"
|
|
338
|
+
// badge. When the current address is a commit, that row is the summary;
|
|
339
|
+
// otherwise the summary is a "detached" card (you're at a sig chunk, a
|
|
340
|
+
// Duple, raw bytes, etc. — drill state, not a named ref).
|
|
341
|
+
function commitSelectorSection (repo, keyHex, currentAddr) {
|
|
342
|
+
const entries = [...commitsNewestFirst(repo)].filter(e => e.kind === 'commit')
|
|
351
343
|
if (!entries.length) return null
|
|
352
344
|
const tagFor = i => i === 0 ? 'HEAD' : `HEAD-${i}`
|
|
353
|
-
const
|
|
354
|
-
const c0 = e.commits[0]
|
|
355
|
-
const headline = e.commits.length === 0
|
|
356
|
-
? h`<span class="dim">(sig with no covered commits)</span>`
|
|
357
|
-
: e.commits.length === 1
|
|
358
|
-
? (c0.message || h`<span class="dim">(no message)</span>`)
|
|
359
|
-
: h`<span class="dim">${e.commits.length}× </span>${c0?.message || ''}`
|
|
345
|
+
const commitRow = (c, tag, { asSummary = false, isSelected = false } = {}) => {
|
|
360
346
|
const cls = ['row', 'signed-commit',
|
|
361
347
|
asSummary ? 'head-card' : null,
|
|
362
|
-
isSelected ? 'selected' : null
|
|
348
|
+
isSelected ? 'selected' : null,
|
|
349
|
+
c.covering ? null : 'unsigned']
|
|
363
350
|
const action = asSummary ? null : 'select-commit'
|
|
351
|
+
const badge = () => {
|
|
352
|
+
dep()
|
|
353
|
+
if (!c.covering) return h`<span class="verify-badge pending" title="not yet signed">…</span>`
|
|
354
|
+
return verifyBadge(verifyStatus(repo, keyHex, c.covering.decoded || repo.decode(c.covering.sigAddress), c.covering.sigAddress))
|
|
355
|
+
}
|
|
364
356
|
return h`
|
|
365
357
|
<div class=${cls}
|
|
366
|
-
data-key=${`
|
|
358
|
+
data-key=${`c${c.address}`}
|
|
367
359
|
data-action=${action}
|
|
368
|
-
data-keyhex=${keyHex} data-addr=${
|
|
369
|
-
<span class="kind">${tag} ${
|
|
370
|
-
<span class="msg">${
|
|
371
|
-
<span class="when">${
|
|
372
|
-
<span class="mono dim">@${
|
|
360
|
+
data-keyhex=${keyHex} data-addr=${c.address}>
|
|
361
|
+
<span class="kind">${tag} ${badge}</span>
|
|
362
|
+
<span class="msg">${c.message || h`<span class="dim">(no message)</span>`}</span>
|
|
363
|
+
<span class="when">${fmtDate(c.date)}</span>
|
|
364
|
+
<span class="mono dim">@${c.address}</span>
|
|
373
365
|
</div>`
|
|
374
366
|
}
|
|
375
|
-
const selectedIdx = entries.findIndex(e => e.
|
|
376
|
-
const
|
|
367
|
+
const selectedIdx = entries.findIndex(e => e.address === currentAddr)
|
|
368
|
+
const isDetached = selectedIdx < 0
|
|
369
|
+
const detachedSummary = (() => {
|
|
370
|
+
let codec = ''
|
|
371
|
+
try { codec = repo.footerToCodec[repo.resolve(currentAddr).at(-1)]?.type || '' } catch {}
|
|
372
|
+
return h`
|
|
373
|
+
<div class="row signed-commit detached-card" data-key="detached">
|
|
374
|
+
<span class="kind">detached</span>
|
|
375
|
+
<span class="msg dim">exploring raw memory${codec ? ` · ${codec}` : ''}</span>
|
|
376
|
+
<span class="when"></span>
|
|
377
|
+
<span class="mono dim">@${currentAddr}</span>
|
|
378
|
+
</div>`
|
|
379
|
+
})()
|
|
380
|
+
const summary = isDetached
|
|
381
|
+
? detachedSummary
|
|
382
|
+
: commitRow(entries[selectedIdx], tagFor(selectedIdx), { asSummary: true })
|
|
377
383
|
return h`
|
|
378
384
|
<details class="commit-selector" data-key=${`selector-${keyHex}`}>
|
|
379
|
-
<summary>${
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
</div>
|
|
384
|
-
` : null}
|
|
385
|
+
<summary>${summary}</summary>
|
|
386
|
+
<div class="dropdown-body">
|
|
387
|
+
${entries.map((e, i) => commitRow(e, tagFor(i), { isSelected: !isDetached && i === selectedIdx }))}
|
|
388
|
+
</div>
|
|
385
389
|
</details>
|
|
386
390
|
`
|
|
387
391
|
}
|
|
388
392
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// so
|
|
393
|
+
// Repo-wide "other storage chunks" list — Duples, raw OBJECTs, ARRAYs,
|
|
394
|
+
// STRINGs, etc. The chunks underneath the commit graph. Tucked into a
|
|
395
|
+
// closed <details> so it doesn't compete with primary content. Unsigned
|
|
396
|
+
// commits already appear in the selector dropdown (with a pending badge),
|
|
397
|
+
// so they don't need a second listing here.
|
|
392
398
|
function repoExtras (repo, keyHex) {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const others = entries.filter(e => e.kind === 'other')
|
|
399
|
+
const others = [...commitsNewestFirst(repo)].filter(e => e.kind === 'other')
|
|
400
|
+
if (!others.length) return null
|
|
396
401
|
return h`
|
|
397
|
-
|
|
398
|
-
<
|
|
399
|
-
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
<table class="kv clickable">
|
|
413
|
-
<tbody>
|
|
414
|
-
${others.map(e => h`
|
|
415
|
-
<tr data-key=${`o${e.address}`} data-action="open-at"
|
|
416
|
-
data-keyhex=${keyHex} data-addr=${e.address}>
|
|
417
|
-
<td class="mono dim">${e.codecType}</td>
|
|
418
|
-
<td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
|
|
419
|
-
<td class="mono dim">@${e.address}</td>
|
|
420
|
-
</tr>
|
|
421
|
-
`)}
|
|
422
|
-
</tbody>
|
|
423
|
-
</table>
|
|
424
|
-
</details>
|
|
425
|
-
` : null}
|
|
402
|
+
<details class="other-storage">
|
|
403
|
+
<summary>storage chunks <span class="dim">(${others.length}) — the chunks underneath</span></summary>
|
|
404
|
+
<table class="kv clickable">
|
|
405
|
+
<tbody>
|
|
406
|
+
${others.map(e => h`
|
|
407
|
+
<tr data-key=${`o${e.address}`} data-action="open-at"
|
|
408
|
+
data-keyhex=${keyHex} data-addr=${e.address}>
|
|
409
|
+
<td class="mono dim">${e.codecType}</td>
|
|
410
|
+
<td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
|
|
411
|
+
<td class="mono dim">@${e.address}</td>
|
|
412
|
+
</tr>
|
|
413
|
+
`)}
|
|
414
|
+
</tbody>
|
|
415
|
+
</table>
|
|
416
|
+
</details>
|
|
426
417
|
`
|
|
427
418
|
}
|
|
428
419
|
|
|
@@ -439,15 +430,15 @@ function AtView ({ keyHex, address }) {
|
|
|
439
430
|
if (!repo) return h`<div class="empty">opening…</div>`
|
|
440
431
|
|
|
441
432
|
// Resolve HEAD (symbolic) to the most-recent sig address. If the
|
|
442
|
-
// repo has no
|
|
443
|
-
// surfaces
|
|
433
|
+
// repo has no commits yet, render a useful "no HEAD" page that
|
|
434
|
+
// still surfaces any storage chunks.
|
|
444
435
|
let resolvedAddr = address
|
|
445
436
|
if (address === 'HEAD') {
|
|
446
437
|
resolvedAddr = resolveHead(repo)
|
|
447
438
|
if (resolvedAddr === undefined) {
|
|
448
439
|
return h`
|
|
449
|
-
<h2>at HEAD <span class="dim">(no
|
|
450
|
-
<div class="empty">this repo
|
|
440
|
+
<h2>at HEAD <span class="dim">(no commits yet)</span></h2>
|
|
441
|
+
<div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
|
|
451
442
|
${repoExtras(repo, keyHex)}
|
|
452
443
|
`
|
|
453
444
|
}
|
|
@@ -462,8 +453,11 @@ function AtView ({ keyHex, address }) {
|
|
|
462
453
|
const isCommit = isCommitShape(decoded)
|
|
463
454
|
const isSig = codecType === 'SIGNATURE'
|
|
464
455
|
|
|
465
|
-
// Tabs are part of the page content (not the static header) so
|
|
466
|
-
//
|
|
456
|
+
// Tabs are part of the page content (not the static header) so the
|
|
457
|
+
// commit selector renders ABOVE the tabs. The selector is always
|
|
458
|
+
// present (when the repo has any commits) so the UI doesn't shift
|
|
459
|
+
// as you click between commit pages and storage drilling — when
|
|
460
|
+
// the current address isn't a commit, the summary shows "detached".
|
|
467
461
|
const tabs = h`
|
|
468
462
|
<nav class="tabs">
|
|
469
463
|
<a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
|
|
@@ -472,7 +466,7 @@ function AtView ({ keyHex, address }) {
|
|
|
472
466
|
data-action="set-tab" data-tab="storage">storage</a>
|
|
473
467
|
</nav>
|
|
474
468
|
`
|
|
475
|
-
const selector =
|
|
469
|
+
const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
|
|
476
470
|
|
|
477
471
|
// Storage tab: spatial view of where this chunk lives in the byte
|
|
478
472
|
// stream + outgoing references + this chunk's bytes + incoming
|
|
@@ -488,37 +482,84 @@ function AtView ({ keyHex, address }) {
|
|
|
488
482
|
`
|
|
489
483
|
}
|
|
490
484
|
|
|
485
|
+
// Every value-tab branch below prepends ${selector}${tabs} so the
|
|
486
|
+
// UI is stable across navigation: the selector is always at the
|
|
487
|
+
// top of the page when the repo has any sigs.
|
|
488
|
+
|
|
491
489
|
// Value tab — branches by codec.
|
|
490
|
+
// Helper: render the kv-table of decoded fields for any Object/Array
|
|
491
|
+
// (including commits, which are just OBJECTs with a known shape).
|
|
492
|
+
// Inline children render their value directly; addressable children
|
|
493
|
+
// get a clickable @addr link in the third column.
|
|
494
|
+
const refsTable = () => {
|
|
495
|
+
if (!refs || typeof refs !== 'object') return null
|
|
496
|
+
const isArray = Array.isArray(refs)
|
|
497
|
+
const fieldEntries = isArray
|
|
498
|
+
? refs.map((addr, i) => [String(i), addr])
|
|
499
|
+
: Object.entries(refs)
|
|
500
|
+
if (fieldEntries.length === 0) return h`<div class="empty">${isArray ? '[]' : '{}'}</div>`
|
|
501
|
+
return h`
|
|
502
|
+
<table class="kv clickable">
|
|
503
|
+
<tbody>
|
|
504
|
+
${fieldEntries.map(([k, childAddr]) => {
|
|
505
|
+
if (childAddr === undefined) {
|
|
506
|
+
const inlineValue = isArray ? decoded[+k] : decoded[k]
|
|
507
|
+
return h`
|
|
508
|
+
<tr>
|
|
509
|
+
<td class="mono">${k}</td>
|
|
510
|
+
<td>${previewValue(inlineValue)}</td>
|
|
511
|
+
<td class="dim">(inline)</td>
|
|
512
|
+
</tr>
|
|
513
|
+
`
|
|
514
|
+
}
|
|
515
|
+
let preview = ''
|
|
516
|
+
try { preview = previewValue(repo.decode(childAddr)) }
|
|
517
|
+
catch { preview = '(error)' }
|
|
518
|
+
return h`
|
|
519
|
+
<tr data-key=${k} data-action="open-at"
|
|
520
|
+
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
521
|
+
<td class="mono">${k}</td>
|
|
522
|
+
<td>${preview}</td>
|
|
523
|
+
<td class="mono dim">@${childAddr}</td>
|
|
524
|
+
</tr>
|
|
525
|
+
`
|
|
526
|
+
})}
|
|
527
|
+
</tbody>
|
|
528
|
+
</table>
|
|
529
|
+
`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Commit: same direct kv-table format as Object (it *is* an OBJECT —
|
|
533
|
+
// user requested the "dumber" version that names every field rather
|
|
534
|
+
// than packing them into a polished headline). Banner shows the
|
|
535
|
+
// verify state from the covering sig; the verification table at the
|
|
536
|
+
// bottom links to that sig and shows its bytes.
|
|
492
537
|
if (isCommit) {
|
|
538
|
+
const covering = findCoveringSig(repo, resolvedAddr)
|
|
493
539
|
const parentDataAddr = decoded.parent !== undefined
|
|
494
540
|
? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
|
|
495
541
|
: undefined
|
|
496
542
|
const changes = parentDataAddr !== undefined
|
|
497
543
|
? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
|
|
498
544
|
: null
|
|
545
|
+
const banner = kindBanner(
|
|
546
|
+
covering ? 'signed commit' : 'commit (unsigned)',
|
|
547
|
+
covering
|
|
548
|
+
? () => {
|
|
549
|
+
dep()
|
|
550
|
+
const status = verifyStatus(repo, keyHex, covering.decoded, covering.sigAddress)
|
|
551
|
+
return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
|
|
552
|
+
}
|
|
553
|
+
: h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
|
|
554
|
+
covering ? 'verified' : 'unsigned'
|
|
555
|
+
)
|
|
499
556
|
return h`
|
|
557
|
+
${selector}
|
|
500
558
|
${tabs}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
<tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
|
|
506
|
-
<tr>
|
|
507
|
-
<td>dataAddress</td>
|
|
508
|
-
<td><a class="addr-link" data-action="open-at"
|
|
509
|
-
data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
|
|
510
|
-
>@${decoded.dataAddress}</a></td>
|
|
511
|
-
</tr>
|
|
512
|
-
<tr>
|
|
513
|
-
<td>parent</td>
|
|
514
|
-
<td>${decoded.parent === undefined
|
|
515
|
-
? h`<span class="dim">(none — first commit)</span>`
|
|
516
|
-
: h`<a class="addr-link" data-action="open-at"
|
|
517
|
-
data-keyhex=${keyHex} data-addr=${decoded.parent}
|
|
518
|
-
>@${decoded.parent}</a>`}</td>
|
|
519
|
-
</tr>
|
|
520
|
-
</tbody>
|
|
521
|
-
</table>
|
|
559
|
+
${banner}
|
|
560
|
+
${refsTable()}
|
|
561
|
+
<h3>rehydrated</h3>
|
|
562
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
522
563
|
${changes
|
|
523
564
|
? h`
|
|
524
565
|
<h3>changed paths <span class="dim">(${changes.length})</span></h3>
|
|
@@ -527,16 +568,32 @@ function AtView ({ keyHex, address }) {
|
|
|
527
568
|
: h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
|
|
528
569
|
`
|
|
529
570
|
: null}
|
|
530
|
-
|
|
531
|
-
|
|
571
|
+
${covering ? h`
|
|
572
|
+
<h3>verification</h3>
|
|
573
|
+
<table class="kv">
|
|
574
|
+
<tbody>
|
|
575
|
+
<tr>
|
|
576
|
+
<td>signature</td>
|
|
577
|
+
<td><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${covering.sigAddress}>@${covering.sigAddress}</a></td>
|
|
578
|
+
</tr>
|
|
579
|
+
<tr>
|
|
580
|
+
<td>covers</td>
|
|
581
|
+
<td>@${covering.signedFrom} through @${covering.signedTo} (${covering.signedTo - covering.signedFrom + 1} bytes)</td>
|
|
582
|
+
</tr>
|
|
583
|
+
<tr><td>sig bytes</td><td class="mono">${truncHex(covering.decoded.compactRawBytes, 32)}</td></tr>
|
|
584
|
+
</tbody>
|
|
585
|
+
</table>
|
|
586
|
+
` : null}
|
|
587
|
+
${repoExtras(repo, keyHex)}
|
|
532
588
|
`
|
|
533
589
|
}
|
|
534
590
|
|
|
535
591
|
// Duple: explain what this tree-node IS, then show its two children.
|
|
536
592
|
if (codecType === 'DUPLE') {
|
|
537
593
|
return h`
|
|
594
|
+
${selector}
|
|
538
595
|
${tabs}
|
|
539
|
-
|
|
596
|
+
${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
|
|
540
597
|
<p class="explainer">
|
|
541
598
|
A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
|
|
542
599
|
to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
|
|
@@ -555,14 +612,24 @@ function AtView ({ keyHex, address }) {
|
|
|
555
612
|
`
|
|
556
613
|
}
|
|
557
614
|
|
|
558
|
-
// Signature: the
|
|
559
|
-
//
|
|
615
|
+
// Signature: the sig-detail page (auxiliary in the new model — sigs
|
|
616
|
+
// are how commits are verified, not the user-level unit). Lists
|
|
617
|
+
// the commits this sig covers; pick one to land on its commit page.
|
|
560
618
|
if (isSig) {
|
|
619
|
+
const banner = kindBanner(
|
|
620
|
+
'signature chunk',
|
|
621
|
+
() => {
|
|
622
|
+
dep()
|
|
623
|
+
const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
|
|
624
|
+
return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
|
|
625
|
+
},
|
|
626
|
+
'verified'
|
|
627
|
+
)
|
|
561
628
|
return h`
|
|
562
629
|
${selector}
|
|
563
630
|
${tabs}
|
|
564
|
-
${
|
|
565
|
-
${
|
|
631
|
+
${banner}
|
|
632
|
+
${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
|
|
566
633
|
<div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
|
|
567
634
|
`
|
|
568
635
|
}
|
|
@@ -570,59 +637,30 @@ function AtView ({ keyHex, address }) {
|
|
|
570
637
|
// Object/array: clickable children with their addresses.
|
|
571
638
|
if (refs && typeof refs === 'object') {
|
|
572
639
|
const isArray = Array.isArray(refs)
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
<div class="empty">${isArray ? '[]' : '{}'}</div>
|
|
581
|
-
`
|
|
582
|
-
}
|
|
640
|
+
const fieldCount = isArray ? refs.length : Object.keys(refs).length
|
|
641
|
+
const dim = fieldCount === 0
|
|
642
|
+
? null
|
|
643
|
+
: h`<span class="dim">${isArray ? `length ${fieldCount}` : `${fieldCount} field${fieldCount === 1 ? '' : 's'}`}</span>`
|
|
644
|
+
const label = fieldCount === 0
|
|
645
|
+
? (isArray ? 'empty array' : 'empty object')
|
|
646
|
+
: (isArray ? 'array' : 'object')
|
|
583
647
|
return h`
|
|
648
|
+
${selector}
|
|
584
649
|
${tabs}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
// Show those non-clickably with the decoded value pulled from
|
|
592
|
-
// the parent.
|
|
593
|
-
if (childAddr === undefined) {
|
|
594
|
-
const inlineValue = isArray ? decoded[+k] : decoded[k]
|
|
595
|
-
return h`
|
|
596
|
-
<tr>
|
|
597
|
-
<td class="mono">${k}</td>
|
|
598
|
-
<td>${previewValue(inlineValue)}</td>
|
|
599
|
-
<td class="dim">(inline)</td>
|
|
600
|
-
</tr>
|
|
601
|
-
`
|
|
602
|
-
}
|
|
603
|
-
let preview = ''
|
|
604
|
-
try { preview = previewValue(repo.decode(childAddr)) }
|
|
605
|
-
catch { preview = '(error)' }
|
|
606
|
-
return h`
|
|
607
|
-
<tr data-key=${k} data-action="open-at"
|
|
608
|
-
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
609
|
-
<td class="mono">${k}</td>
|
|
610
|
-
<td>${preview}</td>
|
|
611
|
-
<td class="mono dim">@${childAddr}</td>
|
|
612
|
-
</tr>
|
|
613
|
-
`
|
|
614
|
-
})}
|
|
615
|
-
</tbody>
|
|
616
|
-
</table>
|
|
617
|
-
<h3>rehydrated</h3>
|
|
618
|
-
<pre class="value">${safeJSON(decoded)}</pre>
|
|
650
|
+
${kindBanner(label, dim)}
|
|
651
|
+
${refsTable()}
|
|
652
|
+
${fieldCount > 0 ? h`
|
|
653
|
+
<h3>rehydrated</h3>
|
|
654
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
655
|
+
` : null}
|
|
619
656
|
`
|
|
620
657
|
}
|
|
621
658
|
|
|
622
659
|
// Primitive: just show it.
|
|
623
660
|
return h`
|
|
661
|
+
${selector}
|
|
624
662
|
${tabs}
|
|
625
|
-
|
|
663
|
+
${kindBanner(codecType.toLowerCase())}
|
|
626
664
|
<pre class="value">${safeJSON(decoded)}</pre>
|
|
627
665
|
`
|
|
628
666
|
}}
|
|
@@ -925,6 +963,28 @@ function verifyStatus (repo, keyHex, sig, sigAddress) {
|
|
|
925
963
|
return 'pending'
|
|
926
964
|
}
|
|
927
965
|
|
|
966
|
+
// Consistent "what this is" banner at the top of every value-tab branch.
|
|
967
|
+
// label is the short codec/role name (e.g. "signed commit", "object",
|
|
968
|
+
// "duple"); content is whatever else goes in the banner (verify badge +
|
|
969
|
+
// label, field count, etc.); variant tints the surface — 'verified' for
|
|
970
|
+
// commits/sigs with a covering signature, 'unsigned' for commits awaiting
|
|
971
|
+
// one, undefined for everything else.
|
|
972
|
+
function kindBanner (label, content, variant) {
|
|
973
|
+
return h`
|
|
974
|
+
<div class=${['kind-banner', variant || null]}>
|
|
975
|
+
<span class="kind-label">${label}</span>
|
|
976
|
+
${content || null}
|
|
977
|
+
</div>
|
|
978
|
+
`
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function verifyLabel (status) {
|
|
982
|
+
if (status === 'valid') return 'verified — bytes match this repo’s public key'
|
|
983
|
+
if (status === 'invalid') return 'NOT VERIFIED — bytes do not match the repo key'
|
|
984
|
+
if (status === 'pending') return 'verifying…'
|
|
985
|
+
return `error: ${status?.error ?? 'unknown'}`
|
|
986
|
+
}
|
|
987
|
+
|
|
928
988
|
function verifyBadge (status) {
|
|
929
989
|
if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
|
|
930
990
|
if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
|
|
@@ -126,7 +126,7 @@ rl.on('line', async line => {
|
|
|
126
126
|
const messages = myRepo.get('messages') ?? []
|
|
127
127
|
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
128
128
|
myRepo.defaultMessage = `"${preview}" (cli)`
|
|
129
|
-
myRepo.set({ name: username, messages: [...messages, { text, at: Date
|
|
129
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
130
130
|
rl.prompt()
|
|
131
131
|
})
|
|
132
132
|
|