meta-sovereign 0.53.2 → 0.53.3

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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/js/src/server/local-certificate-check.js +82 -0
  3. package/js/src/server/local-tls.js +14 -19
  4. package/js/src/sources/telegram-chat-url.js +20 -8
  5. package/js/src/storage/doublets-mirror.js +19 -3
  6. package/js/src/storage/dual-store.js +11 -1
  7. package/js/src/web/app-navigation.js +61 -0
  8. package/js/src/web/app-route.js +205 -0
  9. package/js/src/web/app.css +233 -5
  10. package/js/src/web/app.js +14 -39
  11. package/js/src/web/app.min.js +92 -92
  12. package/js/src/web/automations-view.js +2 -2
  13. package/js/src/web/chat-archive-media.js +12 -4
  14. package/js/src/web/chat-calls.js +6 -2
  15. package/js/src/web/chat-history-load.js +1 -0
  16. package/js/src/web/chat-invite.js +3 -5
  17. package/js/src/web/chat-media.js +160 -62
  18. package/js/src/web/chat-message.js +3 -1
  19. package/js/src/web/chat-model.js +8 -1
  20. package/js/src/web/chat-photo.js +84 -71
  21. package/js/src/web/chat-remote-media.js +5 -2
  22. package/js/src/web/chat-round.js +16 -1
  23. package/js/src/web/chat-state.js +30 -16
  24. package/js/src/web/chat-view.js +58 -45
  25. package/js/src/web/chat-web-apps.js +18 -12
  26. package/js/src/web/locales/en-chat-more.js +8 -0
  27. package/js/src/web/locales/en.js +3 -2
  28. package/js/src/web/locales/hi-chat-more.js +8 -0
  29. package/js/src/web/locales/hi.js +3 -2
  30. package/js/src/web/locales/ru-chat-more.js +9 -0
  31. package/js/src/web/locales/ru.js +3 -2
  32. package/js/src/web/locales/zh-chat-more.js +8 -0
  33. package/js/src/web/locales/zh.js +3 -2
  34. package/js/src/web/media-loading.js +176 -0
  35. package/js/src/web/operator-model.js +11 -4
  36. package/js/src/web/operator-order.js +46 -0
  37. package/js/src/web/operator-prefs.js +1 -0
  38. package/js/src/web/operator-unread.js +2 -2
  39. package/js/src/web/operator-view.js +46 -15
  40. package/js/src/web/peer-avatar.js +14 -1
  41. package/js/src/web/person-panel.js +15 -8
  42. package/js/src/web/spinner.js +7 -2
  43. package/js/src/web/views.js +12 -18
  44. package/js/src/web/web-app-loading.js +81 -0
  45. package/js/src/web/web-apps-model.js +20 -1
  46. package/package.json +8 -2
package/README.md CHANGED
@@ -370,3 +370,5 @@ The CI/CD pipeline inherited from `js-ai-driven-development-pipeline-template` c
370
370
  ## License
371
371
 
372
372
  [Unlicense](LICENSE) - Public Domain
