From 08e6c3bad8b584e6d1a8bac30c1ff8cb1f6142b3 Mon Sep 17 00:00:00 2001
From: Zef Hemel <zef@zef.me>
Date: Fri, 25 Mar 2022 12:03:06 +0100
Subject: [PATCH] Refactoring

---
 common/manifest.ts                            |   4 +-
 package.json                                  |   7 +-
 plugbox/environment/iframe_sandbox.ts         |   6 +-
 plugbox/environment/node_sandbox.ts           |   6 +-
 plugbox/environment/webworker_sandbox.ts      |   6 +-
 plugbox/feature/endpoint.test.ts              |   2 +-
 plugbox/feature/event.ts                      |  43 ++++++++
 plugbox/plug.ts                               |  39 +++----
 plugbox/plug_loader.ts                        |   6 +-
 plugbox/runtime.test.ts                       |  45 +++++++-
 plugbox/sandbox.ts                            |  12 ++-
 plugbox/syscall/fetch.node.ts                 |  15 +++
 .../shell.ts => plugbox/syscall/shell.node.ts |   9 +-
 plugbox/syscall/store.dexie_browser.test.ts   |  50 +++++++++
 plugbox/syscall/store.dexie_browser.ts        |  66 ++++++++++++
 plugbox/syscall/store.knex_node.test.ts       |  50 +++++++++
 plugbox/syscall/store.knex_node.ts            |  84 +++++++++++++++
 plugbox/syscall/transport.ts                  |  16 +++
 plugbox/system.ts                             |  73 ++++++++-----
 plugbox/types.ts                              |   7 +-
 plugs/core/core.plug.json                     |  10 --
 plugs/core/dates.ts                           |   2 +-
 plugs/core/git.ts                             |  12 ---
 plugs/core/markup.ts                          |   2 +-
 plugs/core/navigate.ts                        |  36 ++-----
 plugs/core/page.ts                            |  13 +--
 plugs/core/server.ts                          |   1 +
 plugs/core/task.ts                            |   2 +-
 plugs/core/word_count_command.ts              |   2 +-
 plugs/git/git.plug.json                       |  37 +++++++
 plugs/git/git.ts                              |  42 ++++++++
 plugs/{core => }/lib/syscall.ts               |   0
 server/api_server.ts                          |  20 +++-
 server/index_api.ts                           |  17 +--
 server/page_api.ts                            |   6 +-
 server/server.ts                              |   8 +-
 server/syscalls/page_index.ts                 |  25 ++---
 tsconfig.json                                 |   9 +-
 webapp/collab.ts                              |   3 +-
 webapp/components/top_bar.tsx                 |  15 +--
 webapp/editor.tsx                             |  34 +++---
 webapp/space.ts                               |  38 +------
 webapp/styles/editor.scss                     |   1 -
 webapp/syscalls/db.localstorage.ts            |   8 --
 .../syscalls/{editor.browser.ts => editor.ts} |  33 +++---
 webapp/syscalls/indexer.native.ts             |  22 ----
 webapp/syscalls/indexer.ts                    |  17 +++
 webapp/syscalls/{space.native.ts => space.ts} |  14 +--
 webapp/syscalls/system.ts                     |  13 +++
 webapp/syscalls/ui.browser.ts                 |  20 ----
 webapp/tsconfig.json                          |  12 ---
 yarn.lock                                     | 102 +++++++++++++++++-
 52 files changed, 796 insertions(+), 326 deletions(-)
 create mode 100644 plugbox/feature/event.ts
 create mode 100644 plugbox/syscall/fetch.node.ts
 rename server/syscalls/shell.ts => plugbox/syscall/shell.node.ts (54%)
 create mode 100644 plugbox/syscall/store.dexie_browser.test.ts
 create mode 100644 plugbox/syscall/store.dexie_browser.ts
 create mode 100644 plugbox/syscall/store.knex_node.test.ts
 create mode 100644 plugbox/syscall/store.knex_node.ts
 create mode 100644 plugbox/syscall/transport.ts
 delete mode 100644 plugs/core/git.ts
 create mode 100644 plugs/git/git.plug.json
 create mode 100644 plugs/git/git.ts
 rename plugs/{core => }/lib/syscall.ts (100%)
 delete mode 100644 webapp/syscalls/db.localstorage.ts
 rename webapp/syscalls/{editor.browser.ts => editor.ts} (78%)
 delete mode 100644 webapp/syscalls/indexer.native.ts
 create mode 100644 webapp/syscalls/indexer.ts
 rename webapp/syscalls/{space.native.ts => space.ts} (64%)
 create mode 100644 webapp/syscalls/system.ts
 delete mode 100644 webapp/syscalls/ui.browser.ts
 delete mode 100644 webapp/tsconfig.json

diff --git a/common/manifest.ts b/common/manifest.ts
index 1ba822c..06187b2 100644
--- a/common/manifest.ts
+++ b/common/manifest.ts
@@ -1,6 +1,7 @@
 import * as plugbox from "../plugbox/types";
 import { EndpointHook } from "../plugbox/feature/endpoint";
 import { CronHook } from "../plugbox/feature/node_cron";
+import { EventHook } from "../plugbox/feature/event";
 
 export type CommandDef = {
   // Function name to invoke
@@ -20,6 +21,7 @@ export type SilverBulletHooks = {
     [key: string]: CommandDef;
   };
 } & EndpointHook &
-  CronHook;
+  CronHook &
+  EventHook;
 
 export type Manifest = plugbox.Manifest<SilverBulletHooks>;
diff --git a/package.json b/package.json
index 35e7c1a..2b39a16 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
     "watch": "rm -rf .parcel-cache && parcel watch",
     "build": "parcel build",
     "clean": "rm -rf dist",
-    "plugs": "node dist/bundler/plugbox-bundle.js plugs/core/core.plug.json plugs/dist/core.plug.json",
+    "plugs": "node dist/bundler/plugbox-bundle.js plugs/core/core.plug.json plugs/dist/core.plug.json && node dist/bundler/plugbox-bundle.js plugs/git/git.plug.json plugs/dist/git.plug.json",
     "server": "nodemon -w dist/server dist/server/server.js pages",
     "test": "jest"
   },
@@ -41,6 +41,8 @@
       "source": [
         "plugbox/runtime.test.ts",
         "plugbox/feature/endpoint.test.ts",
+        "plugbox/syscall/store.knex_node.test.ts",
+        "plugbox/syscall/store.dexie_browser.test.ts",
         "server/api.test.ts"
       ],
       "outputFormat": "commonjs",
@@ -72,11 +74,13 @@
     "dexie": "^3.2.1",
     "esbuild": "^0.14.27",
     "express": "^4.17.3",
+    "fake-indexeddb": "^3.1.7",
     "idb": "^7.0.0",
     "jest": "^27.5.1",
     "knex": "^1.0.4",
     "lodash": "^4.17.21",
     "node-cron": "^3.0.0",
+    "node-fetch": "^3.2.3",
     "nodemon": "^2.0.15",
     "parcel": "^2.3.2",
     "react": "^17.0.2",
@@ -99,6 +103,7 @@
     "@types/jest": "^27.4.1",
     "@types/node": "^17.0.21",
     "@types/node-cron": "^3.0.1",
+    "@types/node-fetch": "^2.6.1",
     "@types/react": "^17.0.39",
     "@types/react-dom": "^17.0.11",
     "@types/supertest": "^2.0.11",
diff --git a/plugbox/environment/iframe_sandbox.ts b/plugbox/environment/iframe_sandbox.ts
index a4448c2..81e901e 100644
--- a/plugbox/environment/iframe_sandbox.ts
+++ b/plugbox/environment/iframe_sandbox.ts
@@ -3,8 +3,8 @@ import { safeRun } from "../util";
 // @ts-ignore
 import sandboxHtml from "bundle-text:./iframe_sandbox.html";
 import { Sandbox } from "../sandbox";
-import { System } from "../system";
 import { WorkerLike } from "./worker";
+import { Plug } from "../plug";
 
 class IFrameWrapper implements WorkerLike {
   private iframe: HTMLIFrameElement;
@@ -49,6 +49,6 @@ class IFrameWrapper implements WorkerLike {
   }
 }
 
