nixamp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +609 -10
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +16 -0
- package/dist/share.js +19 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +702 -10
- package/src/session.ts +209 -0
- package/src/share.ts +27 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface Session {
|
|
2
|
+
site: string;
|
|
3
|
+
email: string;
|
|
4
|
+
token: string;
|
|
5
|
+
signedInAt: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function sessionPath(): string;
|
|
8
|
+
export declare function readSession(): Session | null;
|
|
9
|
+
export declare function writeSession(session: Session): void;
|
|
10
|
+
export declare function clearSession(): void;
|
|
11
|
+
/**
|
|
12
|
+
* Ask without echoing. Node has no "read a password" call, so the terminal is
|
|
13
|
+
* put in raw mode and the keystrokes are collected by hand.
|
|
14
|
+
*/
|
|
15
|
+
export declare function askSecret(prompt: string): Promise<string>;
|
|
16
|
+
export interface LoginOptions {
|
|
17
|
+
site: string;
|
|
18
|
+
email: string;
|
|
19
|
+
/** Create the account rather than signing in to one. */
|
|
20
|
+
signUp: boolean;
|
|
21
|
+
fetcher?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
/** Read the flags `nixamp login` accepts. */
|
|
24
|
+
export declare function parseLoginArgs(argv: string[]): LoginOptions;
|
|
25
|
+
/** `nixamp login` / `nixamp signup`. */
|
|
26
|
+
export declare function login(argv: string[]): Promise<number>;
|
|
27
|
+
export declare function logout(): number;
|
|
28
|
+
/** `nixamp whoami`, which asks the server rather than trusting the file. */
|
|
29
|
+
export declare function whoami(fetcher?: typeof fetch): Promise<number>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Being signed in, from a terminal.
|
|
3
|
+
*
|
|
4
|
+
* `nixamp login` asks for an address and a password, and keeps the token it
|
|
5
|
+
* gets back beside the daemon's state. The desktop app bundles this same CLI,
|
|
6
|
+
* so signing in there and signing in here are the same thing on disk.
|
|
7
|
+
*
|
|
8
|
+
* The password is read with the echo turned off and is never written down: the
|
|
9
|
+
* token is what is kept, and it can be revoked without changing anything the
|
|
10
|
+
* person has to remember.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { stateDir } from "./daemon.js";
|
|
16
|
+
import { DEFAULT_DIRECTORY } from "./directory.js";
|
|
17
|
+
export function sessionPath() {
|
|
18
|
+
return join(stateDir(), "session.json");
|
|
19
|
+
}
|
|
20
|
+
export function readSession() {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(sessionPath(), "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function writeSession(session) {
|
|
29
|
+
const path = sessionPath();
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`);
|
|
32
|
+
// A bearer token is as good as the password for as long as it lives, so it
|
|
33
|
+
// is not left readable by everyone with an account on the machine.
|
|
34
|
+
chmodSync(path, 0o600);
|
|
35
|
+
}
|
|
36
|
+
export function clearSession() {
|
|
37
|
+
rmSync(sessionPath(), { force: true });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Ask without echoing. Node has no "read a password" call, so the terminal is
|
|
41
|
+
* put in raw mode and the keystrokes are collected by hand.
|
|
42
|
+
*/
|
|
43
|
+
export async function askSecret(prompt) {
|
|
44
|
+
const input = process.stdin;
|
|
45
|
+
if (!input.isTTY) {
|
|
46
|
+
// A pipe has no echo to turn off, and reading a line is what a script
|
|
47
|
+
// wants anyway.
|
|
48
|
+
const rl = createInterface({ input, output: process.stdout });
|
|
49
|
+
try {
|
|
50
|
+
return await rl.question("");
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rl.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write(prompt);
|
|
57
|
+
input.setRawMode(true);
|
|
58
|
+
input.resume();
|
|
59
|
+
input.setEncoding("utf8");
|
|
60
|
+
return new Promise((done) => {
|
|
61
|
+
let typed = "";
|
|
62
|
+
const onData = (key) => {
|
|
63
|
+
switch (key) {
|
|
64
|
+
case "\u0003": // ctrl-c
|
|
65
|
+
input.setRawMode(false);
|
|
66
|
+
input.pause();
|
|
67
|
+
process.stdout.write("\n");
|
|
68
|
+
process.exit(130);
|
|
69
|
+
return;
|
|
70
|
+
case "\r":
|
|
71
|
+
case "\n":
|
|
72
|
+
case "\u0004": // ctrl-d
|
|
73
|
+
input.setRawMode(false);
|
|
74
|
+
input.pause();
|
|
75
|
+
input.off("data", onData);
|
|
76
|
+
process.stdout.write("\n");
|
|
77
|
+
done(typed);
|
|
78
|
+
return;
|
|
79
|
+
case "\u007f": // backspace
|
|
80
|
+
case "\b":
|
|
81
|
+
typed = typed.slice(0, -1);
|
|
82
|
+
return;
|
|
83
|
+
default:
|
|
84
|
+
// One printable character. An arrow key arrives as a whole escape
|
|
85
|
+
// sequence, which would otherwise be appended as several characters
|
|
86
|
+
// of password nobody typed.
|
|
87
|
+
if (key.length === 1 && key >= " " && key !== "\u007f")
|
|
88
|
+
typed += key;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
input.on("data", onData);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function ask(prompt) {
|
|
95
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
96
|
+
try {
|
|
97
|
+
return (await rl.question(prompt)).trim();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
rl.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Read the flags `nixamp login` accepts. */
|
|
104
|
+
export function parseLoginArgs(argv) {
|
|
105
|
+
const at = (flag) => {
|
|
106
|
+
const index = argv.indexOf(flag);
|
|
107
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
site: (at("--site") ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
|
|
111
|
+
email: at("--email") ?? argv.find((a) => !a.startsWith("-") && a.includes("@")) ?? "",
|
|
112
|
+
signUp: argv.includes("--signup") || argv.includes("--sign-up"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** `nixamp login` / `nixamp signup`. */
|
|
116
|
+
export async function login(argv) {
|
|
117
|
+
const options = parseLoginArgs(argv);
|
|
118
|
+
const send = options.fetcher ?? fetch;
|
|
119
|
+
const email = options.email || (await ask("Email: "));
|
|
120
|
+
if (!email) {
|
|
121
|
+
console.error("nixamp: no email given");
|
|
122
|
+
return 64;
|
|
123
|
+
}
|
|
124
|
+
const password = await askSecret("Password: ");
|
|
125
|
+
if (!password) {
|
|
126
|
+
console.error("nixamp: no password given");
|
|
127
|
+
return 64;
|
|
128
|
+
}
|
|
129
|
+
const where = `${options.site}/api/v1/auth/${options.signUp ? "signup" : "login"}`;
|
|
130
|
+
let answer;
|
|
131
|
+
try {
|
|
132
|
+
answer = await send(where, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ email, password }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
console.error(`nixamp: could not reach ${options.site}: ${error.message}`);
|
|
140
|
+
return 69;
|
|
141
|
+
}
|
|
142
|
+
const body = (await answer.json().catch(() => ({})));
|
|
143
|
+
if (!answer.ok || !body.token) {
|
|
144
|
+
console.error(`nixamp: ${body.error ?? `signing in failed (${answer.status})`}`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
writeSession({ site: options.site, email, token: body.token, signedInAt: Date.now() });
|
|
148
|
+
console.log(`Signed in to ${options.site} as ${email}.`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
export function logout() {
|
|
152
|
+
const session = readSession();
|
|
153
|
+
clearSession();
|
|
154
|
+
console.log(session ? `Signed out of ${session.site}.` : "nixamp: you were not signed in.");
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
/** `nixamp whoami`, which asks the server rather than trusting the file. */
|
|
158
|
+
export async function whoami(fetcher = fetch) {
|
|
159
|
+
const session = readSession();
|
|
160
|
+
if (session === null) {
|
|
161
|
+
console.log("nixamp: not signed in. Try `nixamp login`.");
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const answer = await fetcher(`${session.site}/api/v1/auth/me`, {
|
|
166
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
167
|
+
});
|
|
168
|
+
if (!answer.ok) {
|
|
169
|
+
// The token outlived its welcome, which is worth saying plainly rather
|
|
170
|
+
// than leaving a stale file to confuse the next command.
|
|
171
|
+
console.log(`nixamp: signed in as ${session.email}, but ${session.site} no longer accepts it.`);
|
|
172
|
+
console.log(" Run `nixamp login` again.");
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
const body = (await answer.json());
|
|
176
|
+
console.log(`${body.account?.email ?? session.email} at ${session.site}`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Offline is not signed out: the token is still good, we just cannot ask.
|
|
181
|
+
console.log(`${session.email} at ${session.site} (could not reach it to check)`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/share.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { IncomingMessage } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* Two keys, two scopes.
|
|
4
|
+
*
|
|
5
|
+
* The full key drives the player: it can skip, stop, and point the server at a
|
|
6
|
+
* different source. The listen key can only hear it. A stream published to the
|
|
7
|
+
* public directory hands out the listen key, because a link that lets a
|
|
8
|
+
* stranger pause your music is not a link you can publish.
|
|
9
|
+
*/
|
|
10
|
+
export type Scope = "control" | "listen";
|
|
2
11
|
/** The cookie, and the query parameter that sets it. */
|
|
3
12
|
export declare const KEY_COOKIE = "nixamp_key";
|
|
4
13
|
export declare const KEY_QUERY = "k";
|
|
@@ -28,6 +37,13 @@ export declare function reachableAddresses(host: string, port: number): {
|
|
|
28
37
|
}[];
|
|
29
38
|
/** The full link, key and all. */
|
|
30
39
|
export declare function shareLink(base: string, key: string | null): string;
|
|
40
|
+
/**
|
|
41
|
+
* What a key is allowed to do. An unknown key is allowed nothing, which is the
|
|
42
|
+
* same answer as no key at all.
|
|
43
|
+
*/
|
|
44
|
+
export declare function scopeOf(offered: string | null, control: string | null, listen: string | null): Scope | null;
|
|
45
|
+
/** Paths a listen key may have. Everything else needs the control key. */
|
|
46
|
+
export declare function allowedForListening(path: string): boolean;
|
|
31
47
|
/** How to run a command, so the tests never touch a real firewall. */
|
|
32
48
|
export interface Runner {
|
|
33
49
|
read(path: string): string | null;
|
package/dist/share.js
CHANGED
|
@@ -112,6 +112,25 @@ export function reachableAddresses(host, port) {
|
|
|
112
112
|
export function shareLink(base, key) {
|
|
113
113
|
return key === null ? base : `${base}/s/${key}`;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* What a key is allowed to do. An unknown key is allowed nothing, which is the
|
|
117
|
+
* same answer as no key at all.
|
|
118
|
+
*/
|
|
119
|
+
export function scopeOf(offered, control, listen) {
|
|
120
|
+
if (offered === null)
|
|
121
|
+
return null;
|
|
122
|
+
if (control !== null && keysMatch(offered, control))
|
|
123
|
+
return "control";
|
|
124
|
+
if (listen !== null && keysMatch(offered, listen))
|
|
125
|
+
return "listen";
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** Paths a listen key may have. Everything else needs the control key. */
|
|
129
|
+
export function allowedForListening(path) {
|
|
130
|
+
if (path === "/api/command" || path === "/api/source")
|
|
131
|
+
return false;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
115
134
|
/**
|
|
116
135
|
* Whether a firewall is running that would keep the port closed to other
|
|
117
136
|
* devices. Listening on 0.0.0.0 proves the socket is open on this machine and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"pack:cli": "bun scripts/pack-cli.ts"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@profullstack/
|
|
42
|
+
"@profullstack/auth-system": "^0.6.0",
|
|
43
|
+
"@profullstack/hqtui": "^0.3.0",
|
|
44
|
+
"@profullstack/x402-gateway": "^0.4.0",
|
|
45
|
+
"pg": "^8.23.0"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
|
45
48
|
"@types/node": "^26",
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts on nixamp.com.
|
|
3
|
+
*
|
|
4
|
+
* The house auth module does the work: password and JWT, over the Postgres
|
|
5
|
+
* adapter. This is the shape nixamp needs around it, and the two things the
|
|
6
|
+
* module gets wrong from a caller's point of view:
|
|
7
|
+
*
|
|
8
|
+
* - `login()` and `register()` THROW on a bad password or a taken address
|
|
9
|
+
* rather than resolving `{ success: false }`, so a bare `if (!result.success)`
|
|
10
|
+
* never runs. Everything here answers a result instead.
|
|
11
|
+
* - `validateToken()` resolves to the claims directly, not to a wrapper like
|
|
12
|
+
* the other two, so the shapes differ between calls.
|
|
13
|
+
* - `register()` without `autoVerify` creates an account that `login()` will
|
|
14
|
+
* refuse for ever, and returns no tokens. nixamp sends no email, so there
|
|
15
|
+
* would be nothing to click.
|
|
16
|
+
*
|
|
17
|
+
* No magic link: a link in an inbox is no use on a television or a phone that
|
|
18
|
+
* is not the one you read mail on.
|
|
19
|
+
*/
|
|
20
|
+
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
21
|
+
|
|
22
|
+
export interface Account {
|
|
23
|
+
id: string;
|
|
24
|
+
email: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuthResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
account: Account | null;
|
|
30
|
+
token: string;
|
|
31
|
+
/** Safe to show a stranger: it never says whether an address is registered. */
|
|
32
|
+
error: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NO_ACCOUNT: AuthResult = { ok: false, account: null, token: "", error: "" };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The same sentence for a wrong password and an address with no account.
|
|
39
|
+
* Saying which is how an endpoint tells a stranger who has registered.
|
|
40
|
+
*/
|
|
41
|
+
const REFUSED = "that email and password do not match an account";
|
|
42
|
+
|
|
43
|
+
export interface AccountsOptions {
|
|
44
|
+
/** postgres://user:pass@host/db */
|
|
45
|
+
connectionString: string;
|
|
46
|
+
/** Signing secret. Without one, every session dies on restart. */
|
|
47
|
+
secret: string;
|
|
48
|
+
/** Injected by the tests, which have no database. */
|
|
49
|
+
system?: AuthLike;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The slice of the auth system nixamp uses. */
|
|
53
|
+
export interface AuthLike {
|
|
54
|
+
register(input: { email: string; password: string; autoVerify?: boolean }): Promise<unknown>;
|
|
55
|
+
login(input: { email: string; password: string }): Promise<unknown>;
|
|
56
|
+
validateToken(token: string): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pull an account and a token out of whatever shape the module returned. */
|
|
60
|
+
export function readResult(value: unknown): AuthResult {
|
|
61
|
+
const record = (value ?? {}) as Record<string, unknown>;
|
|
62
|
+
const user = (record["user"] ?? {}) as Record<string, unknown>;
|
|
63
|
+
const tokens = (record["tokens"] ?? {}) as Record<string, unknown>;
|
|
64
|
+
const id = typeof user["id"] === "string" ? user["id"] : "";
|
|
65
|
+
const email = typeof user["email"] === "string" ? user["email"] : "";
|
|
66
|
+
const token = typeof tokens["accessToken"] === "string" ? tokens["accessToken"] : "";
|
|
67
|
+
if (!id || !token) return { ...NO_ACCOUNT, error: REFUSED };
|
|
68
|
+
return { ok: true, account: { id, email }, token, error: "" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `validateToken` answers claims directly, unlike login and register. */
|
|
72
|
+
export function readClaims(value: unknown): Account | null {
|
|
73
|
+
const claims = (value ?? {}) as Record<string, unknown>;
|
|
74
|
+
const id = typeof claims["userId"] === "string" ? claims["userId"] : "";
|
|
75
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : "";
|
|
76
|
+
return id ? { id, email } : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An address that could exist, and a password long enough to be worth having. */
|
|
80
|
+
export function checkCredentials(email: unknown, password: unknown): string {
|
|
81
|
+
if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
|
82
|
+
return "that does not look like an email address";
|
|
83
|
+
}
|
|
84
|
+
if (typeof password !== "string" || password.length < 10) {
|
|
85
|
+
// Length is checked here so a hopeless password never reaches the
|
|
86
|
+
// database. The auth module then applies its own composition rules on top,
|
|
87
|
+
// and its refusals are passed through rather than swallowed.
|
|
88
|
+
return "a password needs at least 10 characters";
|
|
89
|
+
}
|
|
90
|
+
if (password.length > 200) return "that password is too long";
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class Accounts {
|
|
95
|
+
private readonly system: AuthLike;
|
|
96
|
+
|
|
97
|
+
constructor(options: AccountsOptions) {
|
|
98
|
+
this.system =
|
|
99
|
+
options.system ??
|
|
100
|
+
(createAuthSystem({
|
|
101
|
+
adapter: new PostgresAdapter({ connectionString: options.connectionString }),
|
|
102
|
+
jwtSecret: options.secret,
|
|
103
|
+
}) as AuthLike);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async signUp(email: unknown, password: unknown): Promise<AuthResult> {
|
|
107
|
+
const wrong = checkCredentials(email, password);
|
|
108
|
+
if (wrong) return { ...NO_ACCOUNT, error: wrong };
|
|
109
|
+
try {
|
|
110
|
+
// autoVerify does two things, and both are necessary here: without it
|
|
111
|
+
// the account is created unverified and login() refuses it forever --
|
|
112
|
+
// nixamp sends no email, so there is nothing to click -- and register()
|
|
113
|
+
// returns no tokens, so signing up would not sign you in.
|
|
114
|
+
return readResult(
|
|
115
|
+
await this.system.register({
|
|
116
|
+
email: email as string,
|
|
117
|
+
password: password as string,
|
|
118
|
+
autoVerify: true,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message = (error as Error).message ?? "";
|
|
123
|
+
// "already exists" is the one case worth naming: a sign-up form that
|
|
124
|
+
// will not say why is a sign-up form people give up on. It reveals
|
|
125
|
+
// nothing that trying to sign up does not reveal anyway.
|
|
126
|
+
if (/exist|taken|duplicate/i.test(message)) {
|
|
127
|
+
return { ...NO_ACCOUNT, error: "there is already an account with that email" };
|
|
128
|
+
}
|
|
129
|
+
// The module has its own password rules -- an uppercase letter, and so
|
|
130
|
+
// on -- and refuses with a sentence saying which. Hiding that behind
|
|
131
|
+
// "could not create that account" leaves someone retyping a password
|
|
132
|
+
// that will never be accepted.
|
|
133
|
+
const complaint = /^Invalid (?:password|email)[:\s]+(.*)$/i.exec(message);
|
|
134
|
+
if (complaint?.[1]) return { ...NO_ACCOUNT, error: complaint[1].trim().toLowerCase() };
|
|
135
|
+
return { ...NO_ACCOUNT, error: "could not create that account" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async signIn(email: unknown, password: unknown): Promise<AuthResult> {
|
|
140
|
+
if (checkCredentials(email, password)) return { ...NO_ACCOUNT, error: REFUSED };
|
|
141
|
+
try {
|
|
142
|
+
return readResult(await this.system.login({ email: email as string, password: password as string }));
|
|
143
|
+
} catch {
|
|
144
|
+
// login() throws on bad credentials, so this is the ordinary path.
|
|
145
|
+
return { ...NO_ACCOUNT, error: REFUSED };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async whoIs(token: string): Promise<Account | null> {
|
|
150
|
+
if (!token) return null;
|
|
151
|
+
try {
|
|
152
|
+
return readClaims(await this.system.validateToken(token));
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The bearer token on a request, from the header or the session cookie. */
|
|
160
|
+
export function tokenFrom(headers: Record<string, string | string[] | undefined>): string {
|
|
161
|
+
const authorization = headers["authorization"];
|
|
162
|
+
const header = Array.isArray(authorization) ? authorization[0] : authorization;
|
|
163
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(header ?? "")?.[1];
|
|
164
|
+
if (bearer) return bearer.trim();
|
|
165
|
+
|
|
166
|
+
const cookie = Array.isArray(headers["cookie"]) ? headers["cookie"][0] : headers["cookie"];
|
|
167
|
+
for (const part of (cookie ?? "").split(";")) {
|
|
168
|
+
const [name, ...rest] = part.trim().split("=");
|
|
169
|
+
if (name === "nixamp_session" && rest.length > 0) return decodeURIComponent(rest.join("="));
|
|
170
|
+
}
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The session cookie. HttpOnly because nothing in the page reads it -- the
|
|
176
|
+
* browser attaches it by itself -- and Secure only where the page was served
|
|
177
|
+
* over https, since a nixamp on your own network is plain http.
|
|
178
|
+
*/
|
|
179
|
+
export function sessionCookie(token: string, secure: boolean): string {
|
|
180
|
+
const parts = [
|
|
181
|
+
`nixamp_session=${encodeURIComponent(token)}`,
|
|
182
|
+
"Path=/",
|
|
183
|
+
"Max-Age=2592000",
|
|
184
|
+
"SameSite=Lax",
|
|
185
|
+
"HttpOnly",
|
|
186
|
+
];
|
|
187
|
+
if (secure) parts.push("Secure");
|
|
188
|
+
return parts.join("; ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function clearedCookie(): string {
|
|
192
|
+
return "nixamp_session=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly";
|
|
193
|
+
}
|