373
+
374
+ Reloads and browser history preserve your section and conversation context; see [navigation](docs/NAVIGATION.md). [Mini App recovery](docs/MINI-APPS.md) documents SDK compatibility and external-site limitations.
@@ -0,0 +1,82 @@
1
+ // Verify our ECDSA certificate using portable crypto primitives. Deno exposes
2
+ // X509Certificate but omits verify(), checkPrivateKey() and subjectAltName.
3
+ import crypto from 'node:crypto';
4
+
5
+ const der = (data, start = 0) => {
6
+ const tag = data[start];
7
+ let length = data[start + 1];
8
+ let offset = start + 2;
9
+ if (length & 0x80) {
10
+ const count = length & 0x7f;
11
+ if (!count || count > 4) {
12
+ throw new Error('invalid DER length');
13
+ }
14
+ length = 0;
15
+ for (let n = 0; n < count; n++) {
16
+ length = length * 256 + data[offset++];
17
+ }
18
+ }
19
+ const end = offset + length;
20
+ if (!Number.isInteger(end) || end > data.length) {
21
+ throw new Error('truncated DER');
22
+ }
23
+ return {
24
+ tag,
25
+ end,
26
+ raw: data.subarray(start, end),
27
+ body: data.subarray(offset, end),
28
+ };
29
+ };
30
+ const children = (data) => {
31
+ const parts = [];
32
+ for (let offset = 0; offset < data.length;) {
33
+ const part = der(data, offset);
34
+ parts.push(part);
35
+ offset = part.end;
36
+ }
37
+ return parts;
38
+ };
39
+ const SAN_OID = Buffer.from('0603551d11', 'hex');
40
+ const ECDSA_SHA256 = Buffer.from('300a06082a8648ce3d040302', 'hex');
41
+
42
+ export const matchesLocalCertificate = (cert, key, { now, renewMs, san }) => {
43
+ try {
44
+ const x509 = new crypto.X509Certificate(cert);
45
+ if (
46
+ x509.ca ||
47
+ Date.parse(x509.validFrom) > now ||
48
+ Date.parse(x509.validTo) - now <= renewMs
49
+ ) {
50
+ return false;
51
+ }
52
+ const encoded = String(cert).replace(/-----[^-]+-----|\s/g, '');
53
+ const root = der(Buffer.from(encoded, 'base64'));
54
+ const [tbs, algorithm, signature] = children(root.body);
55
+ if (!algorithm.raw.equals(ECDSA_SHA256) || signature.body[0] !== 0) {
56
+ return false;
57
+ }
58
+ const fields = children(tbs.body);
59
+ const extensions = children(
60
+ children(fields.find((field) => field.tag === 0xa3).body)[0].body
61
+ );
62
+ const names = extensions.filter((ext) =>
63
+ children(ext.body)[0].raw.equals(SAN_OID)
64
+ );
65
+ if (names.length !== 1 || !names[0].raw.equals(san)) {
66
+ return false;
67
+ }
68
+ const publicKey = crypto.createPublicKey(crypto.createPrivateKey(key));
69
+ const exportKey = (value) => value.export({ type: 'spki', format: 'der' });
70
+ return (
71
+ exportKey(publicKey).equals(exportKey(x509.publicKey)) &&
72
+ crypto.verify(
73
+ 'sha256',
74
+ tbs.raw,
75
+ x509.publicKey,
76
+ signature.body.subarray(1)
77
+ )
78
+ );
79
+ } catch {
80
+ return false;
81
+ }
82
+ };
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import crypto from 'node:crypto';
20
+ import { matchesLocalCertificate } from './local-certificate-check.js';
20
21
  import { promises as fs } from 'node:fs';
21
22
  import net from 'node:net';
22
23
  import path from 'node:path';
@@ -155,25 +156,19 @@ export const makeLocalCertificate = ({
155
156
  };
156
157
  };
157
158
 
158
- const usable = (cert, key, now) => {
159
- try {
160
- const x509 = new crypto.X509Certificate(cert);
161
- return (
162
- !x509.ca &&
163
- x509.verify(x509.publicKey) &&
164
- x509.checkPrivateKey(crypto.createPrivateKey(key)) &&
165
- Date.parse(x509.validFrom) <= now &&
166
- Date.parse(x509.validTo) - now > RENEW_DAYS * DAY_MS &&
167
- x509.subjectAltName?.split(', ').length === 3 &&
168
- x509.subjectAltName.includes('DNS:localhost') &&
169
- x509.subjectAltName.includes('IP Address:127.0.0.1') &&
170
- (x509.subjectAltName.includes('IP Address:0:0:0:0:0:0:0:1') ||
171
- x509.subjectAltName.includes('IP Address:::1'))
172
- );
173
- } catch {
174
- return false;
175
- }
176
- };
159
+ const usable = (cert, key, now) =>
160
+ matchesLocalCertificate(cert, key, {
161
+ now,
162
+ renewMs: RENEW_DAYS * DAY_MS,
163
+ san: extension(
164
+ '2.5.29.17',
165
+ seq(
166
+ tlv(0x82, Buffer.from('localhost')),
167
+ tlv(0x87, Buffer.from([127, 0, 0, 1])),
168
+ tlv(0x87, LOOPBACK_V6)
169
+ )
170
+ ),
171
+ });
177
172
 