-export function createSandbox(system: System<any>) {
-  return new Sandbox(system, new IFrameWrapper());
+export function createSandbox(plug: Plug<any>) {
+  return new Sandbox(plug, new IFrameWrapper());
 }
diff --git a/plugbox/environment/node_sandbox.ts b/plugbox/environment/node_sandbox.ts
index 7de76e5..f51b99a 100644
--- a/plugbox/environment/node_sandbox.ts
+++ b/plugbox/environment/node_sandbox.ts
@@ -4,8 +4,8 @@ import { safeRun } from "../util";
 // @ts-ignore
 import workerCode from "bundle-text:./node_worker.ts";
 import { Sandbox } from "../sandbox";
-import { System } from "../system";
 import { WorkerLike } from "./worker";
+import { Plug } from "../plug";
 
 class NodeWorkerWrapper implements WorkerLike {
   onMessage?: (message: any) => Promise<void>;
@@ -33,12 +33,12 @@ class NodeWorkerWrapper implements WorkerLike {
   }
 }
 
-export function createSandbox(system: System<any>) {
+export function createSandbox(plug: Plug<any>) {
   let worker = new Worker(workerCode, {
     eval: true,
   });
   return new Sandbox(
-    system,
+    plug,
     new NodeWorkerWrapper(
       new Worker(workerCode, {
         eval: true,
diff --git a/plugbox/environment/webworker_sandbox.ts b/plugbox/environment/webworker_sandbox.ts
index e7be30b..307ae79 100644
--- a/plugbox/environment/webworker_sandbox.ts
+++ b/plugbox/environment/webworker_sandbox.ts
@@ -1,7 +1,7 @@
 import { safeRun } from "../util";
 import { Sandbox } from "../sandbox";
-import { System } from "../system";
 import { WorkerLike } from "./worker";
+import { Plug } from "../plug";
 
 class WebWorkerWrapper implements WorkerLike {
   private worker: Worker;
@@ -28,10 +28,10 @@ class WebWorkerWrapper implements WorkerLike {
   }
 }
 
-export function createSandbox(system: System<any>) {
+export function createSandbox(plug: Plug<any>) {
   // ParcelJS will build this file into a worker.
   let worker = new Worker(new URL("sandbox_worker.ts", import.meta.url), {
     type: "module",
   });
-  return new Sandbox(system, new WebWorkerWrapper(worker));
+  return new Sandbox(plug, new WebWorkerWrapper(worker));
 }
diff --git a/plugbox/feature/endpoint.test.ts b/plugbox/feature/endpoint.test.ts
index 58bfbdc..86016ee 100644
--- a/plugbox/feature/endpoint.test.ts
+++ b/plugbox/feature/endpoint.test.ts
@@ -27,7 +27,7 @@ test("Run a plugbox endpoint server", async () => {
         endpoints: [{ method: "GET", path: "/", handler: "testhandler" }],
       },
     } as Manifest<EndpointHook>,
-    createSandbox(system)
+    createSandbox
   );
 
   const app = express();
diff --git a/plugbox/feature/event.ts b/plugbox/feature/event.ts
new file mode 100644
index 0000000..54cc040
--- /dev/null
+++ b/plugbox/feature/event.ts
@@ -0,0 +1,43 @@
+import { Feature, Manifest } from "../types";
+import { System } from "../system";
+
+export type EventHook = {
+  events?: { [key: string]: string[] };
+};
+
+export class EventFeature implements Feature<EventHook> {
+  private system?: System<EventHook>;
+
+  async dispatchEvent(name: string, data?: any): Promise<any[]> {
+    if (!this.system) {
+      throw new Error("EventFeature is not initialized");
+    }
+    let promises: Promise<any>[] = [];
+    for (const plug of this.system.loadedPlugs.values()) {
+      if (!plug.manifest!.hooks?.events) {
+        continue;
+      }
+      let functionsToSpawn = plug.manifest!.hooks.events[name];
+      if (functionsToSpawn) {
+        functionsToSpawn.forEach((functionToSpawn) => {
+          // Only dispatch functions on events when they're allowed to be invoked in this environment
+          if (plug.canInvoke(functionToSpawn)) {
+            promises.push(plug.invoke(functionToSpawn, [data]));
+          }
+        });
+      }
+    }
+    return Promise.all(promises);
+  }
+
+  apply(system: System<EventHook>): void {
+    this.system = system;
+    system.on({
+      plugLoaded: (name, plug) => {},
+    });
+  }
+
+  validateManifest(manifest: Manifest<EventHook>): string[] {
+    return [];
+  }
+}
diff --git a/plugbox/plug.ts b/plugbox/plug.ts
index f8688d9..c15c1c1 100644
--- a/plugbox/plug.ts
+++ b/plugbox/plug.ts
@@ -7,16 +7,28 @@ export class Plug<HookT> {
   sandbox: Sandbox;
   public manifest?: Manifest<HookT>;
   readonly runtimeEnv: RuntimeEnvironment;
+  grantedPermissions: string[] = [];
+  name: string;
 
-  constructor(system: System<HookT>, name: string, sandbox: Sandbox) {
+  constructor(
+    system: System<HookT>,
+    name: string,
+    sandboxFactory: (plug: Plug<HookT>) => Sandbox
+  ) {
     this.system = system;
-    this.sandbox = sandbox;
+    this.name = name;
+    this.sandbox = sandboxFactory(this);
     this.runtimeEnv = system.runtimeEnv;
   }
 
   async load(manifest: Manifest<HookT>) {
     this.manifest = manifest;
-    await this.dispatchEvent("load");
+    // TODO: These need to be explicitly granted, not just taken
+    this.grantedPermissions = manifest.requiredPermissions || [];
+  }
+
+  syscall(name: string, args: any[]): Promise<any> {
+    return this.system.syscallWithContext({ plug: this }, name, args);
   }
 
   canInvoke(name: string) {
@@ -46,27 +58,6 @@ export class Plug<HookT> {
     return await this.sandbox.invoke(name, args);
   }
 
-  async dispatchEvent(name: string, data?: any): Promise<any[]> {
-    if (!this.manifest!.hooks?.events) {
-      return [];
-    }
-    let functionsToSpawn = this.manifest!.hooks.events[name];
-    if (functionsToSpawn) {
-      return await Promise.all(
-        functionsToSpawn.map((functionToSpawn: string) => {
-          // Only dispatch functions on events when they're allowed to be invoked in this environment
-          if (this.canInvoke(functionToSpawn)) {
-            return this.invoke(functionToSpawn, [data]);
-          } else {
-            return Promise.resolve();
-          }
-        })
-      );
-    } else {
-      return [];
-    }
-  }
-
   async stop() {
     this.sandbox.stop();
   }
diff --git a/plugbox/plug_loader.ts b/plugbox/plug_loader.ts
index f32e608..ff88a8b 100644
--- a/plugbox/plug_loader.ts
+++ b/plugbox/plug_loader.ts
@@ -20,9 +20,7 @@ export class DiskPlugLoader<HookT> {
 
   watcher() {
     safeRun(async () => {
-      for await (const { filename, eventType } of watch(this.plugPath, {
-        recursive: true,
-      })) {
+      for await (const { filename, eventType } of watch(this.plugPath)) {
         if (!filename.endsWith(".plug.json")) {
           return;
         }
@@ -50,7 +48,7 @@ export class DiskPlugLoader<HookT> {
     console.log("Now loading plug", plugName);
     try {
       const plugDef = JSON.parse(plug);
-      await this.system.load(plugName, plugDef, createSandbox(this.system));
+      await this.system.load(plugName, plugDef, createSandbox);
       return plugDef;
     } catch (e) {
       console.error("Could not parse plugin file", e);
diff --git a/plugbox/runtime.test.ts b/plugbox/runtime.test.ts
index afe35e0..1e3682c 100644
--- a/plugbox/runtime.test.ts
+++ b/plugbox/runtime.test.ts
@@ -4,17 +4,28 @@ import { System } from "./system";
 
 test("Run a Node sandbox", async () => {
   let system = new System("server");
-  system.registerSyscalls({
-    addNumbers: (a, b) => {
+  system.registerSyscalls("", [], {
+    addNumbers: (ctx, a, b) => {
       return a + b;
     },
     failingSyscall: () => {
       throw new Error("#fail");
     },
   });
+  system.registerSyscalls("", ["restricted"], {
+    restrictedSyscall: () => {
+      return "restricted";
+    },
+  });
+  system.registerSyscalls("", ["dangerous"], {
+    dangerousSyscall: () => {
+      return "yay";
+    },
+  });
   let plug = await system.load(
     "test",
     {
+      requiredPermissions: ["dangerous"],
       functions: {
         addTen: {
           code: `(() => {
@@ -52,12 +63,30 @@ test("Run a Node sandbox", async () => {
           };
         })()`,
         },
+        restrictedTest: {
+          code: `(() => {
+          return {
+            default: async () => {
+              await self.syscall("restrictedSyscall");
+            }
+          };
+        })()`,
+        },
+        dangerousTest: {
+          code: `(() => {
+          return {
+            default: async () => {
+              return await self.syscall("dangerousSyscall");
+            }
+          };
+        })()`,
+        },
       },
       hooks: {
         events: {},
       },
     },
-    createSandbox(system)
+    createSandbox
   );
   expect(await plug.invoke("addTen", [10])).toBe(20);
   for (let i = 0; i < 100; i++) {
@@ -75,5 +104,15 @@ test("Run a Node sandbox", async () => {
   } catch (e: any) {
     expect(e.message).toBe("#fail");
   }
+  try {
+    await plug.invoke("restrictedTest", []);
+    expect(true).toBe(false);
+  } catch (e: any) {
+    expect(e.message).toBe(
+      "Missing permission 'restricted' for syscall restrictedSyscall"
+    );
+  }
+  expect(await plug.invoke("dangerousTest", [])).toBe("yay");
+
   await system.unloadAll();
 });
diff --git a/plugbox/sandbox.ts b/plugbox/sandbox.ts
index 3b32f68..8da85cb 100644
--- a/plugbox/sandbox.ts
+++ b/plugbox/sandbox.ts
@@ -1,9 +1,11 @@
-import { System } from "./system";
 import {
   ControllerMessage,
   WorkerLike,
   WorkerMessage,
 } from "./environment/worker";
+import { Plug } from "./plug";
+
+export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox;
 
 export class Sandbox {
   protected worker: WorkerLike;
@@ -14,12 +16,12 @@ export class Sandbox {
     { resolve: (result: any) => void; reject: (e: any) => void }
   >();
   protected loadedFunctions = new Set<string>();
-  protected system: System<any>;
+  protected plug: Plug<any>;
 
-  constructor(system: System<any>, worker: WorkerLike) {
+  constructor(plug: Plug<any>, worker: WorkerLike) {
     worker.onMessage = this.onMessage.bind(this);
     this.worker = worker;
-    this.system = system;
+    this.plug = plug;
   }
 
   isLoaded(name: string) {
@@ -48,7 +50,7 @@ export class Sandbox {
         break;
       case "syscall":
         try {
-          let result = await this.system.syscall(data.name!, data.args!);
+          let result = await this.plug.syscall(data.name!, data.args!);
 
           this.worker.postMessage({
             type: "syscall-response",
diff --git a/plugbox/syscall/fetch.node.ts b/plugbox/syscall/fetch.node.ts
new file mode 100644
index 0000000..0bb1b2f
--- /dev/null
+++ b/plugbox/syscall/fetch.node.ts
@@ -0,0 +1,15 @@
+import fetch, { RequestInfo, RequestInit } from "node-fetch";
+import { SysCallMapping } from "../system";
+
+export function fetchSyscalls(): SysCallMapping {
+  return {
+    async fetchJson(ctx, url: RequestInfo, init: RequestInit) {
+      let resp = await fetch(url, init);
+      return resp.json();
+    },
+    async fetchText(ctx, url: RequestInfo, init: RequestInit) {
+      let resp = await fetch(url, init);
+      return resp.text();
+    },
+  };
+}
diff --git a/server/syscalls/shell.ts b/plugbox/syscall/shell.node.ts
similarity index 54%
rename from server/syscalls/shell.ts
rename to plugbox/syscall/shell.node.ts
index 5c3c1d6..3b3fd3a 100644
--- a/server/syscalls/shell.ts
+++ b/plugbox/syscall/shell.node.ts
@@ -1,11 +1,16 @@
 import { promisify } from "util";
 import { execFile } from "child_process";
+import type { SysCallMapping } from "../system";
 
 const execFilePromise = promisify(execFile);
 
-export default function (cwd: string) {
+export default function (cwd: string): SysCallMapping {
   return {
-    "shell.run": async (cmd: string, args: string[]) => {
+    run: async (
+      ctx,
+      cmd: string,
+      args: string[]
+    ): Promise<{ stdout: string; stderr: string }> => {
       let { stdout, stderr } = await execFilePromise(cmd, args, {
         cwd: cwd,
       });
diff --git a/plugbox/syscall/store.dexie_browser.test.ts b/plugbox/syscall/store.dexie_browser.test.ts
new file mode 100644
index 0000000..4b15cd3
--- /dev/null
+++ b/plugbox/syscall/store.dexie_browser.test.ts
@@ -0,0 +1,50 @@
+import { createSandbox } from "../environment/node_sandbox";
+import { expect, test } from "@jest/globals";
+import { System } from "../system";
+import { storeSyscalls } from "./store.dexie_browser";
+
+// For testing in node.js
+require("fake-indexeddb/auto");
+
+test("Test store", async () => {
+  let system = new System("server");
+  system.registerSyscalls("store", [], storeSyscalls("test", "test"));
+  let plug = await system.load(
+    "test",
+    {
+      hooks: {},
+      functions: {
+        test1: {
+          code: `(() => {
+          return {
+            default: async () => {
+              await self.syscall("store.set", "name", "Pete");
+              return await self.syscall("store.get", "name");
+            }
+          };
+        })()`,
+        },
+        test2: {
+          code: `(() => {
+          return {
+            default: async () => {
+              await self.syscall("store.set", "page1:bl:page2:10", {title: "Something", meta: 20});
+              await self.syscall("store.batchSet", [
+                 {key: "page2:bl:page3", value: {title: "Something2", meta: 10}},
+                 {key: "page2:bl:page4", value: {title: "Something3", meta: 10}},
+              ]);
+              return await self.syscall("store.queryPrefix", "page2:");
+            }
+          };
+        })()`,
+        },
+      },
+    },
+    createSandbox
+  );
+  expect(await plug.invoke("test1", [])).toBe("Pete");
+  let queryResults = await plug.invoke("test2", []);
+  expect(queryResults.length).toBe(2);
+  expect(queryResults[0].value.meta).toBe(10);
+  await system.unloadAll();
+});
diff --git a/plugbox/syscall/store.dexie_browser.ts b/plugbox/syscall/store.dexie_browser.ts
new file mode 100644
index 0000000..125ae1d
--- /dev/null
+++ b/plugbox/syscall/store.dexie_browser.ts
@@ -0,0 +1,66 @@
+import Dexie from "dexie";
+import { SysCallMapping } from "../system";
+
+export type KV = {
+  key: string;
+  value: any;
+};
+
+export function storeSyscalls(
+  dbName: string,
+  tableName: string
+): SysCallMapping {
+  const db = new Dexie(dbName);
+  db.version(1).stores({
+    test: "key",
+  });
+  const items = db.table(tableName);
+
+  return {
+    async delete(ctx, key: string) {
+      await items.delete(key);
+    },
+
+    async deletePrefix(ctx, prefix: string) {
+      await items.where("key").startsWith(prefix).delete();
+    },
+
+    async deleteAll() {
+      await items.clear();
+    },
+
+    async set(ctx, key: string, value: any) {
+      await items.put({
+        key,
+        value,
+      });
+    },
+
+    async batchSet(ctx, kvs: KV[]) {
+      await items.bulkPut(
+        kvs.map(({ key, value }) => ({
+          key,
+          value,
+        }))
+      );
+    },
+
+    async get(ctx, key: string): Promise<any | null> {
+      let result = await items.get({
+        key,
+      });
+      return result ? result.value : null;
+    },
+
+    async queryPrefix(
+      ctx,
+      keyPrefix: string
+    ): Promise<{ key: string; value: any }[]> {
+      let results = await items.where("key").startsWith(keyPrefix).toArray();
+      return results.map((result) => ({
+        key: result.key,
+        value: result.value,
+      }));
+    },
+  };
+}
diff --git a/plugbox/syscall/store.knex_node.test.ts b/plugbox/syscall/store.knex_node.test.ts
new file mode 100644
index 0000000..2d142ac
--- /dev/null
+++ b/plugbox/syscall/store.knex_node.test.ts
@@ -0,0 +1,50 @@
+import { createSandbox } from "../environment/node_sandbox";
+import { expect, test } from "@jest/globals";
+import { System } from "../system";
+import {
+  ensureTable,
+  storeReadSyscalls,
+  storeWriteSyscalls,
+} from "./store.knex_node";
+import knex from "knex";
+import fs from "fs/promises";
+
+test("Test store", async () => {
+  const db = knex({
+    client: "better-sqlite3",
+    connection: {
+      filename: "test.db",
+    },
+    useNullAsDefault: true,
+  });
+  await ensureTable(db, "test_table");
+  let system = new System("server");
+  system.registerSyscalls(
+    "store",
+    [],
+    storeWriteSyscalls(db, "test_table"),
+    storeReadSyscalls(db, "test_table")
+  );
+  let plug = await system.load(
+    "test",
+    {
+      hooks: {},
+      functions: {
+        test1: {
+          code: `(() => {
+          return {
+            default: async () => {
+              await self.syscall("store.set", "name", "Pete");
+              return await self.syscall("store.get", "name");
+            }
+          };
+        })()`,
+        },
+      },
+    },
+    createSandbox
+  );
+  expect(await plug.invoke("test1", [])).toBe("Pete");
+  await system.unloadAll();
+  await fs.unlink("test.db");
+});
diff --git a/plugbox/syscall/store.knex_node.ts b/plugbox/syscall/store.knex_node.ts
new file mode 100644
index 0000000..d8d8961
--- /dev/null
+++ b/plugbox/syscall/store.knex_node.ts
@@ -0,0 +1,84 @@
+import { Knex } from "knex";
+import { SysCallMapping } from "../system";
+
+type Item = {
+  page: string;
+  key: string;
+  value: any;
+};
+
+export type KV = {
+  key: string;
+  value: any;
+};
+
+export async function ensureTable(db: Knex<any, unknown>, tableName: string) {
+  if (!(await db.schema.hasTable(tableName))) {
+    await db.schema.createTable(tableName, (table) => {
+      table.string("key");
+      table.text("value");
+      table.primary(["key"]);
+    });
+    console.log(`Created table ${tableName}`);
+  }
+}
+
+export function storeWriteSyscalls(
+  db: Knex<any, unknown>,
+  tableName: string
+): SysCallMapping {
+  const apiObj: SysCallMapping = {
+    delete: async (ctx, page: string, key: string) => {
+      await db<Item>(tableName).where({ page, key }).del();
+    },
+    deletePrefix: async (ctx, prefix: string) => {
+      return db<Item>(tableName).andWhereLike("key", `${prefix}%`).del();
+    },
+    deleteAll: async (ctx) => {
+      await db<Item>(tableName).del();
+    },
+    set: async (ctx, key: string, value: any) => {
+      let changed = await db<Item>(tableName)
+        .where({ key })
+        .update("value", JSON.stringify(value));
+      if (changed === 0) {
+        await db<Item>(tableName).insert({
+          key,
+          value: JSON.stringify(value),
+        });
+      }
+    },
+    batchSet: async (ctx, kvs: KV[]) => {
+      for (let { key, value } of kvs) {
+        await apiObj["store.set"](ctx, key, value);
+      }
+    },
+  };
+  return apiObj;
+}
+
+export function storeReadSyscalls(
+  db: Knex<any, unknown>,
+  tableName: string
+): SysCallMapping {
+  return {
+    get: async (ctx, key: string): Promise<any | null> => {
+      let result = await db<Item>(tableName).where({ key }).select("value");
+      if (result.length) {
+        return JSON.parse(result[0].value);
+      } else {
+        return null;
+      }
+    },
+    queryPrefix: async (ctx, prefix: string) => {
+      return (
+        await db<Item>(tableName)
+          .andWhereLike("key", `${prefix}%`)
+          .select("key", "value")
+      ).map(({ key, value }) => ({
+        key,
+        value: JSON.parse(value),
+      }));
+    },
+  };
+}
diff --git a/plugbox/syscall/transport.ts b/plugbox/syscall/transport.ts
new file mode 100644
index 0000000..dcfe9f7
--- /dev/null
+++ b/plugbox/syscall/transport.ts
@@ -0,0 +1,16 @@
+import { SysCallMapping } from "../system";
+
+export function transportSyscalls(
+  names: string[],
+  transportCall: (name: string, ...args: any[]) => Promise<any>
+): SysCallMapping {
+  let syscalls: SysCallMapping = {};
+
+  for (let name of names) {
+    syscalls[name] = (ctx, ...args: any[]) => {
+      return transportCall(name, ...args);
+    };
+  }
+
+  return syscalls;
+}
diff --git a/plugbox/system.ts b/plugbox/system.ts
index 08fb5f2..f99c4f9 100644
--- a/plugbox/system.ts
+++ b/plugbox/system.ts
@@ -1,10 +1,10 @@
 import { Feature, Manifest, RuntimeEnvironment } from "./types";
 import { EventEmitter } from "../common/event";
-import { Sandbox } from "./sandbox";
+import { SandboxFactory } from "./sandbox";
 import { Plug } from "./plug";
 
 export interface SysCallMapping {
-  [key: string]: (...args: any) => Promise<any> | any;
+  [key: string]: (ctx: SyscallContext, ...args: any) => Promise<any> | any;
 }
 
 export type SystemJSON<HookT> = { [key: string]: Manifest<HookT> };
@@ -14,9 +14,23 @@ export type SystemEvents<HookT> = {
   plugUnloaded: (name: string, plug: Plug<HookT>) => void;
 };
 
+type SyscallContext = {
+  plug: Plug<any> | null;
+};
+
+type SyscallSignature = (
+  ctx: SyscallContext,
+  ...args: any[]
+) => Promise<any> | any;
+
+type Syscall = {
+  requiredPermissions: string[];
+  callback: SyscallSignature;
+};
+
 export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
   protected plugs = new Map<string, Plug<HookT>>();
-  registeredSyscalls: SysCallMapping = {};
+  protected registeredSyscalls = new Map<string, Syscall>();
   protected enabledFeatures = new Set<Feature<HookT>>();
 
   readonly runtimeEnv: RuntimeEnvironment;
@@ -31,29 +45,46 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
     feature.apply(this);
   }
 
-  registerSyscalls(...registrationObjects: SysCallMapping[]) {
+  registerSyscalls(
+    namespace: string,
+    requiredCapabilities: string[],
+    ...registrationObjects: SysCallMapping[]
+  ) {
     for (const registrationObject of registrationObjects) {
-      for (let [name, def] of Object.entries(registrationObject)) {
-        this.registeredSyscalls[name] = def;
+      for (let [name, callback] of Object.entries(registrationObject)) {
+        const callName = namespace ? `${namespace}.${name}` : name;
+        this.registeredSyscalls.set(callName, {
+          requiredPermissions: requiredCapabilities,
+          callback,
+        });
       }
     }
   }
 
-  async syscall(name: string, args: any[]): Promise<any> {
-    const callback = this.registeredSyscalls[name];
-    if (!name) {
+  async syscallWithContext(
+    ctx: SyscallContext,
+    name: string,
+    args: any[]
+  ): Promise<any> {
+    const syscall = this.registeredSyscalls.get(name);
+    if (!syscall) {
       throw Error(`Unregistered syscall ${name}`);
     }
-    if (!callback) {
-      throw Error(`Registered but not implemented syscall ${name}`);
+    for (const permission of syscall.requiredPermissions) {
+      if (!ctx.plug) {
+        throw Error(`Syscall ${name} requires permission and no plug is set`);
+      }
+      if (!ctx.plug.grantedPermissions.includes(permission)) {
+        throw Error(`Missing permission '${permission}' for syscall ${name}`);
+      }
     }
-    return Promise.resolve(callback(...args));
+    return Promise.resolve(syscall.callback(ctx, ...args));
   }
 
   async load(
     name: string,
     manifest: Manifest<HookT>,
-    sandbox: Sandbox
+    sandboxFactory: SandboxFactory<HookT>
   ): Promise<Plug<HookT>> {
     if (this.plugs.has(name)) {
       await this.unload(name);
@@ -67,7 +98,7 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
       throw new Error(`Invalid manifest: ${errors.join(", ")}`);
     }
     // Ok, let's load this thing!
-    const plug = new Plug(this, name, sandbox);
+    const plug = new Plug(this, name, sandboxFactory);
     await plug.load(manifest);
     this.plugs.set(name, plug);
     this.emit("plugLoaded", name, plug);
@@ -84,16 +115,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
     this.plugs.delete(name);
   }
 
-  async dispatchEvent(name: string, data?: any): Promise<any[]> {
-    let promises = [];
-    for (let plug of this.plugs.values()) {
-      for (let result of await plug.dispatchEvent(name, data)) {
-        promises.push(result);
-      }
-    }
-    return await Promise.all(promises);
-  }
-
   get loadedPlugs(): Map<string, Plug<HookT>> {
     return this.plugs;
   }
@@ -111,12 +132,12 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
 
   async replaceAllFromJSON(
     json: SystemJSON<HookT>,
-    sandboxFactory: () => Sandbox
+    sandboxFactory: SandboxFactory<HookT>
   ) {
     await this.unloadAll();
     for (let [name, manifest] of Object.entries(json)) {
       console.log("Loading plug", name);
-      await this.load(name, manifest, sandboxFactory());
+      await this.load(name, manifest, sandboxFactory);
     }
   }
 
diff --git a/plugbox/types.ts b/plugbox/types.ts
index 082c6e2..a6d2963 100644
--- a/plugbox/types.ts
+++ b/plugbox/types.ts
@@ -1,7 +1,8 @@
 import { System } from "./system";
 
 export interface Manifest<HookT> {
-  hooks: HookT & EventHook;
+  requiredPermissions?: string[];
+  hooks: HookT;
   functions: {
     [key: string]: FunctionDef;
   };
@@ -15,10 +16,6 @@ export interface FunctionDef {
 
 export type RuntimeEnvironment = "client" | "server";
 
-export type EventHook = {
-  events?: { [key: string]: string[] };
-};
-
 export interface Feature<HookT> {
   validateManifest(manifest: Manifest<HookT>): string[];
 
diff --git a/plugs/core/core.plug.json b/plugs/core/core.plug.json
index 196f65f..32f8e2a 100644
--- a/plugs/core/core.plug.json
+++ b/plugs/core/core.plug.json
@@ -45,12 +45,6 @@
         "path": "/",
         "handler": "endpointTest"
       }
-    ],
-    "crons": [
-      {
-        "cron": "*/15 * * * *",
-        "handler": "gitSnapshot"
-      }
     ]
   },
   "functions": {
@@ -96,10 +90,6 @@
     "welcome": {
       "path": "./server.ts:welcome",
       "env": "server"
-    },
-    "gitSnapshot": {
-      "path": "./git.ts:commit",
-      "env": "server"
     }
   }
 }
diff --git a/plugs/core/dates.ts b/plugs/core/dates.ts
index e653901..0692f6a 100644
--- a/plugs/core/dates.ts
+++ b/plugs/core/dates.ts
@@ -1,4 +1,4 @@
-import { syscall } from "./lib/syscall";
+import { syscall } from "../lib/syscall";
 
 export async function insertToday() {
   console.log("Inserting date");
diff --git a/plugs/core/git.ts b/plugs/core/git.ts
deleted file mode 100644
index ae89e7a..0000000
--- a/plugs/core/git.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { syscall } from "./lib/syscall";
-
-export async function commit() {
-  console.log("Snapshotting the current space to git");
-  await syscall("shell.run", "git", ["add", "./*.md"]);
-  try {
-    await syscall("shell.run", "git", ["commit", "-a", "-m", "Snapshot"]);
-  } catch (e) {
-    // We can ignore, this happens when there's no changes to commit
-  }
-  console.log("Done!");
-}
diff --git a/plugs/core/markup.ts b/plugs/core/markup.ts
index a371553..5e30cb2 100644
--- a/plugs/core/markup.ts
+++ b/plugs/core/markup.ts
@@ -1,4 +1,4 @@
-import { syscall } from "./lib/syscall";
+import { syscall } from "../lib/syscall";
 
 export async function toggleH1() {
   await togglePrefix("# ");
diff --git a/plugs/core/navigate.ts b/plugs/core/navigate.ts
index 362dfdd..530c7cc 100644
--- a/plugs/core/navigate.ts
+++ b/plugs/core/navigate.ts
@@ -1,5 +1,5 @@
-import { ClickEvent } from "../../webapp/src/app_event";
-import { syscall } from "./lib/syscall";
+import { ClickEvent } from "../../webapp/app_event";
+import { syscall } from "../lib/syscall";
 
 async function navigate(syntaxNode: any) {
   if (!syntaxNode) {
@@ -8,7 +8,6 @@ async function navigate(syntaxNode: any) {
   console.log("Attempting to navigate based on syntax node", syntaxNode);
   switch (syntaxNode.name) {
     case "WikiLinkPage":
-    case "AtMention":
       await syscall("editor.navigate", syntaxNode.text);
       break;
     case "URL":
@@ -36,31 +35,16 @@ export async function clickNavigate(event: ClickEvent) {
 }
 
 export async function pageComplete() {
-  let prefix = await syscall(
-    "editor.matchBefore",
-    "(\\[\\[[\\w\\s]*|@[\\w\\.]*)"
-  );
+  let prefix = await syscall("editor.matchBefore", "\\[\\[[\\w\\s]*");
   if (!prefix) {
     return null;
   }
   let allPages = await syscall("space.listPages");
-  if (prefix.text[0] === "@") {
-    return {
-      from: prefix.from,
-      options: allPages
-        .filter((page: any) => page.name.startsWith(prefix.text))
-        .map((pageMeta: any) => ({
-          label: pageMeta.name,
-          type: "page",
-        })),
-    };
-  } else {
-    return {
-      from: prefix.from + 2,
-      options: allPages.map((pageMeta: any) => ({
-        label: pageMeta.name,
-        type: "page",
-      })),
-    };
-  }
+  return {
+    from: prefix.from + 2,
+    options: allPages.map((pageMeta: any) => ({
+      label: pageMeta.name,
+      type: "page",
+    })),
+  };
 }
diff --git a/plugs/core/page.ts b/plugs/core/page.ts
index 7fae98f..3c6866c 100644
--- a/plugs/core/page.ts
+++ b/plugs/core/page.ts
@@ -1,13 +1,13 @@
 import { IndexEvent } from "../../webapp/app_event";
 import { pageLinkRegex } from "../../webapp/constant";
-import { syscall } from "./lib/syscall";
+import { syscall } from "../lib/syscall";
 
 const wikilinkRegex = new RegExp(pageLinkRegex, "g");
-const atMentionRegex = /(@[A-Za-z\.]+)/g;
 
 export async function indexLinks({ name, text }: IndexEvent) {
   let backLinks: { key: string; value: string }[] = [];
   // [[Style Links]]
+
   for (let match of text.matchAll(wikilinkRegex)) {
     let toPage = match[1];
     let pos = match.index!;
@@ -16,15 +16,6 @@ export async function indexLinks({ name, text }: IndexEvent) {
       value: name,
     });
   }
-  // @links
-  for (let match of text.matchAll(atMentionRegex)) {
-    let toPage = match[1];
-    let pos = match.index!;
-    backLinks.push({
-      key: `pl:${toPage}:${pos}`,
-      value: name,
-    });
-  }
   console.log("Found", backLinks.length, "wiki link(s)");
   // throw Error("Boom");
   await syscall("indexer.batchSet", name, backLinks);
diff --git a/plugs/core/server.ts b/plugs/core/server.ts
index 3f3d222..8eec1a9 100644
--- a/plugs/core/server.ts
+++ b/plugs/core/server.ts
@@ -13,4 +13,5 @@ export function endpointTest(req: EndpointRequest): EndpointResponse {
 
 export function welcome() {
   console.log("Hello world!");
+  return "hi";
 }
diff --git a/plugs/core/task.ts b/plugs/core/task.ts
index 33ecd0c..70cfce5 100644
--- a/plugs/core/task.ts
+++ b/plugs/core/task.ts
@@ -1,5 +1,5 @@
 import { ClickEvent } from "../../webapp/src/app_event";
-import { syscall } from "./lib/syscall";
+import { syscall } from "../lib/syscall";
 
 export async function taskToggle(event: ClickEvent) {
   let syntaxNode = await syscall("editor.getSyntaxNodeAtPos", event.pos);
diff --git a/plugs/core/word_count_command.ts b/plugs/core/word_count_command.ts
index e7720c6..9780f25 100644
--- a/plugs/core/word_count_command.ts
+++ b/plugs/core/word_count_command.ts
@@ -1,4 +1,4 @@
-import { syscall } from "./lib/syscall";
+import { syscall } from "../lib/syscall";
 
 function countWords(str: string): number {
   var matches = str.match(/[\w\d\'\'-]+/gi);
diff --git a/plugs/git/git.plug.json b/plugs/git/git.plug.json
new file mode 100644
index 0000000..ccc2f8c
--- /dev/null
+++ b/plugs/git/git.plug.json
@@ -0,0 +1,37 @@
+{
+  "requiredPermissions": ["shell"],
+  "hooks": {
+    "commands": {
+      "Git: Snapshot": {
+        "invoke": "snapshotCommand"
+      },
+      "Git: Sync": {
+        "invoke": "syncCommand"
+      }
+    },
+    "crons": [
+      {
+        "cron": "*/15 * * * *",
+        "handler": "commit"
+      }
+    ]
+  },
+  "functions": {
+    "snapshotCommand": {
+      "path": "./git.ts:snapshotCommand",
+      "env": "client"
+    },
+    "syncCommand": {
+      "path": "./git.ts:syncCommand",
+      "env": "client"
+    },
+    "commit": {
+      "path": "./git.ts:commit",
+      "env": "server"
+    },
+    "sync": {
+      "path": "./git.ts:sync",
+      "env": "server"
+    }
+  }
+}
diff --git a/plugs/git/git.ts b/plugs/git/git.ts
new file mode 100644
index 0000000..bd50452
--- /dev/null
+++ b/plugs/git/git.ts
@@ -0,0 +1,42 @@
+import { syscall } from "../lib/syscall";
+
+export async function commit(message?: string) {
+  if (!message) {
+    message = "Snapshot";
+  }
+  console.log(
+    "Snapshotting the current space to git with commit message",
+    message
+  );
+  await syscall("shell.run", "git", ["add", "./*.md"]);
+  try {
+    await syscall("shell.run", "git", ["commit", "-a", "-m", message]);
+  } catch (e) {
+    // We can ignore, this happens when there's no changes to commit
+  }
+  console.log("Done!");
+}
+
+export async function snapshotCommand() {
+  let revName = await syscall("editor.prompt", `Revision name:`);
+  if (!revName) {
+    revName = "Snapshot";
+  }
+  console.log("Revision name", revName);
+  await syscall("system.invokeFunctionOnServer", "commit", revName);
+}
+
+export async function syncCommand() {
+  await syscall("system.invokeFunctionOnServer", "sync");
+}
+
+export async function sync() {
+  console.log("Going to sync with git");
+  console.log("First locally committing everything");
+  await commit();
+  console.log("Then pulling from remote");
+  await syscall("shell.run", "git", ["pull"]);
+  console.log("And then pushing to remote");
+  await syscall("shell.run", "git", ["push"]);
+  console.log("Done!");
+}
diff --git a/plugs/core/lib/syscall.ts b/plugs/lib/syscall.ts
similarity index 100%
rename from plugs/core/lib/syscall.ts
rename to plugs/lib/syscall.ts
diff --git a/server/api_server.ts b/server/api_server.ts
index 839ce29..278a3a5 100644
--- a/server/api_server.ts
+++ b/server/api_server.ts
@@ -46,7 +46,7 @@ export class SocketServer {
   public async init() {
     const indexApi = new IndexApi(this.rootPath);
     await this.registerApi("index", indexApi);
-    this.system.registerSyscalls(pageIndexSyscalls(indexApi.db));
+    this.system.registerSyscalls("indexer", [], pageIndexSyscalls(indexApi.db));
     await this.registerApi(
       "page",
       new PageApi(
@@ -118,6 +118,24 @@ export class SocketServer {
         });
       }
 
+      onCall(
+          "invokeFunction",
+          (plugName: string, name: string, ...args: any[]): Promise<any> => {
+            let plug = this.system.loadedPlugs.get(plugName);
+            if (!plug) {
+              throw new Error(`Plug ${plugName} not loaded`);
+            }
+            console.log(
+                "Invoking function",
+                name,
+                "for plug",
+                plugName,
+                "as requested over socket"
+            );
+            return plug.invoke(name, args);
+          }
+      );
+
       console.log("Sending the sytem to the client");
       socket.emit("loadSystem", this.system.toJSON());
     });
diff --git a/server/index_api.ts b/server/index_api.ts
index d54a819..8dd2c39 100644
--- a/server/index_api.ts
+++ b/server/index_api.ts
@@ -36,12 +36,13 @@ export class IndexApi implements ApiProvider {
 
   api() {
     const syscalls = pageIndexSyscalls(this.db);
+    const nullContext = { plug: null };
     return {
       clearPageIndexForPage: async (
         clientConn: ClientConnection,
         page: string
       ) => {
-        return syscalls["indexer.clearPageIndexForPage"](page);
+        return syscalls.clearPageIndexForPage(nullContext, page);
       },
       set: async (
         clientConn: ClientConnection,
@@ -49,41 +50,41 @@ export class IndexApi implements ApiProvider {
         key: string,
         value: any
       ) => {
-        return syscalls["indexer.set"](page, key, value);
+        return syscalls.set(nullContext, page, key, value);
       },
       get: async (clientConn: ClientConnection, page: string, key: string) => {
-        return syscalls["indexer.get"](page, key);
+        return syscalls.get(nullContext, page, key);
       },
       delete: async (
         clientConn: ClientConnection,
         page: string,
         key: string
       ) => {
-        return syscalls["indexer.delete"](page, key);
+        return syscalls.delete(nullContext, page, key);
       },
       scanPrefixForPage: async (
         clientConn: ClientConnection,
         page: string,
         prefix: string
       ) => {
-        return syscalls["indexer.scanPrefixForPage"](page, prefix);
+        return syscalls.scanPrefixForPage(nullContext, page, prefix);
       },
       scanPrefixGlobal: async (
         clientConn: ClientConnection,
         prefix: string
       ) => {
-        return syscalls["indexer.scanPrefixGlobal"](prefix);
+        return syscalls.scanPrefixGlobal(nullContext, prefix);
       },
       deletePrefixForPage: async (
         clientConn: ClientConnection,
         page: string,
         prefix: string
       ) => {
-        return syscalls["indexer.deletePrefixForPage"](page, prefix);
+        return syscalls.deletePrefixForPage(nullContext, page, prefix);
       },
 
       clearPageIndex: async (clientConn: ClientConnection) => {
-        return syscalls["indexer.clearPageIndex"]();
+        return syscalls.clearPageIndex(nullContext);
       },
     };
   }
diff --git a/server/page_api.ts b/server/page_api.ts
index d44ef13..66202bb 100644
--- a/server/page_api.ts
+++ b/server/page_api.ts
@@ -11,6 +11,7 @@ import { stat } from "fs/promises";
 import { Cursor, cursorEffect } from "../webapp/cursorEffect";
 import { SilverBulletHooks } from "../common/manifest";
 import { System } from "../plugbox/system";
+import { EventFeature } from "../plugbox/feature/event";
 
 export class PageApi implements ApiProvider {
   openPages: Map<string, Page>;
@@ -18,6 +19,7 @@ export class PageApi implements ApiProvider {
   rootPath: string;
   connectedSockets: Set<Socket>;
   private system: System<SilverBulletHooks>;
+  private eventFeature: EventFeature;
 
   constructor(
     rootPath: string,
@@ -30,6 +32,8 @@ export class PageApi implements ApiProvider {
     this.openPages = openPages;
     this.connectedSockets = connectedSockets;
     this.system = system;
+    this.eventFeature = new EventFeature();
+    system.addFeature(this.eventFeature);
   }
 
   async init(): Promise<void> {
@@ -222,7 +226,7 @@ export class PageApi implements ApiProvider {
                     );
                     await this.flushPageToDisk(pageName, page);
 
-                    await this.system.dispatchEvent("page:index", {
+                    await this.eventFeature.dispatchEvent("page:index", {
                       name: pageName,
                       text: page.text.sliceString(0),
                     });
diff --git a/server/server.ts b/server/server.ts
index 750f620..b6b4796 100644
--- a/server/server.ts
+++ b/server/server.ts
@@ -8,7 +8,7 @@ import { SilverBulletHooks } from "../common/manifest";
 import { ExpressServer } from "./express_server";
 import { DiskPlugLoader } from "../plugbox/plug_loader";
 import { NodeCronFeature } from "../plugbox/feature/node_cron";
-import shellSyscalls from "./syscalls/shell";
+import shellSyscalls from "../plugbox/syscall/shell.node";
 import { System } from "../plugbox/system";
 
 let args = yargs(hideBin(process.argv))
@@ -53,9 +53,9 @@ expressServer
       `${__dirname}/../../plugs/dist`
     );
     await plugLoader.loadPlugs();
-    plugLoader.watcher();
-    system.registerSyscalls(shellSyscalls(pagesPath));
-    system.addFeature(new NodeCronFeature());
+      plugLoader.watcher();
+      system.registerSyscalls("shell", ["shell"], shellSyscalls(pagesPath));
+      system.addFeature(new NodeCronFeature());
     server.listen(port, () => {
       console.log(`Server listening on port ${port}`);
     });
diff --git a/server/syscalls/page_index.ts b/server/syscalls/page_index.ts
index 3056fb4..0b283f7 100644
--- a/server/syscalls/page_index.ts
+++ b/server/syscalls/page_index.ts
@@ -1,4 +1,5 @@
 import { Knex } from "knex";
+import { SysCallMapping } from "../../plugbox/system";
 
 type IndexItem = {
   page: string;
@@ -11,12 +12,12 @@ export type KV = {
   value: any;
 };
 
-export default function (db: Knex) {
-  const apiObj = {
-    "indexer.clearPageIndexForPage": async (page: string) => {
+export default function (db: Knex): SysCallMapping {
+  const apiObj: SysCallMapping = {
+    clearPageIndexForPage: async (ctx, page: string) => {
       await db<IndexItem>("page_index").where({ page }).del();
     },
-    "indexer.set": async (page: string, key: string, value: any) => {
+    set: async (ctx, page: string, key: string, value: any) => {
       let changed = await db<IndexItem>("page_index")
         .where({ page, key })
         .update("value", JSON.stringify(value));
@@ -28,12 +29,12 @@ export default function (db: Knex) {
         });
       }
     },
-    "indexer.batchSet": async (page: string, kvs: KV[]) => {
+    batchSet: async (ctx, page: string, kvs: KV[]) => {
       for (let { key, value } of kvs) {
-        await apiObj["indexer.set"](page, key, value);
+        await apiObj.set(ctx, page, key, value);
       }
     },
-    "indexer.get": async (page: string, key: string) => {
+    get: async (ctx, page: string, key: string) => {
       let result = await db<IndexItem>("page_index")
         .where({ page, key })
         .select("value");
@@ -43,10 +44,10 @@ export default function (db: Knex) {
         return null;
       }
     },
-    "indexer.delete": async (page: string, key: string) => {
+    delete: async (ctx, page: string, key: string) => {
       await db<IndexItem>("page_index").where({ page, key }).del();
     },
-    "indexer.scanPrefixForPage": async (page: string, prefix: string) => {
+    scanPrefixForPage: async (ctx, page: string, prefix: string) => {
       return (
         await db<IndexItem>("page_index")
           .where({ page })
@@ -58,7 +59,7 @@ export default function (db: Knex) {
         value: JSON.parse(value),
       }));
     },
-    "indexer.scanPrefixGlobal": async (prefix: string) => {
+    scanPrefixGlobal: async (ctx, prefix: string) => {
       return (
         await db<IndexItem>("page_index")
           .andWhereLike("key", `${prefix}%`)
@@ -69,13 +70,13 @@ export default function (db: Knex) {
         value: JSON.parse(value),
       }));
     },
-    "indexer.deletePrefixForPage": async (page: string, prefix: string) => {
+    deletePrefixForPage: async (ctx, page: string, prefix: string) => {
       return db<IndexItem>("page_index")
         .where({ page })
         .andWhereLike("key", `${prefix}%`)
         .del();
     },
-    "indexer.clearPageIndex": async () => {
+    clearPageIndex: async () => {
       return db<IndexItem>("page_index").del();
     },
   };
diff --git a/tsconfig.json b/tsconfig.json
index 8737af9..7d70674 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,9 +1,5 @@
 {
-  "include": [
-    "webapp/**/*",
-    "server/**/*",
-    "plugbox/**/*"
-  ],
+  "include": ["webapp/**/*", "server/**/*", "plugbox/**/*", "plugs/**/*"],
   "compilerOptions": {
     "target": "esnext",
     "strict": true,
@@ -12,6 +8,7 @@
     "esModuleInterop": true,
     "allowSyntheticDefaultImports": true,
     "resolveJsonModule": true,
-    "jsx": "react-jsx"
+    "jsx": "react-jsx",
+    "downlevelIteration": true
   }
 }
diff --git a/webapp/collab.ts b/webapp/collab.ts
index f337e4b..344b33d 100644
--- a/webapp/collab.ts
+++ b/webapp/collab.ts
@@ -179,7 +179,7 @@ export function collabExtension(
         if (this.pushing || !updates.length) return;
         this.pushing = true;
         let version = getSyncedVersion(this.view.state);
-        console.log("Updates", updates, "to apply to version", version);
+        // console.log("Updates", updates, "to apply to version", version);
         let success = await callbacks.pushUpdates(pageName, version, updates);
         this.pushing = false;
 
@@ -201,7 +201,6 @@ export function collabExtension(
         // Regardless of whether the push failed or new updates came in
         // while it was running, try again if there's updates remaining
         if (!this.done && sendableUpdates(this.view.state).length) {
-          // setTimeout(() => this.push(), 100);
           this.throttledPush();
         }
       }
diff --git a/webapp/components/top_bar.tsx b/webapp/components/top_bar.tsx
index ef8cf9d..e81e076 100644
--- a/webapp/components/top_bar.tsx
+++ b/webapp/components/top_bar.tsx
@@ -1,7 +1,6 @@
-import { AppViewState, PageMeta } from "../types";
+import { Notification } from "../types";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { faFileLines } from "@fortawesome/free-solid-svg-icons";
-import { Notification } from "../types";
 
 function prettyName(s: string | undefined): string {
   if (!s) {
@@ -28,11 +27,13 @@ export function TopBar({
           <FontAwesomeIcon icon={faFileLines} />
         </span>
         <span className="current-page">{prettyName(pageName)}</span>
-        <div className="status">
-          {notifications.map((notification) => (
-            <div key={notification.id}>{notification.message}</div>
-          ))}
-        </div>
+        {notifications.length > 0 && (
+          <div className="status">
+            {notifications.map((notification) => (
+              <div key={notification.id}>{notification.message}</div>
+            ))}
+          </div>
+        )}
       </div>
     </div>
   );
diff --git a/webapp/editor.tsx b/webapp/editor.tsx
index dbb6f3d..9747add 100644
--- a/webapp/editor.tsx
+++ b/webapp/editor.tsx
@@ -37,9 +37,9 @@ import reducer from "./reducer";
 import { smartQuoteKeymap } from "./smart_quotes";
 import { Space } from "./space";
 import customMarkdownStyle from "./style";
-import editorSyscalls from "./syscalls/editor.browser";
-import indexerSyscalls from "./syscalls/indexer.native";
-import spaceSyscalls from "./syscalls/space.native";
+import editorSyscalls from "./syscalls/editor";
+import indexerSyscalls from "./syscalls/indexer";
+import spaceSyscalls from "./syscalls/space";
 import {
   Action,
   AppCommand,
@@ -50,6 +50,8 @@ import {
 import { SilverBulletHooks } from "../common/manifest";
 import { safeRun } from "./util";
 import { System } from "../plugbox/system";
+import { EventFeature } from "../plugbox/feature/event";
+import { systemSyscalls } from "./syscalls/system";
 
 class PageState {
   scrollTop: number;
@@ -71,11 +73,16 @@ export class Editor implements AppEventDispatcher {
   space: Space;
   navigationResolve?: (val: undefined) => void;
   pageNavigator: IPageNavigator;
+  private eventFeature: EventFeature;
 
   constructor(space: Space, parent: Element) {
     this.space = space;
     this.viewState = initialViewState;
     this.viewDispatch = () => {};
+
+    this.eventFeature = new EventFeature();
+    this.system.addFeature(this.eventFeature);
+
     this.render(parent);
     this.editorView = new EditorView({
       state: this.createEditorState(
@@ -86,11 +93,10 @@ export class Editor implements AppEventDispatcher {
     });
     this.pageNavigator = new PathPageNavigator();
 
-    this.system.registerSyscalls(
-      editorSyscalls(this),
-      spaceSyscalls(this),
-      indexerSyscalls(this.space)
-    );
+    this.system.registerSyscalls("editor", [], editorSyscalls(this));
+    this.system.registerSyscalls("space", [], spaceSyscalls(this));
+    this.system.registerSyscalls("indexer", [], indexerSyscalls(this.space));
+    this.system.registerSyscalls("system", [], systemSyscalls(this.space));
   }
 
   async init() {
@@ -129,20 +135,14 @@ export class Editor implements AppEventDispatcher {
       },
       loadSystem: (systemJSON) => {
         safeRun(async () => {
-          await this.system.replaceAllFromJSON(systemJSON, () =>
-            createIFrameSandbox(this.system)
-          );
+          await this.system.replaceAllFromJSON(systemJSON, createIFrameSandbox);
           this.buildAllCommands();
         });
       },
       plugLoaded: (plugName, plug) => {
         safeRun(async () => {
           console.log("Plug load", plugName);
-          await this.system.load(
-            plugName,
-            plug,
-            createIFrameSandbox(this.system)
-          );
+          await this.system.load(plugName, plug, createIFrameSandbox);
           this.buildAllCommands();
         });
       },
@@ -199,7 +199,7 @@ export class Editor implements AppEventDispatcher {
   }
 
   async dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]> {
-    return this.system.dispatchEvent(name, data);
+    return this.eventFeature.dispatchEvent(name, data);
   }
 
   get currentPage(): string | undefined {
diff --git a/webapp/space.ts b/webapp/space.ts
index b4c7640..95e2e71 100644
--- a/webapp/space.ts
+++ b/webapp/space.ts
@@ -80,7 +80,7 @@ export class Space extends EventEmitter<SpaceEvents> {
     });
   }
 
-  private wsCall(eventName: string, ...args: any[]): Promise<any> {
+  public wsCall(eventName: string, ...args: any[]): Promise<any> {
     return new Promise((resolve, reject) => {
       this.reqId++;
       this.socket!.once(`${eventName}Resp${this.reqId}`, (err, result) => {
@@ -160,40 +160,4 @@ export class Space extends EventEmitter<SpaceEvents> {
   async getPageMeta(name: string): Promise<PageMeta> {
     return this.wsCall("page.getPageMeta", name);
   }
-
-  async indexSet(pageName: string, key: string, value: any) {
-    await this.wsCall("index.set", pageName, key, value);
-  }
-
-  async indexBatchSet(pageName: string, kvs: KV[]) {
-    // TODO: Optimize with batch call
-    for (let { key, value } of kvs) {
-      await this.indexSet(pageName, key, value);
-    }
-  }
-
-  async indexGet(pageName: string, key: string): Promise<any | null> {
-    return await this.wsCall("index.get", pageName, key);
-  }
-
-  async indexScanPrefixForPage(
-    pageName: string,
-    keyPrefix: string
-  ): Promise<{ key: string; value: any }[]> {
-    return await this.wsCall("index.scanPrefixForPage", pageName, keyPrefix);
-  }
-
-  async indexScanPrefixGlobal(
-    keyPrefix: string
-  ): Promise<{ key: string; value: any }[]> {
-    return await this.wsCall("index.scanPrefixGlobal", keyPrefix);
-  }
-
-  async indexDeletePrefixForPage(pageName: string, keyPrefix: string) {
-    await this.wsCall("index.deletePrefixForPage", keyPrefix);
-  }
-
-  async indexDelete(pageName: string, key: string) {
-    await this.wsCall("index.delete", pageName, key);
-  }
 }
diff --git a/webapp/styles/editor.scss b/webapp/styles/editor.scss
index e45cd1d..c79d55c 100644
--- a/webapp/styles/editor.scss
+++ b/webapp/styles/editor.scss
@@ -129,7 +129,6 @@
 
   .mention {
     color: #0330cb;
-    text-decoration: underline;
   }
 
   .tag {
diff --git a/webapp/syscalls/db.localstorage.ts b/webapp/syscalls/db.localstorage.ts
deleted file mode 100644
index b666f95..0000000
--- a/webapp/syscalls/db.localstorage.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export default {
-  "db.put": (key: string, value: any) => {
-    localStorage.setItem(key, value);
-  },
-  "db.get": (key: string) => {
-    return localStorage.getItem(key);
-  },
-};
diff --git a/webapp/syscalls/editor.browser.ts b/webapp/syscalls/editor.ts
similarity index 78%
rename from webapp/syscalls/editor.browser.ts
rename to webapp/syscalls/editor.ts
index 8e83e5a..ba94084 100644
--- a/webapp/syscalls/editor.browser.ts
+++ b/webapp/syscalls/editor.ts
@@ -1,7 +1,7 @@
 import { Editor } from "../editor";
 import { syntaxTree } from "@codemirror/language";
 import { Transaction } from "@codemirror/state";
-import { PageMeta } from "../types";
+import { SysCallMapping } from "../../plugbox/system";
 
 type SyntaxNode = {
   name: string;
@@ -26,23 +26,23 @@ function ensureAnchor(expr: any, start: boolean) {
   );
 }
 
-export default (editor: Editor) => ({
-  "editor.getCurrentPage": (): string => {
+export default (editor: Editor): SysCallMapping => ({
+  getCurrentPage: (): string => {
     return editor.currentPage!;
   },
-  "editor.getText": () => {
+  getText: () => {
     return editor.editorView?.state.sliceDoc();
   },
-  "editor.getCursor": (): number => {
+  getCursor: (): number => {
     return editor.editorView!.state.selection.main.from;
   },
-  "editor.navigate": async (name: string) => {
+  navigate: async (ctx, name: string) => {
     await editor.navigate(name);
   },
-  "editor.openUrl": async (url: string) => {
+  openUrl: async (ctx, url: string) => {
     window.open(url, "_blank")!.focus();
   },
-  "editor.insertAtPos": (text: string, pos: number) => {
+  insertAtPos: (ctx, text: string, pos: number) => {
     editor.editorView!.dispatch({
       changes: {
         insert: text,
@@ -50,7 +50,7 @@ export default (editor: Editor) => ({
       },
     });
   },
-  "editor.replaceRange": (from: number, to: number, text: string) => {
+  replaceRange: (ctx, from: number, to: number, text: string) => {
     editor.editorView!.dispatch({
       changes: {
         insert: text,
@@ -59,14 +59,14 @@ export default (editor: Editor) => ({
       },
     });
   },
-  "editor.moveCursor": (pos: number) => {
+  moveCursor: (ctx, pos: number) => {
     editor.editorView!.dispatch({
       selection: {
         anchor: pos,
       },
     });
   },
-  "editor.insertAtCursor": (text: string) => {
+  insertAtCursor: (ctx, text: string) => {
     let editorView = editor.editorView!;
     let from = editorView.state.selection.main.from;
     editorView.dispatch({
@@ -79,7 +79,7 @@ export default (editor: Editor) => ({
       },
     });
   },
-  "editor.getSyntaxNodeUnderCursor": (): SyntaxNode | undefined => {
+  getSyntaxNodeUnderCursor: (): SyntaxNode | undefined => {
     const editorState = editor.editorView!.state;
     let selection = editorState.selection.main;
     if (selection.empty) {
@@ -94,7 +94,8 @@ export default (editor: Editor) => ({
       }
     }
   },
-  "editor.matchBefore": (
+  matchBefore: (
+    ctx,
     regexp: string
   ): { from: number; to: number; text: string } | null => {
     const editorState = editor.editorView!.state;
@@ -112,7 +113,7 @@ export default (editor: Editor) => ({
     }
     return null;
   },
-  "editor.getSyntaxNodeAtPos": (pos: number): SyntaxNode | undefined => {
+  getSyntaxNodeAtPos: (ctx, pos: number): SyntaxNode | undefined => {
     const editorState = editor.editorView!.state;
     let node = syntaxTree(editorState).resolveInner(pos);
     if (node) {
@@ -124,10 +125,10 @@ export default (editor: Editor) => ({
       };
     }
   },
-  "editor.dispatch": (change: Transaction) => {
+  dispatch: (ctx, change: Transaction) => {
     editor.editorView!.dispatch(change);
   },
-  "editor.prompt": (message: string, defaultValue = ""): string | null => {
+  prompt: (ctx, message: string, defaultValue = ""): string | null => {
     return prompt(message, defaultValue);
   },
 });
diff --git a/webapp/syscalls/indexer.native.ts b/webapp/syscalls/indexer.native.ts
deleted file mode 100644
index fbb97ba..0000000
--- a/webapp/syscalls/indexer.native.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Space, KV } from "../space";
-
-export default (space: Space) => ({
-  "indexer.scanPrefixForPage": async (pageName: string, keyPrefix: string) => {
-    return await space.indexScanPrefixForPage(pageName, keyPrefix);
-  },
-  "indexer.scanPrefixGlobal": async (keyPrefix: string) => {
-    return await space.indexScanPrefixGlobal(keyPrefix);
-  },
-  "indexer.get": async (pageName: string, key: string): Promise<any> => {
-    return await space.indexGet(pageName, key);
-  },
-  "indexer.set": async (pageName: string, key: string, value: any) => {
-    await space.indexSet(pageName, key, value);
-  },
-  "indexer.batchSet": async (pageName: string, kvs: KV[]) => {
-    await space.indexBatchSet(pageName, kvs);
-  },
-  "indexer.delete": async (pageName: string, key: string) => {
-    await space.indexDelete(pageName, key);
-  },
-});
diff --git a/webapp/syscalls/indexer.ts b/webapp/syscalls/indexer.ts
new file mode 100644
index 0000000..b71350a
--- /dev/null
+++ b/webapp/syscalls/indexer.ts
@@ -0,0 +1,17 @@
+import { Space } from "../space";
+import { SysCallMapping } from "../../plugbox/system";
+import { transportSyscalls } from "../../plugbox/syscall/transport";
+
+export default function indexerSyscalls(space: Space): SysCallMapping {
+  return transportSyscalls(
+    [
+      "scanPrefixForPage",
+      "scanPrefixGlobal",
+      "get",
+      "set",
+      "batchSet",
+      "delete",
+    ],
+    (name, ...args) => space.wsCall(`index.${name}`, ...args)
+  );
+}
diff --git a/webapp/syscalls/space.native.ts b/webapp/syscalls/space.ts
similarity index 64%
rename from webapp/syscalls/space.native.ts
rename to webapp/syscalls/space.ts
index ddc5eae..204f2c5 100644
--- a/webapp/syscalls/space.native.ts
+++ b/webapp/syscalls/space.ts
@@ -1,21 +1,21 @@
 import { Editor } from "../editor";
 import { PageMeta } from "../types";
+import { SysCallMapping } from "../../plugbox/system";
 
-export default (editor: Editor) => ({
-  "space.listPages": (): PageMeta[] => {
+export default (editor: Editor): SysCallMapping => ({
+  listPages: (): PageMeta[] => {
     return [...editor.viewState.allPages];
   },
-  "space.readPage": async (
+  readPage: async (
+    ctx,
     name: string
   ): Promise<{ text: string; meta: PageMeta }> => {
     return await editor.space.readPage(name);
   },
-  "space.writePage": async (name: string, text: string): Promise<PageMeta> => {
+  writePage: async (ctx, name: string, text: string): Promise<PageMeta> => {
     return await editor.space.writePage(name, text);
   },
-  "space.deletePage": async (name: string) => {
-    console.log("Clearing page index", name);
-    await editor.space.indexDeletePrefixForPage(name, "");
+  deletePage: async (ctx, name: string) => {
     // If we're deleting the current page, navigate to the start page
     if (editor.currentPage === name) {
       await editor.navigate("start");
diff --git a/webapp/syscalls/system.ts b/webapp/syscalls/system.ts
new file mode 100644
index 0000000..89b89f8
--- /dev/null
+++ b/webapp/syscalls/system.ts
@@ -0,0 +1,13 @@
+import { SysCallMapping } from "../../plugbox/system";
+import { Space } from "../space";
+
+export function systemSyscalls(space: Space): SysCallMapping {
+  return {
+    async invokeFunctionOnServer(ctx, name: string, ...args: any[]) {
+      if (!ctx.plug) {
+        throw Error("No plug associated with context");
+      }
+      return await space.wsCall("invokeFunction", ctx.plug.name, name, ...args);
+    },
+  };
+}
diff --git a/webapp/syscalls/ui.browser.ts b/webapp/syscalls/ui.browser.ts
deleted file mode 100644
index 120b022..0000000
--- a/webapp/syscalls/ui.browser.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// @ts-ignore
-let frameTest = document.getElementById("main-frame");
-
-window.addEventListener("message", async (event) => {
-  let messageEvent = event as MessageEvent;
-  let data = messageEvent.data;
-  if (data.type === "iframe_event") {
-    // @ts-ignore
-    window.mainPlug.dispatchEvent(data.data.event, data.data.data);
-  }
-});
-
-export default {
-  "ui.update": function (doc: any) {
-    // frameTest.contentWindow.postMessage({
-    //     type: "loadContent",
-    //     doc: doc,
-    // });
-  },
-};
diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json
deleted file mode 100644
index cab209a..0000000
--- a/webapp/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "include": ["src/**/*"],
-  "compilerOptions": {
-    "target": "esnext",
-    "strict": true,
-    "moduleResolution": "node",
-    "module": "ESNext",
-    "allowSyntheticDefaultImports": true,
-    "resolveJsonModule": true,
-    "jsx": "react-jsx"
-  }
-}
diff --git a/yarn.lock b/yarn.lock
index 3bdde9f..6a22a80 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1631,6 +1631,14 @@
   resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.1.tgz#e01a874d4c2aa1a02ebc64cfd1cd8ebdbad7a996"
   integrity sha512-BkMHHonDT8NJUE/pQ3kr5v2GLDKm5or9btLBoBx4F2MB2cuqYC748LYMDC55VlrLI5qZZv+Qgc3m4P3dBPcmeg==
 
+"@types/node-fetch@^2.6.1":
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975"
+  integrity sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==
+  dependencies:
+    "@types/node" "*"
+    form-data "^3.0.0"
+
 "@types/node@*", "@types/node@>=10.0.0", "@types/node@^17.0.21":
   version "17.0.21"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644"
@@ -1976,6 +1984,11 @@ base-x@^3.0.8:
   dependencies:
     safe-buffer "^5.0.1"
 
+base64-arraybuffer-es6@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86"
+  integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==
+
 base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -2486,6 +2499,11 @@ cookiejar@^2.1.3:
   resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc"
   integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==
 
+core-js@^3.4:
+  version "3.21.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94"
+  integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==
+
 core-util-is@~1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
@@ -2691,6 +2709,11 @@ csstype@^3.0.2:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33"
   integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==
 
+data-uri-to-buffer@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b"
+  integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==
+
 data-urls@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
@@ -2863,6 +2886,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0:
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
   integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
+domexception@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
+  integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
+  dependencies:
+    webidl-conversions "^4.0.2"
+
 domexception@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
@@ -3320,6 +3350,13 @@ express@^4.17.3:
     utils-merge "1.0.1"
     vary "~1.1.2"
 
+fake-indexeddb@^3.1.7:
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.7.tgz#d9efbeade113c15efbe862e4598a4b0a1797ed9f"
+  integrity sha512-CUGeCzCOVjmeKi2C0pcvSh6NDU6uQIaS+7YyR++tO/atJJujkBYVhDvfePdz/U8bD33BMVWirsr1MKczfAqbjA==
+  dependencies:
+    realistic-structured-clone "^2.0.1"
+
 fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@@ -3342,6 +3379,14 @@ fb-watchman@^2.0.0:
   dependencies:
     bser "2.1.1"
 
+fetch-blob@^3.1.2, fetch-blob@^3.1.4:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.5.tgz#0077bf5f3fcdbd9d75a0b5362f77dbb743489863"
+  integrity sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==
+  dependencies:
+    node-domexception "^1.0.0"
+    web-streams-polyfill "^3.0.3"
+
 file-uri-to-path@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -3398,6 +3443,13 @@ form-data@^4.0.0:
     combined-stream "^1.0.8"
     mime-types "^2.1.12"
 
+formdata-polyfill@^4.0.10:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
+  integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
+  dependencies:
+    fetch-blob "^3.1.2"
+
 formidable@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.0.1.tgz#4310bc7965d185536f9565184dee74fbb75557ff"
@@ -4914,6 +4966,20 @@ node-cron@^3.0.0:
   dependencies:
     moment-timezone "^0.5.31"
 
+node-domexception@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
+  integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
+
+node-fetch@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.3.tgz#a03c9cc2044d21d1a021566bd52f080f333719a6"
+  integrity sha512-AXP18u4pidSZ1xYXRDPY/8jdv3RAozIt/WLNR/MBGZAz+xjtlr90RvCnsvHQRiXyWliZF/CpytExp32UU67/SA==
+  dependencies:
+    data-uri-to-buffer "^4.0.0"
+    fetch-blob "^3.1.4"
+    formdata-polyfill "^4.0.10"
+
 node-gyp-build@^4.2.3, node-gyp-build@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3"
@@ -5757,6 +5823,16 @@ readdirp@~3.6.0:
   dependencies:
     picomatch "^2.2.1"
 
+realistic-structured-clone@^2.0.1:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.4.tgz#7eb4c2319fc3cb72f4c8d3c9e888b11647894b50"
+  integrity sha512-lItAdBIFHUSe6fgztHPtmmWqKUgs+qhcYLi3wTRUl4OTB3Vb8aBVSjGfQZUvkmJCKoX3K9Wf7kyLp/F/208+7A==
+  dependencies:
+    core-js "^3.4"
+    domexception "^1.0.1"
+    typeson "^6.1.0"
+    typeson-registry "^1.0.0-alpha.20"
+
 rechoir@^0.8.0:
   version "0.8.0"
   resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22"
@@ -6493,6 +6569,20 @@ typescript@^4.6.2:
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
   integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
 
+typeson-registry@^1.0.0-alpha.20:
+  version "1.0.0-alpha.39"
+  resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211"
+  integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==
+  dependencies:
+    base64-arraybuffer-es6 "^0.7.0"
+    typeson "^6.0.0"
+    whatwg-url "^8.4.0"
+
+typeson@^6.0.0, typeson@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b"
+  integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==
+
 uglify-js@^3.15.1:
   version "3.15.3"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.15.3.tgz#9aa82ca22419ba4c0137642ba0df800cb06e0471"
@@ -6650,6 +6740,16 @@ weak-lru-cache@^1.2.2:
   resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz#fdbb6741f36bae9540d12f480ce8254060dccd19"
   integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==
 
+web-streams-polyfill@^3.0.3:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965"
+  integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==
+
+webidl-conversions@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
+  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
+
 webidl-conversions@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@@ -6672,7 +6772,7 @@ whatwg-mimetype@^2.3.0:
   resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
   integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
 
-whatwg-url@^8.0.0, whatwg-url@^8.5.0:
+whatwg-url@^8.0.0, whatwg-url@^8.4.0, whatwg-url@^8.5.0:
   version "8.7.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
   integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==