@nsite/stealthis 0.1.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/dist/nsite-deploy.js +546 -0
- package/dist/nsite-deploy.mjs +4999 -0
- package/package.json +25 -0
- package/src/index.ts +17 -0
- package/src/nostr.ts +296 -0
- package/src/qr.ts +8 -0
- package/src/signer.ts +90 -0
- package/src/styles.ts +440 -0
- package/src/widget.ts +629 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +13 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nsite/stealthis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/stealthis.js",
|
|
6
|
+
"module": "dist/stealthis.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"default": "./dist/stealthis.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "vite build --watch",
|
|
15
|
+
"build": "vite build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"nostr-tools": "^2.23.3",
|
|
19
|
+
"qrcode-generator": "^1.4.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"vite": "^7.3.1"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NsiteDeployButton } from './widget';
|
|
2
|
+
|
|
3
|
+
customElements.define('nsite-deploy', NsiteDeployButton);
|
|
4
|
+
|
|
5
|
+
function autoInject() {
|
|
6
|
+
if (!document.querySelector('nsite-deploy')) {
|
|
7
|
+
const el = document.createElement('nsite-deploy');
|
|
8
|
+
el.classList.add('nd-fixed');
|
|
9
|
+
document.body.appendChild(el);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (document.readyState === 'loading') {
|
|
14
|
+
document.addEventListener('DOMContentLoaded', autoInject);
|
|
15
|
+
} else {
|
|
16
|
+
autoInject();
|
|
17
|
+
}
|
package/src/nostr.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { npubEncode } from 'nostr-tools/nip19';
|
|
2
|
+
import type { SignedEvent, EventTemplate } from './signer';
|
|
3
|
+
|
|
4
|
+
export type { SignedEvent, EventTemplate };
|
|
5
|
+
|
|
6
|
+
export interface NsiteContext {
|
|
7
|
+
pubkey: string;
|
|
8
|
+
identifier?: string;
|
|
9
|
+
baseDomain: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Thief {
|
|
13
|
+
index: number;
|
|
14
|
+
pubkey: string;
|
|
15
|
+
relays: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { npubEncode };
|
|
19
|
+
|
|
20
|
+
const BOOTSTRAP_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
|
|
21
|
+
const B36_LEN = 50;
|
|
22
|
+
const D_TAG_RE = /^[a-z0-9-]{1,13}$/;
|
|
23
|
+
const NAMED_LABEL_RE = /^[0-9a-z]{50}[a-z0-9-]{1,13}$/;
|
|
24
|
+
|
|
25
|
+
// --- Base36 ---
|
|
26
|
+
|
|
27
|
+
export function pubkeyToBase36(hex: string): string {
|
|
28
|
+
return BigInt('0x' + hex)
|
|
29
|
+
.toString(36)
|
|
30
|
+
.padStart(B36_LEN, '0');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function base36ToHex(b36: string): string {
|
|
34
|
+
let n = 0n;
|
|
35
|
+
for (const c of b36) n = n * 36n + BigInt(parseInt(c, 36));
|
|
36
|
+
return n.toString(16).padStart(64, '0');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Minimal bech32 decode (npub only) ---
|
|
40
|
+
|
|
41
|
+
function npubDecode(npub: string): string | null {
|
|
42
|
+
if (!npub.startsWith('npub1')) return null;
|
|
43
|
+
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
44
|
+
const values: number[] = [];
|
|
45
|
+
for (const c of npub.slice(5)) {
|
|
46
|
+
const v = CHARSET.indexOf(c);
|
|
47
|
+
if (v === -1) return null;
|
|
48
|
+
values.push(v);
|
|
49
|
+
}
|
|
50
|
+
const payload = values.slice(0, -6);
|
|
51
|
+
let acc = 0,
|
|
52
|
+
bits = 0;
|
|
53
|
+
const bytes: number[] = [];
|
|
54
|
+
for (const v of payload) {
|
|
55
|
+
acc = (acc << 5) | v;
|
|
56
|
+
bits += 5;
|
|
57
|
+
while (bits >= 8) {
|
|
58
|
+
bits -= 8;
|
|
59
|
+
bytes.push((acc >> bits) & 0xff);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (bytes.length !== 32) return null;
|
|
63
|
+
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Context ---
|
|
67
|
+
|
|
68
|
+
export function parseContext(): NsiteContext | null {
|
|
69
|
+
const parts = window.location.hostname.split('.');
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < parts.length; i++) {
|
|
72
|
+
if (parts[i].startsWith('npub1') && parts[i].length >= 63) {
|
|
73
|
+
const pubkey = npubDecode(parts[i]);
|
|
74
|
+
if (pubkey) return { pubkey, baseDomain: parts.slice(i + 1).join('.') };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const label = parts[0];
|
|
79
|
+
if (
|
|
80
|
+
label &&
|
|
81
|
+
label.length > B36_LEN &&
|
|
82
|
+
label.length <= 63 &&
|
|
83
|
+
NAMED_LABEL_RE.test(label) &&
|
|
84
|
+
!label.endsWith('-')
|
|
85
|
+
) {
|
|
86
|
+
try {
|
|
87
|
+
const pubkey = base36ToHex(label.slice(0, B36_LEN));
|
|
88
|
+
return { pubkey, identifier: label.slice(B36_LEN), baseDomain: parts.slice(1).join('.') };
|
|
89
|
+
} catch {
|
|
90
|
+
/* invalid */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isValidDTag(s: string): boolean {
|
|
98
|
+
return D_TAG_RE.test(s) && !s.endsWith('-');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Relay communication ---
|
|
102
|
+
|
|
103
|
+
interface RelayEvent {
|
|
104
|
+
id: string;
|
|
105
|
+
pubkey: string;
|
|
106
|
+
created_at: number;
|
|
107
|
+
kind: number;
|
|
108
|
+
tags: string[][];
|
|
109
|
+
content: string;
|
|
110
|
+
sig: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function withSocket(
|
|
114
|
+
url: string,
|
|
115
|
+
sendMsg: unknown[],
|
|
116
|
+
onMsg: (data: unknown[]) => boolean,
|
|
117
|
+
timeout = 5000
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
try {
|
|
121
|
+
const ws = new WebSocket(url);
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
try { ws.close(); } catch { /* */ }
|
|
124
|
+
resolve();
|
|
125
|
+
}, timeout);
|
|
126
|
+
const finish = () => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
try { ws.close(); } catch { /* */ }
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
ws.onopen = () => ws.send(JSON.stringify(sendMsg));
|
|
132
|
+
ws.onmessage = (e) => {
|
|
133
|
+
try { if (onMsg(JSON.parse(e.data))) finish(); } catch { /* */ }
|
|
134
|
+
};
|
|
135
|
+
ws.onerror = () => finish();
|
|
136
|
+
} catch {
|
|
137
|
+
resolve();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function queryRelays(urls: string[], filter: Record<string, unknown>): Promise<RelayEvent[]> {
|
|
143
|
+
const events = new Map<string, RelayEvent>();
|
|
144
|
+
const subId = Math.random().toString(36).slice(2, 8);
|
|
145
|
+
await Promise.allSettled(
|
|
146
|
+
urls.map((url) =>
|
|
147
|
+
withSocket(url, ['REQ', subId, filter], (msg) => {
|
|
148
|
+
if (msg[0] === 'EVENT' && msg[1] === subId)
|
|
149
|
+
events.set((msg[2] as RelayEvent).id, msg[2] as RelayEvent);
|
|
150
|
+
return msg[0] === 'EOSE' && msg[1] === subId;
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
return [...events.values()];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function publishRelay(url: string, event: SignedEvent): Promise<boolean> {
|
|
158
|
+
let ok = false;
|
|
159
|
+
await withSocket(url, ['EVENT', event], (msg) => {
|
|
160
|
+
if (msg[0] === 'OK') {
|
|
161
|
+
ok = msg[2] === true;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
});
|
|
166
|
+
return ok;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function publishToRelays(urls: string[], event: SignedEvent): Promise<number> {
|
|
170
|
+
const results = await Promise.allSettled(urls.map((url) => publishRelay(url, event)));
|
|
171
|
+
return results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- High-level operations ---
|
|
175
|
+
|
|
176
|
+
function extractWriteRelays(events: RelayEvent[]): string[] {
|
|
177
|
+
const relays = new Set<string>();
|
|
178
|
+
for (const e of events) {
|
|
179
|
+
for (const t of e.tags) {
|
|
180
|
+
if (t[0] === 'r' && t[1]?.startsWith('wss://') && (!t[2] || t[2] === 'write'))
|
|
181
|
+
relays.add(t[1].trim());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...relays];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function fetchManifest(ctx: NsiteContext): Promise<RelayEvent | null> {
|
|
188
|
+
const manifestFilter = ctx.identifier
|
|
189
|
+
? { kinds: [35128], authors: [ctx.pubkey], '#d': [ctx.identifier] }
|
|
190
|
+
: { kinds: [15128], authors: [ctx.pubkey], limit: 1 };
|
|
191
|
+
|
|
192
|
+
// Query bootstrap relays for manifest AND relay list in parallel
|
|
193
|
+
const [bootstrapManifests, relayEvents] = await Promise.all([
|
|
194
|
+
queryRelays(BOOTSTRAP_RELAYS, manifestFilter),
|
|
195
|
+
queryRelays(BOOTSTRAP_RELAYS, { kinds: [10002], authors: [ctx.pubkey], limit: 5 })
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
// If bootstrap already found it, return immediately
|
|
199
|
+
if (bootstrapManifests.length > 0) {
|
|
200
|
+
return bootstrapManifests.sort((a, b) => b.created_at - a.created_at)[0];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Otherwise try the owner's relays
|
|
204
|
+
const ownerRelays = extractWriteRelays(relayEvents).filter(
|
|
205
|
+
(r) => !BOOTSTRAP_RELAYS.includes(r)
|
|
206
|
+
);
|
|
207
|
+
if (ownerRelays.length === 0) return null;
|
|
208
|
+
|
|
209
|
+
const events = await queryRelays(ownerRelays, manifestFilter);
|
|
210
|
+
return events.sort((a, b) => b.created_at - a.created_at)[0] ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function getWriteRelays(pubkey: string): Promise<string[]> {
|
|
214
|
+
const events = await queryRelays(BOOTSTRAP_RELAYS, {
|
|
215
|
+
kinds: [10002],
|
|
216
|
+
authors: [pubkey],
|
|
217
|
+
limit: 5
|
|
218
|
+
});
|
|
219
|
+
const relays = extractWriteRelays(events);
|
|
220
|
+
return relays.length > 0 ? relays : BOOTSTRAP_RELAYS.filter((r) => r !== 'wss://purplepag.es');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function checkExistingSite(
|
|
224
|
+
relays: string[],
|
|
225
|
+
pubkey: string,
|
|
226
|
+
slug?: string
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const filter = slug
|
|
229
|
+
? { kinds: [35128], authors: [pubkey], '#d': [slug], limit: 1 }
|
|
230
|
+
: { kinds: [15128], authors: [pubkey], limit: 1 };
|
|
231
|
+
const events = await queryRelays(relays, filter);
|
|
232
|
+
return events.length > 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const MAX_THIEF_TAGS = 9;
|
|
236
|
+
|
|
237
|
+
export function extractThieves(event: RelayEvent): Thief[] {
|
|
238
|
+
return event.tags
|
|
239
|
+
.filter((t) => t[0] === 'thief' && t[1] && t[2])
|
|
240
|
+
.map((t) => ({ index: parseInt(t[1], 10), pubkey: t[2], relays: t.slice(3) }))
|
|
241
|
+
.sort((a, b) => a.index - b.index);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function createDeployEvent(
|
|
245
|
+
source: RelayEvent,
|
|
246
|
+
options: {
|
|
247
|
+
slug?: string;
|
|
248
|
+
title?: string;
|
|
249
|
+
description?: string;
|
|
250
|
+
deployerPubkey: string;
|
|
251
|
+
deployerRelays: string[];
|
|
252
|
+
}
|
|
253
|
+
): EventTemplate {
|
|
254
|
+
const tags: string[][] = [];
|
|
255
|
+
if (options.slug) tags.push(['d', options.slug]);
|
|
256
|
+
for (const t of source.tags) {
|
|
257
|
+
if (t[0] === 'path' || t[0] === 'server') tags.push([...t]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Paper trail: copy thief tags, add new one, enforce max 9
|
|
261
|
+
const sourceThieves = source.tags
|
|
262
|
+
.filter((t) => t[0] === 'thief' && t[1] && t[2])
|
|
263
|
+
.map((t) => [...t])
|
|
264
|
+
.sort((a, b) => parseInt(a[1], 10) - parseInt(b[1], 10));
|
|
265
|
+
|
|
266
|
+
const maxIndex = sourceThieves.length > 0
|
|
267
|
+
? Math.max(...sourceThieves.map((t) => parseInt(t[1], 10)))
|
|
268
|
+
: -1;
|
|
269
|
+
const newThief = ['thief', String(maxIndex + 1), options.deployerPubkey, ...options.deployerRelays];
|
|
270
|
+
const allThieves = [...sourceThieves, newThief];
|
|
271
|
+
|
|
272
|
+
// Keep index 0 (originator) + newest, FIFO truncate the middle
|
|
273
|
+
if (allThieves.length > MAX_THIEF_TAGS) {
|
|
274
|
+
const originator = allThieves[0];
|
|
275
|
+
const keep = allThieves.slice(allThieves.length - (MAX_THIEF_TAGS - 1));
|
|
276
|
+
tags.push(originator, ...keep);
|
|
277
|
+
} else {
|
|
278
|
+
for (const t of allThieves) tags.push(t);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (options.title) tags.push(['title', options.title]);
|
|
282
|
+
if (options.description) tags.push(['description', options.description]);
|
|
283
|
+
return {
|
|
284
|
+
kind: options.slug ? 35128 : 15128,
|
|
285
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
286
|
+
tags,
|
|
287
|
+
content: ''
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function buildSiteUrl(baseDomain: string, pubkey: string, slug?: string): string {
|
|
292
|
+
if (slug) {
|
|
293
|
+
return `https://${pubkeyToBase36(pubkey)}${slug}.${baseDomain}`;
|
|
294
|
+
}
|
|
295
|
+
return `https://${npubEncode(pubkey)}.${baseDomain}`;
|
|
296
|
+
}
|
package/src/qr.ts
ADDED
package/src/signer.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
2
|
+
import {
|
|
3
|
+
BunkerSigner as NtBunkerSigner,
|
|
4
|
+
parseBunkerInput,
|
|
5
|
+
createNostrConnectURI
|
|
6
|
+
} from 'nostr-tools/nip46';
|
|
7
|
+
|
|
8
|
+
export interface EventTemplate {
|
|
9
|
+
kind: number;
|
|
10
|
+
created_at: number;
|
|
11
|
+
tags: string[][];
|
|
12
|
+
content: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SignedEvent extends EventTemplate {
|
|
16
|
+
id: string;
|
|
17
|
+
pubkey: string;
|
|
18
|
+
sig: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Signer {
|
|
22
|
+
getPublicKey(): Promise<string>;
|
|
23
|
+
signEvent(event: EventTemplate): Promise<SignedEvent>;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
declare global {
|
|
28
|
+
interface Window {
|
|
29
|
+
nostr?: {
|
|
30
|
+
getPublicKey(): Promise<string>;
|
|
31
|
+
signEvent(event: EventTemplate): Promise<SignedEvent>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_NIP46_RELAY = 'wss://bucket.coracle.social';
|
|
37
|
+
|
|
38
|
+
export function hasExtension(): boolean {
|
|
39
|
+
return !!window.nostr;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extensionSigner(): Signer {
|
|
43
|
+
if (!window.nostr) throw new Error('No Nostr signer extension found');
|
|
44
|
+
const ext = window.nostr;
|
|
45
|
+
return {
|
|
46
|
+
getPublicKey: () => ext.getPublicKey(),
|
|
47
|
+
signEvent: (e) => ext.signEvent(e),
|
|
48
|
+
close() {}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function bunkerConnect(input: string): Promise<Signer> {
|
|
53
|
+
const sk = generateSecretKey();
|
|
54
|
+
const bp = await parseBunkerInput(input);
|
|
55
|
+
if (!bp) throw new Error('Invalid bunker URI');
|
|
56
|
+
const signer = NtBunkerSigner.fromBunker(sk, bp);
|
|
57
|
+
await signer.connect();
|
|
58
|
+
return wrap(signer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function prepareNostrConnect(relay: string) {
|
|
62
|
+
const sk = generateSecretKey();
|
|
63
|
+
const clientPubkey = getPublicKey(sk);
|
|
64
|
+
const secret = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
|
|
65
|
+
b.toString(16).padStart(2, '0')
|
|
66
|
+
).join('');
|
|
67
|
+
|
|
68
|
+
const uri = createNostrConnectURI({
|
|
69
|
+
clientPubkey,
|
|
70
|
+
relays: [relay],
|
|
71
|
+
secret,
|
|
72
|
+
name: 'nsite deploy'
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
uri,
|
|
77
|
+
async connect(abort?: AbortSignal): Promise<Signer> {
|
|
78
|
+
const signer = await NtBunkerSigner.fromURI(sk, uri, {}, abort ?? 300_000);
|
|
79
|
+
return wrap(signer);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function wrap(signer: NtBunkerSigner): Signer {
|
|
85
|
+
return {
|
|
86
|
+
getPublicKey: () => signer.getPublicKey(),
|
|
87
|
+
signEvent: (e) => signer.signEvent(e) as Promise<SignedEvent>,
|
|
88
|
+
close: () => signer.close()
|
|
89
|
+
};
|
|
90
|
+
}
|