@decentnetwork/beagle-service 0.1.0 → 0.2.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.
Files changed (34) hide show
  1. package/README.md +289 -28
  2. package/dist/auth.d.ts +10 -2
  3. package/dist/auth.js +119 -36
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +163 -0
  6. package/dist/commonjs.cjs +49 -0
  7. package/dist/commonjs.d.cts +11 -0
  8. package/dist/index.d.ts +16 -2
  9. package/dist/index.js +127 -5
  10. package/dist/routes.d.ts +9 -1
  11. package/dist/routes.js +157 -24
  12. package/dist/stores.d.ts +12 -4
  13. package/dist/stores.js +60 -37
  14. package/dist/types.d.ts +157 -12
  15. package/dist/users.js +108 -58
  16. package/dist/util.d.ts +22 -2
  17. package/dist/util.js +103 -2
  18. package/examples/next-pages-cjs/README.md +30 -0
  19. package/examples/next-pages-cjs/instrumentation.js +16 -0
  20. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  21. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  22. package/examples/next-pages-cjs/next.config.js +11 -0
  23. package/examples/next-pages-cjs/package.json +19 -0
  24. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  25. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  26. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  27. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  28. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  29. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  30. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  31. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  32. package/examples/next-pages-cjs/pages/index.js +52 -0
  33. package/examples/sign-in-from-script.mjs +37 -0
  34. package/package.json +19 -7
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { base58ToBytes, carrierIdFromAddress } from "@decentnetwork/peer";
@@ -39,6 +39,107 @@ export function readJsonFile(file, fallback) {
39
39
  throw new Error(`beagle-service: ${file} is not valid JSON (${e.message}); fix or move it before starting`);
40
40
  }
41
41
  }