178
173
  /** Where the certificate of a store lives. */
179
174
  export const localTlsPaths = (storeDir) => {
@@ -2,6 +2,17 @@
2
2
  // Use documented username/phone/message links; numeric-only peers use Web A's
3
3
  // signed-in chat route instead of launching a native app without a destination.
4
4
  // References: core.telegram.org/api/links; Ajaxy/telegram-tt src/util/routing.ts.
5
+ const resolvedLink = (handle, number, native) =>
6
+ /^[a-zA-Z][a-zA-Z0-9_]{3,31}$/.test(handle)
7
+ ? native
8
+ ? `tg://resolve?domain=${handle}`
9
+ : `https://t.me/${handle}`
10
+ : /^[1-9]\d{6,14}$/.test(number)
11
+ ? native
12
+ ? `tg://resolve?phone=${number}`
13
+ : `https://t.me/+${number}`
14
+ : null;
15
+
5
16
  export const telegramChatUrl = ({
6
17
  source,
7
18
  chat,
@@ -9,23 +20,24 @@ export const telegramChatUrl = ({
9
20
  phone,
10
21
  lastId,
11
22
  text,
23
+ native = false,
12
24
  } = {}) => {
13
25
  if (source !== 'telegram' || !/^-?[1-9]\d*$/.test(String(chat))) {
14
26
  return null;
15
27
  }
16
28
  const handle = String(username ?? '').replace(/^@/, '');
17
29
  const number = String(phone ?? '').replace(/[\s()+-]/g, '');
18
- const native = /^[a-zA-Z][a-zA-Z0-9_]{3,31}$/.test(handle)
19
- ? `https://t.me/${handle}`
20
- : /^[1-9]\d{6,14}$/.test(number)
21
- ? `https://t.me/+${number}`
22
- : null;
23
- if (native) {
24
- return text ? `${native}?text=${encodeURIComponent(text)}` : native;
30
+ const resolved = resolvedLink(handle, number, native);
31
+ if (resolved) {
32
+ return text
33
+ ? `${resolved}${native ? '&' : '?'}text=${encodeURIComponent(text)}`
34
+ : resolved;
25
35
  }
26
36
  const id = String(chat);
27
37
  if (/^-100\d+$/.test(id) && /^[1-9]\d*$/.test(String(lastId))) {
28
- return `https://t.me/c/${id.slice(4)}/${lastId}`;
38
+ return native
39
+ ? `tg://privatepost?channel=${id.slice(4)}&post=${lastId}`
40
+ : `https://t.me/c/${id.slice(4)}/${lastId}`;
29
41
  }
30
42
  return `https://web.telegram.org/a/#${id}`;
31
43
  };
@@ -42,6 +42,10 @@ export const snapshotInWorker = (from, to) =>
42
42
  let result = null;
43
43
  worker.once('message', (message) => {
44
44
  result = message;
45
+ // The message follows the durable rename. Deno's worker compatibility
46
+ // layer can keep its port alive without emitting an exit event.
47
+ resolve(message);
48
+ Promise.resolve(worker.terminate()).catch(() => {});
45
49
  });
46
50
  worker.once('error', reject);
47
51
  worker.once('exit', (code) => {
@@ -92,6 +96,7 @@ const createSnapshots = ({
92
96
  let lastAt = 0;
93
97
  let lastCost = 0;
94
98
  let failures = 0;
99
+ let closed = false;
95
100
  const once = async () => {
96
101
  const started = clock.now();
97
102
  try {
@@ -115,6 +120,9 @@ const createSnapshots = ({
115
120
  };
116
121
  let later = null;
117
122
  const now = () => {
123
+ if (closed) {
124
+ return Promise.resolve(null);
125
+ }
118
126
  clock.clearTimeout(timer);
119
127
  timer = null;
120
128
  if (queued) {
@@ -123,7 +131,7 @@ const createSnapshots = ({
123
131
  if (!inFlight) {
124
132
  inFlight = once().finally(() => {
125
133
  inFlight = null;
126
- if (wanted && !queued) {
134
+ if (wanted && !queued && !closed) {
127
135
  wanted = false;
128
136
  later();
129
137
  }
@@ -139,7 +147,7 @@ const createSnapshots = ({
139
147
  return queued;
140
148
  };
141
149
  later = () => {
142
- if (timer) {
150
+ if (closed || timer) {
143
151
  return;
144
152
  }
145
153
  if (inFlight || queued) {
@@ -157,7 +165,14 @@ const createSnapshots = ({
157
165
  }, wait);
158
166
  timer.unref?.();
159
167
  };
160
- return { now, later };
168
+ const close = async () => {
169
+ closed = true;
170
+ wanted = false;
171
+ clock.clearTimeout(timer);
172
+ timer = null;
173
+ await (queued ?? inFlight);
174
+ };
175
+ return { now, later, close };
161
176
  };
162
177
 
163
178
  /**
@@ -230,6 +245,7 @@ export const createDoubletsMirror = async (
230
245
  },
231
246
  /** Write the snapshot now (after the log) and wait for it. */
232
247
  flush: () => snapshots.now(),
248
+ close: () => snapshots.close(),
233
249
  /** A snapshot older than the log is written again, in the background. */
234
250
  async refresh() {
235
251
  const behind = await stale();
@@ -245,7 +245,17 @@ export const openDualStore = async (
245
245
  // What a backup streams, and the flush of just the log (R-Z16).
246
246
  logFile: text.filePath,
247
247
  flushLog: () => text.flush(),
248
- close: lock.release,
248
+ async close() {
249
+ try {
250
+ await binary.close();
251
+ } finally {
252
+ try {
253
+ await text.flush();
254
+ } finally {
255
+ lock.release();
256
+ }
257
+ }
258
+ },
249
259
  };
250
260
  } catch (error) {
251
261
  lock.release();
@@ -0,0 +1,61 @@
1
+ import { useEffect, useLayoutEffect, useSyncExternalStore } from 'react';
2
+ import { appNavigation } from './app-route.js';
3
+
4
+ // Wait for async section content to be tall enough before restoring its scroll.
5
+ // User input cancels restoration so it cannot pull the page away afterwards.
6
+ const restoreScroll = (scroll, done) => {
7
+ const [x, y] = scroll ?? [0, 0];
8
+ let frame;
9
+ let stopped = false;
10
+ const end = () => {
11
+ stopped = true;
12
+ globalThis.cancelAnimationFrame(frame);
13
+ done();
14
+ };
15
+ const until = Date.now() + 5000;
16
+ const apply = () => {
17
+ const height = globalThis.document.scrollingElement?.scrollHeight ?? 0;
18
+ if (height - globalThis.innerHeight >= y || Date.now() >= until) {
19
+ globalThis.scrollTo?.(x, y);
20
+ end();
21
+ } else if (!stopped) {
22
+ frame = globalThis.requestAnimationFrame(apply);
23
+ }
24
+ };
25
+ const events = ['wheel', 'pointerdown', 'touchstart', 'keydown'];
26
+ events.forEach((event) =>
27
+ globalThis.addEventListener(event, end, { passive: true, once: true })
28
+ );
29
+ apply();
30
+ return () => {
31
+ end();
32
+ events.forEach((event) => globalThis.removeEventListener(event, end));
33
+ };
34
+ };
35
+
36
+ export const useAppNavigation = () => {
37
+ const navigation = appNavigation();
38
+ const route = useSyncExternalStore(
39
+ navigation.subscribe,
40
+ navigation.snapshot,
41
+ navigation.snapshot
42
+ );
43
+ useLayoutEffect(() => navigation.start(), [navigation]);
44
+ useEffect(() => {
45
+ const onNavigate = (event) => navigation.go(event.detail ?? {});
46
+ globalThis.addEventListener('meta-sovereign:navigate', onNavigate);
47
+ return () =>
48
+ globalThis.removeEventListener('meta-sovereign:navigate', onNavigate);
49
+ }, [navigation]);
50
+ useLayoutEffect(() => {
51
+ const previous = globalThis.history.scrollRestoration;
52
+ globalThis.history.scrollRestoration = 'manual';
53
+ navigation.restoring(true);
54
+ const stop = restoreScroll(route.scroll, () => navigation.restoring(false));
55
+ return () => {
56
+ stop();
57
+ globalThis.history.scrollRestoration = previous;
58
+ };
59
+ }, [route, navigation]);
60
+ return [route, navigation.go];
61
+ };
@@ -0,0 +1,205 @@
1
+ // Section routes coexist with the original chat and provider deep links.
2
+ import { navItems } from './nav-items.js';
3
+
4
+ export const APP_ROUTE_KEY = 'metaSovereignNavigation';
5
+ const VIEWS = new Set(navItems.map(([view]) => view));
6
+
7
+ export const splitChatAddress = (key) => {
8
+ const match = /^([a-z0-9_-]+):(.+)$/i.exec(String(key ?? ''));
9
+ return match ? { source: match[1], chat: match[2] } : null;
10
+ };
11
+
12
+ export const parseAppRoute = (hash = '') => {
13
+ const anchor = String(hash);
14
+ if (/^#conn-[a-z0-9-]+(?:\/manual)?$/i.test(anchor)) {
15
+ return { view: 'connections', anchor };
16
+ }
17
+ if (anchor.startsWith('#chat=')) {
18
+ try {
19
+ const chat = splitChatAddress(decodeURIComponent(anchor.slice(6)));
20
+ return chat ? { view: 'chat', anchor, chat } : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ const match = /^#\/?([a-z][a-z0-9-]*)(?:\?(.*))?$/.exec(anchor);
26
+ if (!match || !VIEWS.has(match[1])) {
27
+ return null;
28
+ }
29
+ const view = match[1];
30
+ const chat = splitChatAddress(new URLSearchParams(match[2]).get('chat'));
31
+ return { view, anchor, ...(chat ? { chat } : {}) };
32
+ };
33
+
34
+ export const sectionHash = (view) => `#/${view}`;
35
+ export const operatorHash = (key) =>
36
+ key ? `#/operator?chat=${encodeURIComponent(key)}` : sectionHash('operator');
37
+ export const chatHash = ({ source, chat }) =>
38
+ `#chat=${source}:${encodeURIComponent(chat)}`;
39
+
40
+ const remembered = (env) => {
41
+ try {
42
+ const value = JSON.parse(
43
+ env.sessionStorage?.getItem(APP_ROUTE_KEY) ?? 'null'
44
+ );
45
+ return value && typeof value === 'object' ? value : {};
46
+ } catch {
47
+ return {};
48
+ }
49
+ };
50
+
51
+ const targetFor = (view, anchor, hashes) => {
52
+ const wanted = anchor === undefined ? hashes[view] : anchor;
53
+ const next = parseAppRoute(wanted);
54
+ return next?.view === view ? next : { view, anchor: sectionHash(view) };
55
+ };
56
+
57
+ const visit = (target, before, replace) => ({
58
+ ...target,
59
+ previous: replace ? before.previous : before.anchor,
60
+ scroll: replace && before.view === target.view ? before.scroll : [0, 0],
61
+ });
62
+
63
+ /** Browser history is the authority; session storage only remembers sections. */
64
+ export const createAppNavigation = (env = globalThis) => {
65
+ const memory = remembered(env);
66
+ const hashes = { ...memory.hashes };
67
+ const initial = parseAppRoute(env.location?.hash) ??
68
+ parseAppRoute(hashes[memory.view]) ?? { view: 'chat', anchor: '#/chat' };
69
+ let route = {
70
+ ...initial,
71
+ previous: env.history?.state?.[APP_ROUTE_KEY]?.previous,
72
+ scroll: env.history?.state?.[APP_ROUTE_KEY]?.scroll ?? [0, 0],
73
+ };
74
+ let url = env.location?.hash;
75
+ let restoring = false;
76
+ const listeners = new Set();
77
+ const notify = () => listeners.forEach((listener) => listener());
78
+ const remember = () => {
79
+ hashes[route.view] = route.anchor;
80
+ try {
81
+ env.sessionStorage?.setItem(
82
+ APP_ROUTE_KEY,
83
+ JSON.stringify({ view: route.view, hashes })
84
+ );
85
+ } catch {
86
+ // The URL and history still work with browser storage disabled.
87
+ }
88
+ };
89
+ const write = (next, replace) => {
90
+ const state = {
91
+ ...env.history?.state,
92
+ [APP_ROUTE_KEY]: { ...next, scroll: next.scroll },
93
+ };
94
+ env.history?.[replace ? 'replaceState' : 'pushState']?.(
95
+ state,
96
+ '',
97
+ `${env.location?.pathname ?? ''}${env.location?.search ?? ''}${next.anchor}`
98
+ );
99
+ url = env.location?.hash;
100
+ };
101
+ const saveScroll = () => {
102
+ if (restoring) {
103
+ return;
104
+ }
105
+ env.history?.replaceState?.(
106
+ {
107
+ ...env.history.state,
108
+ [APP_ROUTE_KEY]: {
109
+ ...route,
110
+ scroll: [env.scrollX ?? 0, env.scrollY ?? 0],
111
+ },
112
+ },
113
+ '',
114
+ env.location?.href
115
+ );
116
+ };
117
+ const accept = () => {
118
+ if (env.location?.hash === url) {
119
+ return;
120
+ }
121
+ const next = parseAppRoute(env.location?.hash);
122
+ url = env.location?.hash;
123
+ if (!next) {
124
+ return; // In-page anchors such as #root do not switch sections.
125
+ }
126
+ const kept = env.history?.state?.[APP_ROUTE_KEY];
127
+ route = {
128
+ ...next,
129
+ previous: kept?.anchor === next.anchor ? kept.previous : route.anchor,
130
+ scroll: kept?.anchor === next.anchor ? kept.scroll : [0, 0],
131
+ };
132
+ write(route, true);
133
+ remember();
134
+ notify();
135
+ };
136
+ const go = ({ view, anchor } = {}, { replace = false } = {}) => {
137
+ if (!VIEWS.has(view)) {
138
+ return;
139
+ }
140
+ if (anchor === undefined && route.view === view) {
141
+ return;
142
+ }
143
+ const target = targetFor(view, anchor, hashes);
144
+ if (
145
+ route.anchor === target.anchor &&
146
+ env.location?.hash === target.anchor
147
+ ) {
148
+ return;
149
+ }
150
+ saveScroll();
151
+ const oldURL = env.location?.href;
152
+ route = visit(target, route, replace);
153
+ write(route, replace);
154
+ remember();
155
+ notify();
156
+ // pushState does not emit hashchange; existing chat/provider listeners need it.
157
+ if (env.HashChangeEvent) {
158
+ env.dispatchEvent(
159
+ new env.HashChangeEvent('hashchange', {
160
+ oldURL,
161
+ newURL: env.location?.href,
162
+ })
163
+ );
164
+ }
165
+ };
166
+ return {
167
+ snapshot: () => route,
168
+ subscribe: (listener) => {
169
+ listeners.add(listener);
170
+ return () => listeners.delete(listener);
171
+ },
172
+ go,
173
+ restoring: (active) => {
174
+ restoring = active;
175
+ },
176
+ back: (detail) => {
177
+ const target = targetFor(detail.view, detail.anchor, hashes);
178
+ if (route.previous === target.anchor) {
179
+ env.history.back();
180
+ } else {
181
+ go(detail, { replace: true });
182
+ }
183
+ },
184
+ start: () => {
185
+ accept();
186
+ write(route, true);
187
+ remember();
188
+ env.addEventListener?.('popstate', accept);
189
+ env.addEventListener?.('hashchange', accept);
190
+ env.addEventListener?.('scroll', saveScroll, { passive: true });
191
+ return () => {
192
+ env.removeEventListener?.('popstate', accept);
193
+ env.removeEventListener?.('hashchange', accept);
194
+ env.removeEventListener?.('scroll', saveScroll);
195
+ };
196
+ },
197
+ };
198
+ };
199
+
200
+ let browserNavigation;
201
+ export const appNavigation = () =>
202
+ (browserNavigation ??= createAppNavigation());
203
+ export const navigateApp = (detail, options) =>
204
+ appNavigation().go(detail, options);
205
+ export const backInApp = (detail) => appNavigation().back(detail);