querysub 0.156.0 → 0.158.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.156.0",
3
+ "version": "0.158.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,7 +24,7 @@
24
24
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
25
25
  "pako": "^2.1.0",
26
26
  "preact": "^10.11.3",
27
- "socket-function": "^0.89.0",
27
+ "socket-function": "^0.90.0",
28
28
  "terser": "^5.31.0",
29
29
  "typesafecss": "^0.6.3",
30
30
  "yaml": "^2.5.0",
@@ -3,15 +3,17 @@ import { getArchives } from "../-a-archives/archives";
3
3
  import { lazy } from "socket-function/src/caching";
4
4
  import { getStorageDir, getSubFolder } from "../fs";
5
5
  import fs from "fs";
6
- export const keys = getArchives("keys");
6
+ import { isNodeTrue } from "socket-function/src/misc";
7
+
8
+ export const keys = lazy(() => getArchives("keys"));
7
9
 
8
10
  export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: string }> => {
9
- let credsJSON = await keys.get("cloudflare.json");
11
+ let credsJSON = await keys().get("cloudflare.json");
10
12
  if (!credsJSON) {
11
13
  let localPath = getStorageDir() + "cloudflare.json";
12
14
  if (fs.existsSync(localPath)) {
13
15
  credsJSON = fs.readFileSync(localPath);
14
- await keys.set("cloudflare.json", credsJSON);
16
+ await keys().set("cloudflare.json", credsJSON);
15
17
  } else {
16
18
  throw new Error(`b2:/keys/cloudflare.json is missing. It should contain { "key": "your-key", "email": "your-email" }`);
17
19
  }
@@ -1,7 +1,7 @@
1
1
  import os from "os";
2
2
  import * as fs from "fs";
3
3
  import { cache, lazy } from "socket-function/src/caching";
4
- import { isNode } from "socket-function/src/misc";
4
+ import { isNode, isNodeTrue } from "socket-function/src/misc";
5
5
  import { httpsRequest } from "../https";
6
6
  import { getStorageDir } from "../fs";
7
7
  import { SocketFunction } from "socket-function/SocketFunction";
@@ -16,8 +16,6 @@ const DNS_TTLSeconds = {
16
16
  "A": 60,
17
17
  };
18
18
 
19
- export const keys = getArchives("keys");
20
-
21
19
  export const hasDNSWritePermissions = lazy(async () => {
22
20
  if (!isNode()) return false;
23
21
  if (isClient()) return false;
@@ -243,13 +243,12 @@ export async function triggerNodeChange() {
243
243
  }));
244
244
  }
245
245
 
246
- let seenNodesForEvilDNS = new Set<string>();
247
246
  async function syncArchives() {
248
247
  if (isServer()) {
249
248
  // Make sure we are present
250
249
  await writeHeartbeat();
251
250
  let nodeIds = await archives().find("");
252
- console.log(green(`Syncing archives`), { nodeIds });
251
+ console.log(green(`Syncing node ids from archives`), { nodeIds });
253
252
  setNodeIds(nodeIds);
254
253
  } else {
255
254
  if (isNoNetwork() || !isNode()) {
@@ -18,7 +18,6 @@ import { logNodeStateStats, logNodeStats } from "../../-0-hooks/hooks";
18
18
  /** Clean up old files after a while */
19
19
  const DEAD_CREATE_THRESHOLD = timeInHour * 12;
20
20
  const ARCHIVE_PROPAGATION_TIME = 5000;
21
- const LOG = false;
22
21
  // NOTE: There isn't a reason for transaction to not apply. If applying throws, we throw,
23
22
  // and can throw infinite times, but if it doesn't throw, it SHOULD be applied. We retry
24
23
  // a few times though, just in case the storage system was having some issues. After enough
@@ -309,9 +308,7 @@ class TransactionLocker {
309
308
  }
310
309
  public async createConfirm(key: string) {
311
310
  let path = this.getConfirmKey(key);
312
- if (LOG) {
313
- console.log(`Creating confirmation for ${key}`);
314
- }
311
+ diskLog(`Creating confirmation for ${key}`);
315
312
  await this.storage.setValue(path, Buffer.from(""));
316
313
  return path;
317
314
  }
@@ -363,9 +360,10 @@ class TransactionLocker {
363
360
  delete: ellipsize(deletes.map(a => debugFileInfo(a.key)).join(","), 50),
364
361
  });
365
362
 
366
- if (LOG) {
367
- console.log(`Writing transaction ${transaction.seqNum} with ${transaction.ops.length} ops`);
368
- }
363
+ diskLog(`Writing transaction`, {
364
+ name,
365
+ ops: transaction.ops.length,
366
+ });
369
367
 
370
368
  let key = `transaction_${name}.transaction`;
371
369
  let strippedTransaction: Transaction = { ops: transaction.ops.map(a => ({ ...a, value: undefined })) };
@@ -490,10 +488,8 @@ class TransactionLocker {
490
488
  while (true) {
491
489
  let result = await tryToRead();
492
490
  if (result) {
493
- if (LOG) {
494
- let timeToRead = Date.now() - startTime;
495
- console.log(`Read data state in ${formatTime(timeToRead)}`);
496
- }
491
+ let timeToRead = Date.now() - startTime;
492
+ diskLog(`Read data state in ${formatTime(timeToRead)}`);
497
493
  return result;
498
494
  }
499
495
  }
@@ -532,20 +528,20 @@ class TransactionLocker {
532
528
  let rawLookup = new Set(Array.from(rawDataFiles).map(a => a.file));
533
529
  // If any creates are not confirmed, it must not have been applied
534
530
  if (transaction.ops.some(a => a.type === "create" && rawLookup.has(a.key) && !confirmedKeys.has(a.key))) {
535
- if (LOG) {
536
- console.log(`Transaction has pending confirmations of creates.`);
537
- let pending = transaction.ops.filter(a => a.type === "create" && rawLookup.has(a.key) && !confirmedKeys.has(a.key));
538
- for (let p of pending) {
539
- console.log(` ${p.key}`);
540
- }
541
- }
531
+ diskLog(`Transaction not applied (has pending confirmations of creates)`, {
532
+ keys: transaction.ops
533
+ .filter(a => a.type === "create" && rawLookup.has(a.key) && !confirmedKeys.has(a.key))
534
+ .map(a => a.key)
535
+ });
542
536
  return false;
543
537
  }
544
538
  // If any deletes still exist, it must not have been applied
545
539
  if (transaction.ops.some(a => a.type === "delete" && confirmedKeys.has(a.key))) {
546
- if (LOG) {
547
- console.log(`transaction has pending deletes`);
548
- }
540
+ diskLog(`Transaction not applied (has pending deletes)`, {
541
+ keys: transaction.ops
542
+ .filter(a => a.type === "delete" && confirmedKeys.has(a.key))
543
+ .map(a => a.key)
544
+ });
549
545
  return false;
550
546
  }
551
547
  return true;
@@ -554,9 +550,9 @@ class TransactionLocker {
554
550
  let createCount = transaction.ops.filter(a => a.type === "create").length;
555
551
  let deleteCount = transaction.ops.filter(a => a.type === "delete").length;
556
552
  let lockedFiles = transaction.lockedFilesMustEqual?.length;
557
- if (LOG) {
558
- console.log(`Applying transaction with ${createCount} creates and ${deleteCount} deletes. ${lockedFiles !== undefined && `Lock state depends on ${lockedFiles} files` || ""}`);
559
- }
553
+ diskLog(`Applying transaction with ${createCount} creates and ${deleteCount} deletes. ${lockedFiles !== undefined && `Lock state depends on ${lockedFiles} files` || ""}`, {
554
+ transactions: transaction.ops.map(x => JSON.stringify(x)),
555
+ });
560
556
  logNodeStats(`archives|TΔ Apply`, formatNumber, 1);
561
557
  let opsRemaining = transaction.ops.slice();
562
558
  // NOTE: Order doesn't matter here. If anything is reading the values
@@ -580,9 +576,9 @@ class TransactionLocker {
580
576
  };
581
577
  await Promise.all(list(CONCURRENT_WRITE_COUNT).map(runThread));
582
578
 
583
- if (LOG) {
584
- console.log(`Applied transaction with ${createCount} creates and ${deleteCount} deletes. ${lockedFiles !== undefined && `Lock state depends on ${lockedFiles} files` || ""}`);
585
- }
579
+ diskLog(`Applied transaction with ${createCount} creates and ${deleteCount} deletes. ${lockedFiles !== undefined && `Lock state depends on ${lockedFiles} files` || ""}`, {
580
+ transactions: transaction.ops.map(x => JSON.stringify(x)),
581
+ });
586
582
  }
587
583
 
588
584
  /** Only returns data files (no transaction files, or confirmations).
@@ -646,9 +642,7 @@ class TransactionLocker {
646
642
  let threshold = activeT.createTime + this.storage.propagationTime;
647
643
  if (Date.now() < threshold) {
648
644
  let waitTime = threshold - Date.now();
649
- if (LOG) {
650
- console.log(`Waiting ${formatTime(waitTime)} for transaction ${activeT.seqNum} to settle.`);
651
- }
645
+ diskLog(`Waiting ${formatTime(waitTime)} for transaction ${activeT.seqNum} to settle.`);
652
646
  await new Promise(resolve => setTimeout(resolve, waitTime));
653
647
  return this.getFilesBase();
654
648
  }
@@ -688,9 +682,7 @@ class TransactionLocker {
688
682
  // triggered (without this check), and deletes all of our files...
689
683
  let unconfirmedOldFiles2 = veryOldFiles.filter(a => !doubleCheckLookup.has(a) && doubleCheckDataFiles.has(a.file));
690
684
  console.warn(red(`Deleted ${unconfirmedOldFiles2.length} very old unconfirmed files`));
691
- if (LOG) {
692
- logNodeStats(`archives|TΔ Delete Old Rejected File`, formatNumber, unconfirmedOldFiles2.length);
693
- }
685
+ diskLog(`archives|TΔ Delete Old Rejected File`, formatNumber, unconfirmedOldFiles2.length);
694
686
  // At the point the file was very old when we started reading, not part of the active transaction.
695
687
  for (let file of unconfirmedOldFiles2) {
696
688
  await this.deleteDataFile(file.file, `old unconfirmed file (${getOwnNodeId()}, ${process.argv[1]})`);
@@ -706,10 +698,8 @@ class TransactionLocker {
706
698
  let oldEnoughConfirms = dataState.rawDataFiles.filter(x => x.file.endsWith(".confirm") && x.createTime < oldThreshold);
707
699
  let deprecatedFiles = oldEnoughConfirms.filter(a => !usedConfirmations.has(a.file));
708
700
  if (deprecatedFiles.length > 0) {
709
- if (LOG) {
710
- console.warn(red(`Deleted ${deprecatedFiles.length} / ${oldEnoughConfirms.length} confirmations, for not having corresponding data files`));
711
- logNodeStats(`archives|TΔ Delete Deprecated Confirm`, formatNumber, deprecatedFiles.length);
712
- }
701
+ console.warn(red(`Deleted ${deprecatedFiles.length} / ${oldEnoughConfirms.length} confirmations, for not having corresponding data files`));
702
+ logNodeStats(`archives|TΔ Delete Deprecated Confirm`, formatNumber, deprecatedFiles.length);
713
703
  for (let file of deprecatedFiles) {
714
704
  await this.storage.deleteKey(file.file);
715
705
  }
@@ -744,12 +734,10 @@ class TransactionLocker {
744
734
 
745
735
  /** If any deleted files were deleted by other transactions, then we will be rejected. */
746
736
  public async addTransaction(transaction: Transaction): Promise<"accepted" | "rejected"> {
747
- if (LOG) {
748
- let dels = transaction.ops.filter(a => a.type === "delete").length;
749
- let creates = transaction.ops.filter(a => a.type === "create").length;
750
- let createBytes = transaction.ops.map(a => a.type === "create" && a.value?.length || 0).reduce((a, b) => a + b, 0);
751
- console.log(blue(`Starting transaction with ${creates} creates and ${dels} deletes, ${formatNumber(createBytes)}B`));
752
- }
737
+ let dels = transaction.ops.filter(a => a.type === "delete").length;
738
+ let creates = transaction.ops.filter(a => a.type === "create").length;
739
+ let createBytes = transaction.ops.map(a => a.type === "create" && a.value?.length || 0).reduce((a, b) => a + b, 0);
740
+ diskLog(`Starting transaction with ${creates} creates and ${dels} deletes, ${formatNumber(createBytes)}B`);
753
741
  transaction = { ...transaction, ops: transaction.ops.slice() };
754
742
  function normalizePath(path: string) {
755
743
  // Replace duplicate slashes with a single slash
@@ -775,9 +763,7 @@ class TransactionLocker {
775
763
  let beforeData = await this.getFilesBase();
776
764
  if (!this.isTransactionValid(transaction, beforeData.dataFiles, beforeData.rawDataFiles)) {
777
765
  logNodeStats(`archives|TΔ Rejected`, formatNumber, 1);
778
- if (LOG) {
779
- console.log(red(`Finished transaction with rejection, ${transaction.ops.length} ops`));
780
- }
766
+ diskLog(`Finished transaction with rejection, ${transaction.ops.length} ops`);
781
767
  return "rejected";
782
768
  }
783
769
 
@@ -786,9 +772,7 @@ class TransactionLocker {
786
772
  let afterData = await this.getFilesBase();
787
773
  if (this.wasTransactionApplied(transaction, afterData.dataFiles, afterData.rawDataFiles)) {
788
774
  logNodeStats(`archives|TΔ Accepted`, formatNumber, 1);
789
- if (LOG) {
790
- console.log(green(`Finished transaction with ${transaction.ops.length} ops`));
791
- }
775
+ diskLog(`Finished transaction with ${transaction.ops.length} ops`);
792
776
  return "accepted";
793
777
  }
794
778
  }
@@ -6,8 +6,9 @@ import { ignoreErrors, logErrors, timeoutToUndefinedSilent } from "../../errors"
6
6
  import { green, magenta } from "socket-function/src/formatting/logColors";
7
7
  import { devDebugbreak } from "../../config";
8
8
  import { sort } from "socket-function/src/misc";
9
+ import { lazy } from "socket-function/src/caching";
9
10
 
10
- const snapshots = getArchives("snapshots");
11
+ const snapshots = lazy(() => getArchives("snapshots"));
11
12
 
12
13
  export type ArchiveSnapshotOverview = {
13
14
  // If live, it isn't a real file that can be loaded
@@ -54,7 +55,7 @@ export async function saveSnapshot(config: {
54
55
  }) {
55
56
  let { files } = config;
56
57
  let overview = getSnapshotOverview(files);
57
- await snapshots.set(overview.file, Buffer.from(files.join("\n")));
58
+ await snapshots().set(overview.file, Buffer.from(files.join("\n")));
58
59
  }
59
60
  function overviewToFileName(overview: Omit<ArchiveSnapshotOverview, "file">): string {
60
61
  return Object.entries({
@@ -84,7 +85,7 @@ function fileNameToOverview(fileName: string): ArchiveSnapshotOverview {
84
85
 
85
86
  // pathValueArchives.decodeDataPath
86
87
  export async function getSnapshotList(): Promise<ArchiveSnapshotOverview[]> {
87
- let snapshotFiles = await snapshots.find("");
88
+ let snapshotFiles = await snapshots().find("");
88
89
 
89
90
  let overview: ArchiveSnapshotOverview[] = [];
90
91
  let locker = await pathValueArchives.getArchiveLocker();
@@ -128,7 +129,7 @@ async function getSnapshotBase(snapshotFile: string | "live") {
128
129
  files: allFiles.map(x => x.file),
129
130
  };
130
131
  }
131
- let data = await snapshots.get(snapshotFile);
132
+ let data = await snapshots().get(snapshotFile);
132
133
  if (!data) {
133
134
  throw new Error(`Snapshot not found: ${snapshotFile}`);
134
135
  }
@@ -158,7 +159,7 @@ export async function loadSnapshot(config: {
158
159
  console.log(magenta(`Loading snapshot: ${overview.file}`));
159
160
  let locker = await pathValueArchives.getArchiveLocker();
160
161
 
161
- let snapshotData = await snapshots.get(overview.file);
162
+ let snapshotData = await snapshots().get(overview.file);
162
163
  if (!snapshotData) {
163
164
  throw new Error(`Snapshot not found: ${overview.file}`);
164
165
  }
@@ -1768,13 +1768,15 @@ class WriteValidStorage {
1768
1768
  let authorityValue = authorityStorage.getValueExactIgnoreInvalid(write.path, write.time);
1769
1769
  if (authorityValue) {
1770
1770
  authorityValue.valid = write.isValid;
1771
- if (!isCoreQuiet) {
1772
- console.log(`Setting valid state of ${debugPathValuePath(authorityValue)} to ${write.isValid}`);
1773
- }
1771
+ // NOTE: I think it is fine now for us to set the valid state early? Hopefully...
1772
+ // because it happens A LOT.
1773
+ // if (!isCoreQuiet) {
1774
+ // console.log(`Setting valid state of ${debugPathValuePath(authorityValue)} to ${write.isValid}`);
1775
+ // }
1774
1776
  } else {
1775
- if (isNode()) {
1776
- console.error(`Setting valid state of ${write.path}@${debugTime(write.time)} to ${write.isValid}, but the ValuePath was not found. If the ValuePath is found later, it might not have the valid state set correctly.`);
1777
- }
1777
+ // if (isNode()) {
1778
+ // console.error(`Setting valid state of ${write.path}@${debugTime(write.time)} to ${write.isValid}, but the ValuePath was not found. If the ValuePath is found later, it might not have the valid state set correctly.`);
1779
+ // }
1778
1780
  }
1779
1781
  }
1780
1782
 
@@ -162,6 +162,9 @@ interface WatcherOptions<Result> {
162
162
  // Fairly self explanatory. If there are nested createWatchers, instead of forking, we just
163
163
  // run them immediately.
164
164
  inlineNestedWatchers?: boolean;
165
+
166
+ // Temporary indicates after becoming synchronizes it will immediately dispose itself
167
+ temporary?: boolean;
165
168
  }
166
169
 
167
170
  let harvestableReadyLoopCount = 0;
@@ -286,6 +289,8 @@ export type SyncWatcher = {
286
289
  tag: SyncWatcherTag;
287
290
 
288
291
  hackHistory: { message: string; time: number }[];
292
+
293
+ createTime: number;
289
294
  }
290
295
  function addToHistory(watcher: SyncWatcher, message: string) {
291
296
  watcher.hackHistory.push({ message, time: Date.now() });
@@ -989,6 +994,7 @@ export class PathValueProxyWatcher {
989
994
  tag: new SyncWatcherTag(),
990
995
 
991
996
  hackHistory: [],
997
+ createTime: Date.now(),
992
998
  };
993
999
  const SHOULD_TRACE = this.SHOULD_TRACE(watcher);
994
1000
  const proxy = this.proxy;
@@ -1205,11 +1211,13 @@ export class PathValueProxyWatcher {
1205
1211
  debugger;
1206
1212
  // NOTE: Using forceEqualWrites will also fix this a lot of the time, such as when
1207
1213
  // a write contains random numbers or dates.
1208
- let errorMessage = `Too many attempts to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
1214
+ let errorMessage = `Too many attempts (${watcher.countSinceLastFullSync}) to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
1209
1215
  if (specialPromiseUnsynced) {
1210
1216
  errorMessage += ` A promise is being accessed, so it is possible triggerOnPromiseFinish is being used on a new promise every loop, which cannot work (you MUST cache MaybePromise and replace the value with a non-promise, otherwise it will never be available synchronously!)`;
1211
1217
  }
1218
+ errorMessage += ` (${watcher.debugName}})`;
1212
1219
  console.error(red(errorMessage));
1220
+ logUnsynced();
1213
1221
  result = { error: new Error(errorMessage).stack || "" };
1214
1222
  // Force the watches to be equal, so we stop looping
1215
1223
  watcher.pendingWatches = watcher.lastWatches;
@@ -1648,6 +1656,7 @@ export class PathValueProxyWatcher {
1648
1656
  let anyWrites = false;
1649
1657
  this.createWatcher({
1650
1658
  ...options,
1659
+ temporary: true,
1651
1660
  onResultUpdated: (result, writes, watcher) => {
1652
1661
  if (writes?.length) {
1653
1662
  anyWrites = true;
@@ -1700,6 +1709,7 @@ export class PathValueProxyWatcher {
1700
1709
  ...options,
1701
1710
  canWrite: true,
1702
1711
  dryRun: true,
1712
+ temporary: true,
1703
1713
  onResultUpdated: (result, values, watcher) => {
1704
1714
  watcher.dispose();
1705
1715
  if ("error" in result) {
@@ -1,7 +1,3 @@
1
-
2
-
3
- // yarn typenode src\framework-beta\archiveMoveBase\archiveMoveHarness.ts --nonetwork
4
-
5
1
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
2
  import { blue, green, red } from "socket-function/src/formatting/logColors";
7
3
  import { measureBlock, measureWrap } from "socket-function/src/profiling/measure";
@@ -22,6 +18,7 @@ import debugbreak from "debugbreak";
22
18
  let lastFileWrites = new Map<string, string[]>();
23
19
 
24
20
  export async function runArchiveMover(config: {
21
+ debugName: string;
25
22
  // Defaults to getOurAuthorities
26
23
  // NOTE: Runs once per authority, as we want to keep data as sharded as possible
27
24
  authorities?: AuthorityPath[];
@@ -307,10 +304,11 @@ export async function runArchiveMover(config: {
307
304
  || lastResult === "aborted" && green("noop")
308
305
  || red("rejected")
309
306
  );
310
- console.log(`ArchiveMover ${config.runMover.name} on ${authorityDir} in ${formatTime(time)}. Result = ${resultFormat}.`);
307
+ console.log(`ArchiveMover ${config.debugName ?? config.runMover.name} on ${authorityDir} in ${formatTime(time)}. Result = ${resultFormat}.`);
311
308
  if (lastResult === "accepted" && !abortedDueToInsufficientReduction) {
312
309
  console.log(
313
- ` { files: ${formatNumber(inputFiles)}, values: ${formatNumber(inputValueCount)}, bytes: ${formatNumber(inputBytes)} }`
310
+ `ArchiveMover free committed ${config.debugName ?? config.runMover.name} on ${authorityDir}`
311
+ + ` { files: ${formatNumber(inputFiles)}, values: ${formatNumber(inputValueCount)}, bytes: ${formatNumber(inputBytes)} }`
314
312
  + "\n=>\n"
315
313
  + ` { files: ${formatNumber(outputFiles)}, values: ${formatNumber(outputValueCount)}, bytes: ${formatNumber(outputBytes)} }`
316
314
  ,
@@ -542,6 +542,7 @@ export async function runAliveCheckerIteration(config?: {
542
542
 
543
543
  let lockWrites: PathValue[] = [];
544
544
  await runArchiveMover({
545
+ debugName: type,
545
546
  outputType: type,
546
547
  readLiveData: example.unsafeLiveReads,
547
548
  force: config?.force,
@@ -719,7 +720,15 @@ export async function runAliveCheckerIteration(config?: {
719
720
  }
720
721
 
721
722
  let allFrees = new Set(freeLists.flat().flat().map(x => x.value));
723
+ let allValues = values;
722
724
  values = values.filter(x => !allFrees.has(x));
725
+ if (allValues.length !== values.length) {
726
+ console.log("Freeding values due to garbage collection", {
727
+ before: allValues.length,
728
+ after: values.length,
729
+ freed: allValues.length - values.length,
730
+ });
731
+ }
723
732
 
724
733
  // NOTE: We don't notify any servers about this. We will eventually have to write
725
734
  // audit code to periodically check against the disk to get updates.
@@ -199,6 +199,10 @@ export class Schema2Fncs {
199
199
  }
200
200
  return { defaultValue: def.defaultValue, };
201
201
  }
202
+ // OR, if it is an optional object, then the default IS undefined
203
+ if (def?.isMaybeUndefined) {
204
+ return { defaultValue: undefined };
205
+ }
202
206
  return undefined;
203
207
  }
204
208
  public static isObject(schema: Schema2, path: string[]): boolean {
@@ -293,6 +297,7 @@ type InternalTypeDef = {
293
297
 
294
298
  isObject?: boolean;
295
299
  isObjectGC?: boolean;
300
+ isMaybeUndefined?: boolean;
296
301
 
297
302
  isNotAtomic?: boolean;
298
303
 
@@ -340,6 +345,9 @@ function typeDefTypeToInternalType(
340
345
  isObject = true;
341
346
  isObjectGC = false;
342
347
  }
348
+ if (typeDef.path.includes("optionalObject") || typeDef.path.includes("optionalObjectNoGC")) {
349
+ rootResult.isMaybeUndefined = true;
350
+ }
343
351
 
344
352
  let undoRegions = typeDef.pathWithCalls.filter(x => x.key === "undoRegionObject");
345
353
  for (let undoRegion of undoRegions) {
@@ -370,6 +378,7 @@ function typeDefTypeToInternalType(
370
378
  nestedValues.isObjectGC = isObjectGC || isLookupGC;
371
379
  nestedValues.gcDelay = Number(typeDef.pathWithCalls.find(x => x.key === "gcDelay")?.callParams?.[0]) || undefined;
372
380
 
381
+
373
382
  if (isLookup) {
374
383
  let undoRegions = typeDef.pathWithCalls.filter(x => x.key === "undoRegion");
375
384
  for (let undoRegion of undoRegions) {
@@ -550,7 +550,7 @@ export class PathFunctionRunner {
550
550
  }
551
551
  }
552
552
  if (runCount > PathFunctionRunner.MAX_WATCH_LOOPS) {
553
- throw new Error(`MAX_WATCH_LOOPS exceeded for ${getDebugName(callPath, functionSpec, true)}`);
553
+ throw new Error(`MAX_WATCH_LOOPS exceeded for ${getDebugName(callPath, functionSpec, true)}. All accesses have to be consistent. So Querysub.time() instead of Date.now() and Querysub.nextId() instead of nextId() / Math.random(). If you need multiple random numbers, keep track of an index, and pass it to Querysub.nextId() for the nth random number.`);
554
554
  }
555
555
 
556
556
  // We NEED to depend on the function spec, so spec updates can be atomic (which is needed to deploy an update
@@ -140,14 +140,25 @@ export type PermissionsParameters = {
140
140
  };
141
141
  /** A false of false will deny read permissions, resulting in all reads being given value with a value
142
142
  * of undefined, and a time of 0.
143
- * NOTE: Permissions checks at the root (permissions: { READ_PERMISSIONS(config) { } }) will
144
- * be used to verify calls are allowed as well, even if the call doesn't read or write any data
145
- * that is under a permissions check.
143
+
146
144
  */
147
145
  export type PermissionsCallback = (config: PermissionsParameters) => PermissionsCheckResult;
148
146
  export type PermissionsCheckResult = boolean | { allowed: boolean; skipParentChecks?: boolean; };
149
147
  /*
150
148
  NOTE: All ancestor permissions checks are applied as well.
149
+ - This means if the ancestor disallows, it blocks any descendant checks even if they are allowed.
150
+
151
+ NOTE: If no permissions match, then access is disallowed by default.
152
+
153
+ NOTE: Permissions checks at the root (permissions: { READ_PERMISSIONS(config) { } }) will
154
+ be used to verify calls are allowed as well, even if the call doesn't read or write any data
155
+ that is under a permissions check.
156
+ - THIS MEANS that if there is no permission check at the root, calls won't be allowed by any user
157
+ (as we default to disallow).
158
+
159
+ NOTE: Servers watches ignore permissions, as they directly communicate with the underlying PathValueServer,
160
+ skipping the ProxyServer.
161
+ - However function calls by servers are still run by FunctionRunner, which will apply permission checks.
151
162
 
152
163
  IMPORTANT! Wildcards are tested via the "" path. This means if the "" key is provided access to a user, they can run
153
164
  Object.keys()/Object.values() on the data. In order to not accidentally provide list access, never allow "" to be
@@ -7,7 +7,7 @@ import { getArchivesBackblazePublic } from "../-a-archives/archivesBackBlaze";
7
7
  import { nestArchives } from "../-a-archives/archives";
8
8
  import { SocketFunction } from "socket-function/SocketFunction";
9
9
  import { runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
10
- import { compare, compareArray, timeInMinute } from "socket-function/src/misc";
10
+ import { compare, compareArray, isNodeTrue, timeInMinute } from "socket-function/src/misc";
11
11
  import { cacheLimited, lazy } from "socket-function/src/caching";
12
12
  import { canHaveChildren } from "socket-function/src/types";
13
13
  import { shutdown } from "../diagnostics/periodic";
@@ -29,7 +29,7 @@ import { startEdgeNotifier } from "./edgeClientWatcher";
29
29
  const UPDATE_POLL_INTERVAL = timeInMinute;
30
30
  const DEAD_NODE_COUNT_THRESHOLD = 15;
31
31
 
32
- const edgeNodeStorage = nestArchives("edgenodes/", getArchivesBackblazePublic(getDomain()));
32
+ const edgeNodeStorage = isNodeTrue() && nestArchives("edgenodes/", getArchivesBackblazePublic(getDomain()));
33
33
  const edgeNodeIndexFile = "edge-nodes-index.json";
34
34
 
35
35
  const getEdgeNodeConfig = cacheLimited(10000, async (fileName: string): Promise<EdgeNodeConfig | undefined> => {
@@ -2033,7 +2033,7 @@ function blurFixOnMouseDownHack(event: MouseEvent) {
2033
2033
 
2034
2034
  // Looks like we are going to blur, so blur now
2035
2035
  if (selected instanceof HTMLElement && !selected.hasAttribute("data-no-early-blur")) {
2036
- console.info(`Simulating early blur to prevent unblurred inputs from existing after mousedown. You can use data-no-early-blur to opt-out of this feature`, selected);
2036
+ console.info(`Simulating early blur to prevent blur from firing after mousedown. This solves a problem where mousedown changes the UI, and then the blur fires on the wrong element. You can use data-no-early-blur to opt-out of this feature`, selected);
2037
2037
  selected.blur();
2038
2038
  }
2039
2039
  }
@@ -32,7 +32,7 @@ import { pathValueCommitter } from "../0-path-value-core/PathValueCommitter";
32
32
  import debugbreak from "debugbreak";
33
33
  import { extractPublicKey, verifyED25519 } from "../-a-auth/ed25519";
34
34
  import { registerDynamicResource, registerResource } from "../diagnostics/trackResources";
35
- import { registerPeriodic, shutdown } from "../diagnostics/periodic";
35
+ import { registerPeriodic, registerPreshutdownHandler, registerShutdownHandler, shutdown } from "../diagnostics/periodic";
36
36
  import { sha256 } from "js-sha256";
37
37
  import { green, red } from "socket-function/src/formatting/logColors";
38
38
  import { minify_sync } from "terser";
@@ -43,7 +43,6 @@ import { measureBlock, measureFnc } from "socket-function/src/profiling/measure"
43
43
  import { delay } from "socket-function/src/batching";
44
44
  import { MaybePromise } from "socket-function/src/types";
45
45
  import { devDebugbreak, getDomain, isDevDebugbreak, isDynamicallyLoading, isNoNetwork, isPublic } from "../config";
46
- import { hookErrors } from "../diagnostics/errorLogs/hookErrors";
47
46
  import { Schema2, Schema2T, t } from "../2-proxy/schema2";
48
47
  import { CALL_PERMISSIONS_KEY } from "./permissionsShared";
49
48
  import { isDynamicModule } from "../3-path-functions/pathFunctionLoader";
@@ -115,6 +114,7 @@ export class Querysub {
115
114
  public static Fragment = qreact.Fragment;
116
115
  public static t = t;
117
116
  public static shutdown = () => shutdown();
117
+ public static onShutdown = (fnc: () => Promise<void>) => registerPreshutdownHandler(fnc);
118
118
  /**
119
119
  IMPORTANT! New schemas must be deployed by calling `yarn deploy`
120
120
 
@@ -192,7 +192,8 @@ export class Querysub {
192
192
  public static getCallId = () => Querysub.getCallerMachineId() + "_" + Querysub.getCallTime();
193
193
  // Returns a random value between 0 and 1. The value depends on the callId, and index (being identical for
194
194
  // the same callid and index).
195
- public static callRandom = (index: number) => hashRandom(Querysub.getCallId(), index);
195
+ public static callRandom = (index = 0) => hashRandom(Querysub.getCallId(), index);
196
+ public static nextId = (index = 0) => Querysub.getCallId() + "_" + index;
196
197
 
197
198
  public static configRootDiscoveryLocation = configRootDiscoveryLocation;
198
199
 
@@ -275,6 +276,11 @@ export class Querysub {
275
276
  watchFunction: fnc
276
277
  }));
277
278
  }
279
+ public static commitDelayed = (fnc: () => unknown) => {
280
+ setImmediate(() => {
281
+ Querysub.serviceWriteDetached(fnc);
282
+ });
283
+ };
278
284
 
279
285
  public static exit = Querysub.gracefulTerminateProcess;
280
286
  public static async gracefulTerminateProcess() {
@@ -286,6 +292,7 @@ export class Querysub {
286
292
 
287
293
  public static syncedCommit = Querysub.serviceWrite;
288
294
  public static commitSynced = Querysub.serviceWrite;
295
+ public static commitAsync = Querysub.serviceWrite;
289
296
  public static async serviceWrite<T>(fnc: () => T) {
290
297
  return await proxyWatcher.commitFunction({
291
298
  canWrite: true,
@@ -384,7 +391,8 @@ export class Querysub {
384
391
  public static getSelfMachineId = getOwnMachineId;
385
392
 
386
393
  public static getOwnNodeId = getOwnNodeId;
387
- public static getSelfNodeId = getOwnMachineId;
394
+ public static getSelfNodeId = getOwnNodeId;
395
+ public static getOwnThreadId = getOwnNodeId;
388
396
 
389
397
  /** Set ClientWatcher.DEBUG_SOURCES to true for to be populated */
390
398
  public static getTriggerReason() {
@@ -899,7 +907,14 @@ function getSyncedTime() {
899
907
  if (call) {
900
908
  return call.runAtTime.time;
901
909
  }
910
+ let watcher = proxyWatcher.getTriggeredWatcherMaybeUndefined();
911
+ if (watcher?.options.temporary) {
912
+ return watcher.createTime;
913
+ }
902
914
  if (isNode()) {
915
+ if (watcher) {
916
+ throw new Error(`Trying to access time in a serverside non-temporary watcher. Clientside this is allowed, as infinite loops (render every frame) makes sense. Serverside this is not allowed. Did you try to run a call-type operatin in a watcher? If you manually created a watcher, you might want to set "temporary: true" if you will be immediately disposing it. You almost might want to fork your write logic with setImmediate to detach this from your watcher, so your write can access a single non-changing time.`);
917
+ }
903
918
  return Date.now();
904
919
  }
905
920
  initTimeLoop();
@@ -953,9 +968,12 @@ setImmediate(() => {
953
968
  });
954
969
  });
955
970
 
956
- setImmediate(() => {
957
- hookErrors();
958
- });
971
+ if (isNode()) {
972
+ setImmediate(async () => {
973
+ const { hookErrors } = await import("../diagnostics/errorLogs/hookErrors");
974
+ hookErrors();
975
+ });
976
+ }
959
977
 
960
978
  setImmediate(async () => {
961
979
  // Import, so it registers addStatPeriodic
@@ -32,6 +32,7 @@ export class Table<RowT extends RowType> extends qreact.Component<TableType<RowT
32
32
  // Line and character limits before we cut off the inner content
33
33
  lineLimit?: number;
34
34
  characterLimit?: number;
35
+ dark?: boolean;
35
36
  }> {
36
37
  state = {
37
38
  limit: this.props.initialLimit || 100,
@@ -47,7 +48,11 @@ export class Table<RowT extends RowType> extends qreact.Component<TableType<RowT
47
48
 
48
49
  return (
49
50
  <table class={css.borderCollapse("collapse") + this.props.class}>
50
- <tr class={css.position("sticky").top(0).hsla(0, 0, 95, 0.9)}>
51
+ <tr class={
52
+ css.position("sticky").top(0)
53
+ + (this.props.dark && css.hsla(0, 0, 10, 0.9))
54
+ + (!this.props.dark && css.hsla(0, 0, 95, 0.9))
55
+ }>
51
56
  <th class={css.whiteSpace("nowrap")}>⧉ {allRows.length}</th>
52
57
  {Object.entries(columns).filter(x => x[1] !== null).map(([columnName, column]) =>
53
58
  <th class={css.pad2(8, 4) + cellClass}>{column?.title || toSpaceCase(columnName)}</th>
@@ -10,6 +10,7 @@ const MERGE_DELAY = timeInHour;
10
10
 
11
11
  async function mergeFiles() {
12
12
  await runArchiveMover({
13
+ debugName: "merge",
13
14
  outputType: "merge",
14
15
  async runMover(config) {
15
16
  let values = config.values;
@@ -19,7 +19,6 @@ import { atomic, doAtomicWrites } from "../../2-proxy/PathValueProxyWatcher";
19
19
  import { isClient } from "../../config2";
20
20
  import { ControllerPick, SocketRegistered } from "socket-function/SocketFunctionTypes";
21
21
 
22
-
23
22
  const NOTIFY_HISTORY = timeInDay * 7;
24
23
  // Only error / fatal are tracked, for now... we might also add warns?
25
24
  const logIssueNotifyTypes: LogType[] = ["error", "fatal"];
@@ -14,7 +14,6 @@ import { ButtonSelector } from "../../library-components/ButtonSelector";
14
14
  import { Icon } from "../../library-components/icons";
15
15
  import { MeasuredDiv } from "../../library-components/MeasuredDiv";
16
16
 
17
-
18
17
  type RangeSize = "lastYear" | "lastMonth" | "lastWeek" | "lastDay" | "lastHour";
19
18
  export let logNowTime = Date.now();
20
19
  const logFilterStart = new URLParam<number | RangeSize>("logFilterStartTime", "lastWeek");
@@ -18,7 +18,6 @@ import { getNextTime } from "../0-path-value-core/pathValueCore";
18
18
  import { encodeArgs } from "../3-path-functions/PathFunctionHelpers";
19
19
  import { CallSpec, getCurrentCall, getCurrentCallAllowUndefined, overrideCurrentCall } from "../3-path-functions/PathFunctionRunner";
20
20
  import { createLazyComponent } from "../library-components/LazyComponent";
21
- import { LogNotify } from "./errorLogs/LogNotify";
22
21
  import { isTrusted } from "../-d-trust/NetworkTrust2";
23
22
  import { devDebugbreak, getDomain } from "../config";
24
23
  import { getCallWrites } from "../4-querysub/querysubPrediction";
@@ -27,7 +26,9 @@ import * as hooks from "../-0-hooks/hooks";
27
26
  import { addComponentUI } from "../5-diagnostics/qreactDebug";
28
27
  import { addComponentButton } from "../5-diagnostics/qreactDebug";
29
28
  import { closeAllModals } from "../5-diagnostics/Modal";
29
+ import { delay } from "socket-function/src/batching";
30
30
 
31
+ const LogNotify = createLazyComponent(() => import("./errorLogs/LogNotify"))("LogNotify");
31
32
 
32
33
  export const managementPageURL = new URLParam("managementpage", "");
33
34
  export const showingManagementURL = new URLParam("showingmanagement", false);
@@ -134,14 +135,21 @@ export async function registerManagementPages2(config: {
134
135
  __schema = config.schema;
135
136
 
136
137
  setImmediate(async () => {
137
- if (!isNode() && !await ManagementController.nodes[getBrowserUrlNode()].isManagementUser()) return;
138
+ if (!isNode() &&
139
+ !await ManagementController.nodes[getBrowserUrlNode()].isManagementUser()
140
+ && new URLSearchParams(window.location.search).get("dropto") !== "user"
141
+ ) return;
142
+
143
+ // Wait, so the import system knows the modules are async imports
144
+ await delay(0);
145
+
146
+ const ComponentSyncStats = createLazyComponent(() => import("./misc-pages/ComponentSyncStats"))("ComponentSyncStats");
138
147
 
139
- const { filtersURL } = await import("./misc-pages/archiveViewerShared");
140
- const { watchValueType } = await import("./misc-pages/LocalWatchViewer");
141
- const { ComponentSyncStats } = await import("./misc-pages/ComponentSyncStats");
142
148
  addComponentButton({
143
149
  title: "View in Path Navigator",
144
- callback(component) {
150
+ async callback(component) {
151
+ const { filtersURL } = await import("./misc-pages/archiveViewerShared");
152
+ const { watchValueType } = await import("./misc-pages/LocalWatchViewer");
145
153
  filtersURL.value[nextId()] = { column: "path", match: component.debugName, type: "and" };
146
154
  managementPageURL.value = "LocalWatchViewer";
147
155
  watchValueType.value = "component";
@@ -168,6 +176,8 @@ export async function registerManagementPages2(config: {
168
176
  }
169
177
 
170
178
  if (isServer()) {
179
+ // Wait, so the import system knows the modules are async imports
180
+ await delay(0);
171
181
  for (let page of inputPages) {
172
182
  // NOTE: If we split this into a module for component/controller, we need to make sure we
173
183
  // import both serverside, so we can whitelist them for import clientside.
@@ -1,6 +1,6 @@
1
- import { runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
1
+ import { delay, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
2
2
  import { isNode, timeInMinute } from "socket-function/src/misc";
3
- import { logErrors } from "../errors";
3
+ import { logErrors, timeoutToError } from "../errors";
4
4
  import debugbreak from "debugbreak";
5
5
  import { nodeDiscoveryShutdown } from "../-f-node-discovery/NodeDiscovery";
6
6
  import { authorityStorage } from "../0-path-value-core/pathValueCore";
@@ -15,6 +15,10 @@ let shutdownHandlers: (() => Promise<void>)[] = [];
15
15
  export function registerShutdownHandler(fnc: () => Promise<void>) {
16
16
  shutdownHandlers.push(fnc);
17
17
  }
18
+ let preshutdownHandlers: (() => Promise<void>)[] = [];
19
+ export function registerPreshutdownHandler(fnc: () => Promise<void>) {
20
+ preshutdownHandlers.push(fnc);
21
+ }
18
22
 
19
23
  function logAll() {
20
24
  for (let fnc of periodicFncs) {
@@ -24,8 +28,20 @@ function logAll() {
24
28
 
25
29
  logErrors(runInfinitePollCallAtStart(timeInMinute * 5, logAll));
26
30
 
31
+ let shuttingDown = false;
27
32
  export async function shutdown() {
33
+ if (shuttingDown) {
34
+ return;
35
+ }
36
+ shuttingDown = true;
28
37
  const { authorityStorage } = await import("../0-path-value-core/pathValueCore");
38
+ for (let fnc of preshutdownHandlers) {
39
+ try {
40
+ await timeoutToError(timeInMinute, fnc(), () => new Error(`Preshutdown handler ${fnc.name} timed out`));
41
+ } catch (e) {
42
+ console.log(`Error on preshutdown handler ${fnc.name}`, e);
43
+ }
44
+ }
29
45
  try {
30
46
  await authorityStorage.onShutdown();
31
47
  await nodeDiscoveryShutdown();
@@ -34,11 +50,13 @@ export async function shutdown() {
34
50
  }
35
51
  for (let fnc of shutdownHandlers) {
36
52
  try {
37
- await fnc();
53
+ await timeoutToError(timeInMinute, fnc(), () => new Error(`Shutdown handler ${fnc.name} timed out`));
38
54
  } catch (e) {
39
55
  console.log(`Error on shutdown handler ${fnc.name}`, e);
40
56
  }
41
57
  }
58
+ // Wait to allow any logged errors to hopefully be written somewhere?
59
+ await delay(2000);
42
60
  process.exit();
43
61
  }
44
62
 
package/src/server.ts CHANGED
@@ -14,7 +14,6 @@ import { AuthorityPath, pathValueAuthority2 } from "./0-path-value-core/NodePath
14
14
  import { Querysub } from "./4-querysub/QuerysubController";
15
15
  import { ClientWatcher } from "./1-path-client/pathValueClientWatcher";
16
16
  import { delay } from "socket-function/src/batching";
17
- import yargs from "yargs";
18
17
  import { JSONLACKS } from "socket-function/src/JSONLACKS/JSONLACKS";
19
18
  import { green } from "socket-function/src/formatting/logColors";
20
19
  import { formatTime } from "socket-function/src/formatting/format";
@@ -26,6 +25,7 @@ import { getOwnThreadId } from "./-f-node-discovery/NodeDiscovery";
26
25
  import { getOurAuthorities } from "./config2";
27
26
  import { devDebugbreak, isPublic } from "./config";
28
27
 
28
+ import yargs from "yargs";
29
29
  let yargObj = isNodeTrue() && yargs(process.argv)
30
30
  .option("authority", { type: "string", desc: `Defines the base paths we are an authority on (the domain is prepended to them). Either a file path to a JSON(AuthorityPath[]), or a base64 representation of the JSON(AuthorityPath[]).` })
31
31
  .option("verbose", { type: "boolean", desc: "Log all writes and reads" })
@@ -1,7 +1,7 @@
1
1
  import preact from "preact"; import { qreact } from "../4-dom/qreact";
2
2
  import { Button } from "../library-components/Button";
3
3
  import { css } from "../4-dom/css";
4
- import { loginTokenURL, user_data, user_functions } from "./userData";
4
+ import { getUserId, getUserIdAllowUndefined, getUserObj, loginTokenURL, user_data, user_functions } from "./userData";
5
5
  import { Querysub } from "../4-querysub/Querysub";
6
6
  import { isNode, list } from "socket-function/src/misc";
7
7
  import { InputLabel } from "../library-components/InputLabel";
@@ -16,7 +16,7 @@ export class LoginPage extends qreact.Component {
16
16
  render() {
17
17
  const login = () => {
18
18
  user_functions.sendLoginEmail({
19
- userId: this.state.email,
19
+ email: this.state.email,
20
20
  redirectURL: window.location.href,
21
21
  loginTokenRandomness: Buffer.from(list(32).map(() => Math.random() * 256)).toString("base64"),
22
22
  });
@@ -54,6 +54,22 @@ export class LoginPage extends qreact.Component {
54
54
  </div>
55
55
  </div>
56
56
  }
57
+ {getUserIdAllowUndefined() &&
58
+ <div class={css.hbox(10).alignSelf("center").maxWidth("50vw")}>
59
+ <div class={css.fontSize(20).hslcolor(110, 75, 75)}>
60
+ You are logged in as {getUserObj().email}.
61
+ </div>
62
+ <button onClick={() => {
63
+ let call = user_functions.logoutCurrent();
64
+ void Querysub.onCommitPredictFinished(call).finally(async () => {
65
+ await Querysub.gracefulTerminateProcess();
66
+ location.reload();
67
+ });
68
+ }}>
69
+ Logout
70
+ </button>
71
+ </div>
72
+ }
57
73
  </div>
58
74
  );
59
75
  }
@@ -268,7 +268,7 @@ export class UserPage extends qreact.Component {
268
268
  </table>
269
269
  </div>
270
270
  <div>
271
- <div class={css.fontSize(20)}>Latest Page Load By Machine/IP</div>
271
+ <div class={css.fontSize(20)}>Latest Access by Machine/IP</div>
272
272
  <table>
273
273
  <tr>
274
274
  <th>Source</th>
@@ -293,7 +293,7 @@ export class UserPage extends qreact.Component {
293
293
  <h2>Example Login Email</h2>
294
294
  <div key="email" ref2={e => {
295
295
  e.innerHTML = renderToString(generateLoginEmail({
296
- userId,
296
+ email: "user@example.com",
297
297
  loginToken: "loginToken",
298
298
  ip: "127.0.0.1",
299
299
  machineId: getOwnMachineId(),
@@ -14,12 +14,11 @@ async function main() {
14
14
  const email = yargObj.email;
15
15
  if (!email) throw new Error("No email provided." + howToCall);
16
16
  if (!email.includes("@")) throw new Error(`Invalid email ${email}.` + howToCall);
17
- const userId = email;
18
17
 
19
18
  await Querysub.hostService("addSuperUser");
20
19
 
21
20
  await Querysub.commitSynced(() => {
22
- scriptCreateUser({ userId });
21
+ scriptCreateUser({ email });
23
22
  });
24
23
 
25
24
  await pathValueCommitter.waitForValuesToCommit();
@@ -4,7 +4,7 @@ import { list } from "socket-function/src/misc";
4
4
  import { hslToRGB } from "socket-function/src/formatting/colors";
5
5
  import { joinVNodes } from "../misc";
6
6
  export function generateLoginEmail(config: {
7
- userId: string;
7
+ email: string;
8
8
  loginToken: string;
9
9
  ip: string;
10
10
  machineId: string;
@@ -12,10 +12,10 @@ export function generateLoginEmail(config: {
12
12
  timeoutTime: number;
13
13
  noHTMLWrapper?: boolean;
14
14
  }): { subject: string; contents: preact.VNode } {
15
- const { userId, loginToken, ip, machineId, redirectURL } = config;
15
+ const { loginToken, ip, machineId, redirectURL, email } = config;
16
16
  let url = new URL(redirectURL);
17
17
  url.searchParams.set(loginTokenURL.urlKey, loginToken);
18
- const subject = `${userId} Access Request (${new Date().toLocaleString()})`;
18
+ const subject = `${email} Access Request (${new Date().toLocaleString()})`;
19
19
  function center(jsx: preact.ComponentChild) {
20
20
  return (
21
21
  <table style={{ width: "100%" }}>
@@ -79,7 +79,7 @@ export function generateLoginEmail(config: {
79
79
  </table>
80
80
  {line(<div style={{ color: "rgb(255, 255, 255)" }}>
81
81
  {/* Mess up the email so it isn't turned into a link by gmail. */}
82
- Greetings <b>{userId.replaceAll(".", ".​")}</b>. You have requested access to <a href={redirectURL} style={{ color: "rgb(153, 204, 255)" }}>{new URL(redirectURL).hostname}</a>.
82
+ Greetings <b>{email.replaceAll(".", ".​")}</b>. You have requested access to <a href={redirectURL} style={{ color: "rgb(153, 204, 255)" }}>{new URL(redirectURL).hostname}</a>.
83
83
  </div>)}
84
84
  {line(<div style={{ color: "rgb(255, 255, 255)" }}>
85
85
  If you did not make this request, please disregard this email. Your account is safe and no action is required.
@@ -114,11 +114,11 @@ export function generateLoginEmail(config: {
114
114
  <td {...{ "nowrap": true }} style={{ paddingLeft: "40px", }}></td>
115
115
  </tr></tbody></table>
116
116
  <table><tbody><tr><td style={{ paddingTop: "20px" }}></td></tr></tbody></table>
117
- {line(<div style={{ color: "rgb(140, 140, 140)", width: "100%" }}><b>Do NOT share or forward this email with anyone.</b></div>)}
117
+ {line(<div style={{ color: "rgb(140, 140, 140)", width: "100%" }}><b>Do NOT share or forward this email to anyone.</b></div>)}
118
118
  <table><tbody><tr><td style={{ paddingTop: "10px" }}></td></tr></tbody></table>
119
119
  {line(<div style={{ color: "rgb(140, 140, 140)", width: "100%" }}>
120
- This link will only work with the current device and browser.
121
- This link can only be used one time, until {new Date(config.timeoutTime).toLocaleString()}.
120
+ This link will only work in the current device and browser.
121
+ This link can only be used one time, and will expire at {new Date(config.timeoutTime).toLocaleString()}.
122
122
  </div>)}
123
123
  </>
124
124
  );
@@ -1,7 +1,7 @@
1
1
  import { atomic, atomicObjectWrite } from "../2-proxy/PathValueProxyWatcher";
2
2
  import { Querysub } from "../4-querysub/QuerysubController";
3
3
  import { red } from "socket-function/src/formatting/logColors";
4
- import { isNode, timeInHour } from "socket-function/src/misc";
4
+ import { isNode, sha256Hash, timeInHour } from "socket-function/src/misc";
5
5
  import { registerAliveChecker } from "../2-proxy/garbageCollection";
6
6
  import { generateLoginEmail } from "./loginEmail";
7
7
  import { logErrors } from "../errors";
@@ -15,6 +15,7 @@ import { devDebugbreak, getDomain, isDevDebugbreak } from "../config";
15
15
  import { delay } from "socket-function/src/batching";
16
16
  import { enableErrorNotifications } from "../library-components/errorNotifications";
17
17
  import { clamp } from "../misc";
18
+ import { sha256 } from "js-sha256";
18
19
 
19
20
  /*
20
21
  IMPORTANT!
@@ -186,6 +187,10 @@ const { data, functions } = Querysub.syncSchema<{
186
187
  claimedDisplayNames: {
187
188
  [displayName: string]: string;
188
189
  };
190
+
191
+ emailToUserId: {
192
+ [email: string]: string;
193
+ };
189
194
  };
190
195
  machineSecure: {
191
196
  [machineId: string]: {
@@ -332,6 +337,7 @@ export function wildcard0Owner(config: PermissionsParameters) {
332
337
  }
333
338
 
334
339
  function isAllowedToBeUser(userId: string) {
340
+ userId = String(userId);
335
341
  if (isClient()) {
336
342
  return data().users[userId].userId === userId;
337
343
  }
@@ -532,12 +538,20 @@ function internalCreateUser(config: {
532
538
  return data().users[userId];
533
539
  }
534
540
 
541
+ function createNewUserId() {
542
+ let randBuffer = Buffer.from(sha256(Querysub.nextId()), "hex");
543
+ const alphabet = "abcdefghijklmnopqrstuvwxyz";
544
+ return Array.from(randBuffer)
545
+ .map(x => alphabet[x % alphabet.length])
546
+ .join("")
547
+ .slice(0, 16);
548
+ }
535
549
  function sendLoginEmail(config: {
536
- userId: string;
550
+ email: string;
537
551
  redirectURL: string;
538
552
  loginTokenRandomness: string;
539
553
  }) {
540
- const { userId, redirectURL } = config;
554
+ const { email, redirectURL } = config;
541
555
  {
542
556
  let redirectObj = new URL(redirectURL);
543
557
  redirectObj.port = "443";
@@ -547,19 +561,24 @@ function sendLoginEmail(config: {
547
561
  }
548
562
  }
549
563
  Querysub.ignorePermissionsChecks(() => {
550
- if (!userId.includes("@")) {
551
- throw new Error(`Invalid email ${userId}`);
564
+ if (!email.includes("@")) {
565
+ throw new Error(`Invalid email ${email}`);
552
566
  }
553
- const email = userId;
567
+ let userId = atomic(data().secure.emailToUserId[email]) || createNewUserId();
554
568
 
555
569
  const signupError = `Signups are currently closed, and user doesn't presently exist. ${userId}. Add users with "yarn addsuperuser <email>"`;
556
570
  // NOTE: Using the same errors message makes it a BIT harder to know if you are blocked, so attackers are
557
571
  // less likely to change their IP, and more likely to give up.
558
572
  throwIfBlockedIP(signupError);
559
- if (!atomic(data().secure.signupsOpen) && !(userId in data().users)) {
573
+ if (!atomic(data().secure.signupsOpen) && (!userId || !(userId in data().users))) {
560
574
  throw new Error(signupError);
561
575
  }
562
576
 
577
+ // Create user if it doesn't exist
578
+ if (!(userId in data().users)) {
579
+ internalCreateUser({ userId, email });
580
+ }
581
+
563
582
  const ip = Querysub.getCallerIP();
564
583
  const machineId = Querysub.getCallerMachineId();
565
584
  const now = Querysub.getCallTime();
@@ -605,13 +624,6 @@ function sendLoginEmail(config: {
605
624
  }
606
625
  }
607
626
 
608
- // Create user if it doesn't exist
609
- {
610
- if (!(userId in data().users)) {
611
- internalCreateUser({ userId, email });
612
- }
613
- }
614
-
615
627
  let userObj = data().users[userId];
616
628
 
617
629
  // Per email/user throttle
@@ -644,7 +656,7 @@ function sendLoginEmail(config: {
644
656
  // NOTE: We don't track failed calls. Those should be tracked at a framework level and displayed on another page.
645
657
  logActivity({ type: "sendLoginEmail", fields: { userId, ip, machineId, redirectURL } });
646
658
  const { subject, contents } = generateLoginEmail({
647
- userId, loginToken, ip, machineId, redirectURL,
659
+ email, loginToken, ip, machineId, redirectURL,
648
660
  timeoutTime,
649
661
  });
650
662
  Querysub.onCommitFinished(async () => {
@@ -756,35 +768,37 @@ function registerPageLoadTime() {
756
768
  }
757
769
 
758
770
  function inviteUser(config: { email: string }) {
759
- let curUserObj = getUserObjAssert();
760
- if (config.email in curUserObj.invitedUsers) {
761
- console.info(`User ${config.email} already invited`);
762
- return;
763
- }
764
- // If the user already exists, don't invite
765
- const { email } = config;
766
- let userId = email;
767
- if (userId in data().users) {
768
- console.info(`User ${userId} already exists, no need to invite`);
769
- return;
770
- }
771
+ Querysub.ignorePermissionsChecks(() => {
772
+ let curUserObj = getUserObjAssert();
773
+ if (config.email in curUserObj.invitedUsers) {
774
+ console.info(`User ${config.email} already invited`);
775
+ return;
776
+ }
777
+ // If the user already exists, don't invite
778
+ const { email } = config;
779
+ let userId = atomic(data().secure.emailToUserId[email]) || createNewUserId();
780
+ if (userId in data().users) {
781
+ console.info(`User ${userId} already exists, no need to invite`);
782
+ return;
783
+ }
771
784
 
772
- const invitesRemaining = Number(curUserObj.invitesRemaining);
773
- if (invitesRemaining <= 0) {
774
- throw new Error("No invites remaining");
775
- }
785
+ const invitesRemaining = Number(curUserObj.invitesRemaining);
786
+ if (invitesRemaining <= 0) {
787
+ throw new Error("No invites remaining");
788
+ }
776
789
 
777
- curUserObj.invitedUsers[config.email] = atomicObjectWrite({
778
- time: Querysub.getCallTime(),
779
- });
780
- curUserObj.invitesRemaining--;
790
+ curUserObj.invitedUsers[config.email] = atomicObjectWrite({
791
+ time: Querysub.getCallTime(),
792
+ });
793
+ curUserObj.invitesRemaining--;
781
794
 
782
- if (!(userId in data().users)) {
783
- // Create the user if they don't exist
784
- let newUserObj = internalCreateUser({ userId, email });
785
- newUserObj.invitedBy = curUserObj.userId;
786
- }
787
- logActivity({ type: "inviteUser", fields: { inviter: curUserObj.userId, userId, } });
795
+ if (!(userId in data().users)) {
796
+ // Create the user if they don't exist
797
+ let newUserObj = internalCreateUser({ userId, email });
798
+ newUserObj.invitedBy = curUserObj.userId;
799
+ }
800
+ logActivity({ type: "inviteUser", fields: { inviter: curUserObj.userId, userId, } });
801
+ });
788
802
  }
789
803
 
790
804
  function logoutFromMachine(config: { machineId: string; userId: string }) {
@@ -888,11 +902,11 @@ function specialSetUserType(config: { userId: string; userType: UserType; }) {
888
902
 
889
903
 
890
904
  export function scriptCreateUser(config: {
891
- userId: string;
905
+ email: string;
892
906
  }) {
893
- const { userId } = config;
894
- let email = userId;
895
- data().users[config.userId] = {
907
+ const { email } = config;
908
+ let userId = createNewUserId();
909
+ data().users[userId] = {
896
910
  userId,
897
911
  email,
898
912
  settings: { displayName: userId },
@@ -905,6 +919,7 @@ export function scriptCreateUser(config: {
905
919
  invitedUsers: {},
906
920
  invitesRemaining: 0,
907
921
  };
922
+ data().secure.emailToUserId[email] = userId;
908
923
  }
909
924
 
910
925
  function setPostmarkAPIKey(config: { apiKey: string; }) {