42
+ const LOCK_STALE_MS = 5_000;
43
+ const LOCK_WAIT_MS = 3_000;
44
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
45
+ /** One JSON object in one file, shared by every store instance and every
46
+ * process that opens it (a server and the operator's command beside it, or a
47
+ * module a bundler evaluated twice). A read looks at the file's identity
48
+ * first and loads it again when another writer replaced it; a change is made
49
+ * under a lock file, on the copy that is on disk at that moment, and written
50
+ * with a rename. All of it is synchronous: no other code of this process runs
51
+ * between the read and the write. */
52
+ export class SharedJsonFile {
53
+ #file;
54
+ #value;
55
+ #seen = "";
56
+ constructor(file) {
57
+ this.#file = file;
58
+ this.#load();
59
+ }
60
+ #identity() {
61
+ try {
62
+ const s = statSync(this.#file);
63
+ return `${s.ino}:${s.size}:${s.mtimeMs}`;
64
+ }
65
+ catch (e) {
66
+ if (e.code === "ENOENT")
67
+ return "";
68
+ throw e;
69
+ }
70
+ }
71
+ #load() {
72
+ const seen = this.#identity();
73
+ // A null prototype: a key such as "__proto__" is a key like any other.
74
+ this.#value = Object.assign(Object.create(null), readJsonFile(this.#file, {}));
75
+ this.#seen = seen;
76
+ }
77
+ /** The current content. Do not change it; use `update`. */
78
+ read() {
79
+ if (this.#identity() !== this.#seen)
80
+ this.#load();
81
+ return this.#value;
82
+ }
83
+ /** Change the content. `change` gets what is on disk now and says whether
84
+ * it has to be written. */
85
+ update(change) {
86
+ mkdirSync(dirname(this.#file), { recursive: true });
87
+ const lock = `${this.#file}.lock`;
88
+ const waitUntil = Date.now() + LOCK_WAIT_MS;
89
+ for (;;) {
90
+ try {
91
+ closeSync(openSync(lock, "wx", 0o600));
92
+ break;
93
+ }
94
+ catch (e) {
95
+ if (e.code !== "EEXIST")
96
+ throw e;
97
+ let age = 0;
98
+ try {
99
+ age = Date.now() - statSync(lock).mtimeMs;
100
+ }
101
+ catch {
102
+ continue; // released in between
103
+ }
104
+ // A writer holds the lock for a few milliseconds. One this old was
105
+ // left by a process that died while holding it.
106
+ if (age > LOCK_STALE_MS || Date.now() > waitUntil) {
107
+ try {
108
+ unlinkSync(lock);
109
+ }
110
+ catch {
111
+ /* someone else removed it */
112
+ }
113
+ continue;
114
+ }
115
+ Atomics.wait(sleepCell, 0, 0, 5);
116
+ }
117
+ }
118
+ try {
119
+ const value = this.read();
120
+ const { write, result } = change(value);
121
+ if (write) {
122
+ atomicWriteJson(this.#file, value);
123
+ this.#seen = this.#identity();
124
+ }
125
+ return result;
126
+ }
127
+ catch (e) {
128
+ // `change` may have altered the copy in memory before it threw.
129
+ this.#seen = "\0";
130
+ throw e;
131
+ }
132
+ finally {
133
+ try {
134
+ unlinkSync(lock);
135
+ }
136
+ catch {
137
+ /* already gone */
138
+ }
139
+ }
140
+ }
141
+ }
142
+ export const isThenable = (v) => !!v && typeof v.then === "function";
42
143
  /** A userid is the base58 of a 32-byte public key. */
43
144
  export function isUserid(v) {
44
145
  if (typeof v !== "string" || v.length < 32 || v.length > 64)
@@ -90,7 +191,7 @@ export class Hub {
90
191
  }
91
192
  }
92
193
  }
93
- /** Run async jobs for the same key one after another. */
194
+ /** Run jobs for the same key one after another. */
94
195
  export class KeyedLock {
95
196
  #tails = new Map();
96
197
  run(key, job) {
@@ -0,0 +1,30 @@
1
+ # Sign in with Beagle beside NextAuth, in a CommonJS Next.js site
2
+
3
+ A pages-router site whose `lib/` is CommonJS and which already has NextAuth's catch-all at
4
+ `pages/api/auth/[...nextauth].js`.
5
+
6
+ ```bash
7
+ npm install
8
+ npm run build
9
+ BEAGLE_ORIGINS=http://localhost:3000 npm start
10
+ ```
11
+
12
+ | File | What it shows |
13
+ |---|---|
14
+ | `lib/beagle.js` | `require("@decentnetwork/beagle-service")`, one service per process, the `onSignIn` hook |
15
+ | `instrumentation.js` | the service is created when the server starts, so a wrong `BEAGLE_ORIGINS` stops the start |
16
+ | `pages/api/auth/[...nextauth].js` | NextAuth's catch-all, untouched |
17
+ | `pages/api/auth/nonce.js`, `sign-in.js`, `launch.js`, `me.js`, `logout.js`, `register.js` | one Beagle route per file: `export default beagle.route("signIn")` |
18
+ | `pages/api/notes.js` | a route of the site's own: `beagle.authenticate(req, { checkOrigin: true })` |
19
+ | `pages/index.js` | reading the session in `getServerSideProps`; the popup with `beagle-connect` |
20
+ | `next.config.js`, `lib/peer-for-browser.mjs` | what the browser bundle needs for `beagle-connect` |
21
+
22
+ The operator's command works beside the running server:
23
+
24
+ ```bash
25
+ npx beagle-service users list
26
+ npx beagle-service users block <userid> spam
27
+ ```
28
+
29
+ `npm run e2e:next` in the package builds this example against the package as it would be published and runs it
30
+ with `next build` and `next start`.
@@ -0,0 +1,16 @@
1
+ // Next.js runs this once when the server starts (`next start`, `next dev`),
2
+ // before any request. Creating the service here means a wrong configuration
3
+ // (an entry of BEAGLE_ORIGINS that is not an exact origin, a users file that
4
+ // does not parse) is found at start, with its reason, instead of showing up as
5
+ // a failed sign-in later.
6
+ //
7
+ // When `register` rejects, Next.js prints "Failed to prepare server" with the
8
+ // reason and answers 500 to every request until the configuration is fixed and
9
+ // the server started again. To keep the rest of the site up and have only the
10
+ // sign-in routes answer 500, do not await: `beagle.ready().catch(() => {})`
11
+ // (the reason is still printed at start).
12
+ export async function register() {
13
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
14
+ const { beagle } = require("./lib/beagle");
15
+ await beagle.ready();
16
+ }
@@ -0,0 +1,23 @@
1
+ // The site's one beagle-service. CommonJS, like the rest of this lib/.
2
+ //
3
+ // `sharedBeagleService` returns at once and creates the service on first use,
4
+ // one per process however many times Next.js evaluates this file. It never
5
+ // throws at a request: while the service cannot start, the routes answer 500
6
+ // and `authenticate` answers `ok: false`.
7
+ const { sharedBeagleService } = require("@decentnetwork/beagle-service");
8
+
9
+ const list = (v) => String(v || "").split(",").map((s) => s.trim()).filter(Boolean);
10
+
11
+ const beagle = sharedBeagleService(() => ({
12
+ // Exact origins of this site's pages: scheme, host and port.
13
+ origins: list(process.env.BEAGLE_ORIGINS || `http://localhost:${process.env.PORT || 3000}`),
14
+ dataDir: process.env.BEAGLE_DATA_DIR || "./data",
15
+ // The site's part of a sign-in, awaited before the answer goes out. What it
16
+ // returns is added to the answer; `{ refuse: "why" }` refuses the sign-in.
17
+ onSignIn: async (user, { origin, door, first }) => {
18
+ if (first) console.log(`first sign-in: ${user.userid} at ${origin} through ${door}`);
19
+ return { role: user.signIns > 100 ? "regular" : "listener" };
20
+ },
21
+ }));
22
+
23
+ module.exports = { beagle };
@@ -0,0 +1,2 @@
1
+ export { verifyDetached } from "@decentnetwork/peer/dist/crypto/sign.js";
2
+ export { base58ToBytes } from "@decentnetwork/peer/dist/utils/base58.js";
@@ -0,0 +1,11 @@
1
+ const path = require("path");
2
+
3
+ module.exports = {
4
+ webpack(config, { isServer }) {
5
+ // The page uses beagle-connect, which needs two functions of
6
+ // @decentnetwork/peer (the signature check). Give the browser those two
7
+ // modules instead of the whole peer.
8
+ if (!isServer) config.resolve.alias["@decentnetwork/peer$"] = path.join(__dirname, "lib/peer-for-browser.mjs");
9
+ return config;
10
+ },
11
+ };
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "beagle-service-next-pages-cjs",
3
+ "private": true,
4
+ "description": "Sign in with Beagle in a CommonJS Next.js pages-router site that also has NextAuth's catch-all.",
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "users": "beagle-service users list"
10
+ },
11
+ "dependencies": {
12
+ "@decentnetwork/beagle-connect": "^0.1.2",
13
+ "@decentnetwork/beagle-service": "^0.2.0",
14
+ "next": "^15.3.2",
15
+ "next-auth": "^4.24.14",
16
+ "react": "^19.1.0",
17
+ "react-dom": "^19.1.0"
18
+ }
19
+ }
@@ -0,0 +1,17 @@
1
+ // NextAuth owns the catch-all under /api/auth. A second catch-all in this
2
+ // directory does not build, so the Beagle routes beside it are one file each
3
+ // (nonce.js, sign-in.js, launch.js, me.js, logout.js, register.js); a named
4
+ // file wins over the catch-all, and none of those names is one of NextAuth's.
5
+ import NextAuth from "next-auth";
6
+ import Credentials from "next-auth/providers/credentials";
7
+
8
+ export default NextAuth({
9
+ secret: process.env.NEXTAUTH_SECRET || "example-only-secret-change-me",
10
+ providers: [
11
+ Credentials({
12
+ name: "Demo",
13
+ credentials: { name: { label: "Name", type: "text" } },
14
+ authorize: async (credentials) => (credentials?.name ? { id: credentials.name, name: credentials.name } : null),
15
+ }),
16
+ ],
17
+ });
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("launch");
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("logout");
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("me");
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("nonce");
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("register");
@@ -0,0 +1,3 @@
1
+ const { beagle } = require("../../../lib/beagle");
2
+
3
+ export default beagle.route("signIn");
@@ -0,0 +1,16 @@
1
+ // One of the site's own routes. Reading a session is asynchronous and never
2
+ // throws: no session, a bad one, a blocked user or a server that cannot check
3
+ // all come back as `ok: false` with a status.
4
+ const { beagle } = require("../../lib/beagle");
5
+
6
+ const notes = new Map(); // userid -> text; a real site has a database
7
+
8
+ export default async function handler(req, res) {
9
+ // checkOrigin: a request that changes something and carries the session
10
+ // cookie must come from one of this site's pages (its Origin header is on
11
+ // the list). A GET is not checked.
12
+ const who = await beagle.authenticate(req, { checkOrigin: true });
13
+ if (!who.ok) return res.status(who.status).json({ error: who.error });
14
+ if (req.method === "POST") notes.set(who.user.userid, String(req.body?.text ?? "").slice(0, 500));
15
+ res.status(200).json({ note: notes.get(who.user.userid) ?? "" });
16
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from "react";
2
+ import { beagle } from "../lib/beagle"; // used on the server only, in getServerSideProps
3
+
4
+ export async function getServerSideProps({ req }) {
5
+ const who = await beagle.authenticate(req);
6
+ return { props: { user: who.ok ? { userid: who.user.userid, name: who.user.name } : null } };
7
+ }
8
+
9
+ export default function Home({ user }) {
10
+ const [me, setMe] = useState(user);
11
+ const [error, setError] = useState("");
12
+
13
+ async function signInWithBeagle() {
14
+ // Open the window inside the click, or the browser blocks it.
15
+ const win = window.open("about:blank", "beagle-connect", "width=420,height=560");
16
+ try {
17
+ const { signIn } = await import("@decentnetwork/beagle-connect");
18
+ const { nonce } = await (await fetch("/api/auth/nonce")).json();
19
+ const who = await signIn({ nonce, existingWindow: win });
20
+ const res = await fetch("/api/auth/sign-in", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(who) });
21
+ const answer = await res.json();
22
+ if (!res.ok) throw new Error(answer.message || answer.error);
23
+ setMe(answer.user);
24
+ setError("");
25
+ } catch (e) {
26
+ win?.close();
27
+ setError(String(e.message || e));
28
+ }
29
+ }
30
+
31
+ async function signOut() {
32
+ await fetch("/api/auth/logout", { method: "POST" });
33
+ setMe(null);
34
+ }
35
+
36
+ return (
37
+ <main style={{ font: "16px system-ui", maxWidth: 560, margin: "64px auto", padding: "0 16px" }}>
38
+ <h1>Sign in with Beagle</h1>
39
+ {me ? (
40
+ <>
41
+ <p>Signed in as <b>{me.name || "someone"}</b></p>
42
+ <p><code style={{ wordBreak: "break-all" }}>{me.userid}</code></p>
43
+ <button onClick={signOut}>Sign out</button>
44
+ </>
45
+ ) : (
46
+ <button onClick={signInWithBeagle}>Sign in with Beagle</button>
47
+ )}
48
+ {error && <p role="alert">{error}</p>}
49
+ <p><a href="/api/auth/signin">NextAuth's own sign-in page</a> lives beside it.</p>
50
+ </main>
51
+ );
52
+ }
@@ -0,0 +1,37 @@
1
+ // A script that signs in to a site: a publisher node, a cron job, a test.
2
+ //
3
+ // node sign-in-from-script.mjs https://your.app
4
+ //
5
+ // A browser adds the `Origin` header by itself; a script has to. The server
6
+ // takes the origin from that header only (never from the body), checks it
7
+ // against its exact list, and checks that the signature was made for it. So a
8
+ // script names the site's own origin, the one its pages are served from, in
9
+ // the header and in the string it signs.
10
+ import { generateKeyPairSync } from "node:crypto";
11
+ import { bytesToBase58, signDetached } from "@decentnetwork/peer";
12
+
13
+ const site = new URL(process.argv[2] ?? "http://localhost:3000").origin;
14
+ const sendOrigin = !process.argv.includes("--no-origin");
15
+
16
+ // The script's identity. A real one loads its key from a file it keeps; this
17
+ // one makes a throwaway X25519 key so the example runs anywhere.
18
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
19
+ const secretKey = new Uint8Array(Buffer.from(privateKey.export({ format: "jwk" }).d, "base64url"));
20
+ const userid = bytesToBase58(new Uint8Array(Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url")));
21
+ const headers = sendOrigin ? { Origin: site, "content-type": "application/json" } : { "content-type": "application/json" };
22
+
23
+ // README:begin
24
+ const { nonce } = await (await fetch(`${site}/api/auth/nonce`)).json();
25
+ const sig = Buffer.from(signDetached(secretKey, new TextEncoder().encode(`decent-auth\n${site}\n${nonce}`))).toString("hex");
26
+ const res = await fetch(`${site}/api/auth/sign-in`, { method: "POST", headers, body: JSON.stringify({ userid, nonce, sig }) });
27
+ // README:end
28
+
29
+ const answer = await res.json();
30
+ if (!res.ok) {
31
+ console.error(`sign-in refused: ${res.status} ${answer.error}`);
32
+ process.exit(1);
33
+ }
34
+ // The session: send this cookie back on later requests (or use `session: { transport: "bearer" }` on the site).
35
+ const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
36
+ const me = await (await fetch(`${site}/api/auth/me`, { headers: { cookie } })).json();
37
+ console.log(`signed in as ${me.user.userid}`);
package/package.json CHANGED
@@ -1,18 +1,27 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle-service",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The web backend for a site on Beagle identity: Sign in with Beagle on the server, a user store with registration and a blacklist, and the site's own Carrier agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "main": "./dist/index.js",
7
+ "main": "./dist/commonjs.cjs",
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js"
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/commonjs.d.cts",
17
+ "default": "./dist/commonjs.cjs"
18
+ }
13
19
  },
14
20
  "./package.json": "./package.json"
15
21
  },
22
+ "bin": {
23
+ "beagle-service": "./dist/cli.js"
24
+ },
16
25
  "files": [
17
26
  "dist",
18
27
  "examples",
@@ -20,11 +29,13 @@
20
29
  "LICENSE"
21
30
  ],
22
31
  "scripts": {
23
- "build": "rm -rf dist && tsc -p tsconfig.json",
32
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/cli.js",
24
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
34
  "test": "npm run build && tsc -p test/types/tsconfig.json && node --test --test-force-exit test/*.test.mjs",
26
35
  "e2e": "npm run build && node test/e2e/agent-public.mjs",
27
- "prepack": "npm run build"
36
+ "prepack": "npm run build",
37
+ "e2e:next": "node test/e2e/next-example.mjs",
38
+ "test:gaps-on-0.1.0": "node test/gaps-on-0.1.0.mjs"
28
39
  },
29
40
  "dependencies": {
30
41
  "@decentnetwork/beagle-connect": "^0.1.2",
@@ -53,5 +64,6 @@
53
64
  "type": "git",
54
65
  "url": "git+https://github.com/0xli/beagle.git",
55
66
  "directory": "packages/beagle-service"
56
- }
67
+ },
68
+ "module": "./dist/index.js"
57
69
  }