@aweftjs/auth 0.1.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/LICENSE +21 -0
- package/README.md +548 -0
- package/dist/auth-client.d.ts +248 -0
- package/dist/auth-client.js +287 -0
- package/dist/client-modules/Reset.d.ts +12 -0
- package/dist/client-modules/Reset.js +61 -0
- package/dist/client-modules/Session.d.ts +6 -0
- package/dist/client-modules/Session.js +59 -0
- package/dist/client-modules/SignIn.d.ts +13 -0
- package/dist/client-modules/SignIn.js +58 -0
- package/dist/client-modules/Verify.d.ts +12 -0
- package/dist/client-modules/Verify.js +54 -0
- package/dist/client-modules/stage-token.d.ts +3 -0
- package/dist/client-modules/stage-token.js +9 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +33 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +11 -0
- package/dist/cookie.d.ts +15 -0
- package/dist/cookie.js +39 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +42 -0
- package/dist/links.d.ts +37 -0
- package/dist/links.js +77 -0
- package/dist/mail.d.ts +55 -0
- package/dist/mail.js +53 -0
- package/dist/modules/Check.d.ts +10 -0
- package/dist/modules/Check.js +20 -0
- package/dist/modules/Enter.d.ts +57 -0
- package/dist/modules/Enter.js +143 -0
- package/dist/modules/Gate.d.ts +6 -0
- package/dist/modules/Gate.js +43 -0
- package/dist/modules/Password.d.ts +34 -0
- package/dist/modules/Password.js +138 -0
- package/dist/modules/Roles.d.ts +32 -0
- package/dist/modules/Roles.js +135 -0
- package/dist/modules/Session.d.ts +39 -0
- package/dist/modules/Session.js +159 -0
- package/dist/modules/State.d.ts +8 -0
- package/dist/modules/State.js +22 -0
- package/dist/modules/Verify.d.ts +32 -0
- package/dist/modules/Verify.js +97 -0
- package/dist/names.d.ts +25 -0
- package/dist/names.js +44 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +41 -0
- package/dist/props.d.ts +15 -0
- package/dist/props.js +35 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +11 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +12 -0
- package/errors.txt +29 -0
- package/package.json +62 -0
- package/src/auth-client.ts +531 -0
- package/src/client-modules/Reset.tsx +97 -0
- package/src/client-modules/Session.ts +70 -0
- package/src/client-modules/SignIn.tsx +110 -0
- package/src/client-modules/Verify.tsx +82 -0
- package/src/client-modules/stage-token.ts +11 -0
- package/src/client.ts +38 -0
- package/src/context.ts +21 -0
- package/src/cookie.ts +35 -0
- package/src/index.ts +53 -0
- package/src/links.ts +107 -0
- package/src/mail.ts +88 -0
- package/src/modules/Check.ts +31 -0
- package/src/modules/Enter.ts +189 -0
- package/src/modules/Gate.ts +47 -0
- package/src/modules/Password.ts +155 -0
- package/src/modules/Roles.ts +163 -0
- package/src/modules/Session.ts +196 -0
- package/src/modules/State.ts +30 -0
- package/src/modules/Verify.ts +118 -0
- package/src/names.ts +49 -0
- package/src/password.ts +43 -0
- package/src/props.ts +47 -0
- package/src/token.ts +15 -0
- package/src/users.ts +18 -0
- package/surface.txt +19 -0
- package/text.json +50 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const deps: string[];
|
|
2
|
+
/** What the stage hands a refused act beside the refusal (design 244). */
|
|
3
|
+
interface SignInProps {
|
|
4
|
+
/** Build the act the URL chose again. Present when this act is what `refused` names. */
|
|
5
|
+
readonly retry?: () => void;
|
|
6
|
+
}
|
|
7
|
+
declare const _default: ({ imports }: {
|
|
8
|
+
imports: Readonly<Record<string, unknown>>;
|
|
9
|
+
}) => {
|
|
10
|
+
title: unknown;
|
|
11
|
+
component: (props: SignInProps) => unknown;
|
|
12
|
+
};
|
|
13
|
+
export default _default;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { template as _template } from '@aweftjs/ui';
|
|
2
|
+
const _t0 = _template(["form", null, ["p", null]], [["props", []], ["child", [], 0], ["child", [], 0], ["props", [0]], ["child", [0], -1], ["child", [], -1]]);
|
|
3
|
+
// auth/SignIn: one form for signing in and signing up, because `enter` does both (design 245).
|
|
4
|
+
//
|
|
5
|
+
// It decides nothing an application would want back: no URL of its own, no redirect, and no
|
|
6
|
+
// opinion about who may see what. Where to go afterwards is the application's.
|
|
7
|
+
import { mutable } from '@aweftjs/core';
|
|
8
|
+
import { Button, TextField, h, text } from '@aweftjs/ui';
|
|
9
|
+
export const deps = ['auth/Session'];
|
|
10
|
+
const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
|
|
11
|
+
const about = (reasons, code) => reasons.filter((one) => one.code === code).map((one) => one.message).join(' ');
|
|
12
|
+
/** Everything the refusal said that was not about one of the two fields. */
|
|
13
|
+
const rest = (reasons) => reasons.filter((one) => one.code !== 'email' && one.code !== 'password')
|
|
14
|
+
.map((one) => one.message).join(' ');
|
|
15
|
+
export default ({ imports }) => {
|
|
16
|
+
const session = imports['Session'];
|
|
17
|
+
const component = (props) => {
|
|
18
|
+
const email = mutable('');
|
|
19
|
+
const password = mutable('');
|
|
20
|
+
const emailProblem = mutable('');
|
|
21
|
+
const passwordProblem = mutable('');
|
|
22
|
+
const problem = mutable('');
|
|
23
|
+
const busy = mutable(false);
|
|
24
|
+
const submit = async () => {
|
|
25
|
+
if (busy.get())
|
|
26
|
+
return;
|
|
27
|
+
busy.set(true);
|
|
28
|
+
emailProblem.set('');
|
|
29
|
+
passwordProblem.set('');
|
|
30
|
+
problem.set('');
|
|
31
|
+
try {
|
|
32
|
+
const outcome = await session.enter(email.get(), password.get());
|
|
33
|
+
if (!('refused' in outcome)) {
|
|
34
|
+
// This act is standing in for the one the URL asked for, so the thing to do once
|
|
35
|
+
// the reason has stopped holding is build that act again. The battery still picks
|
|
36
|
+
// no URL: `retry` is the stage's own, and the address does not move.
|
|
37
|
+
props.retry?.();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const reasons = outcome.refused;
|
|
41
|
+
emailProblem.set(about(reasons, 'email'));
|
|
42
|
+
passwordProblem.set(about(reasons, 'password'));
|
|
43
|
+
problem.set(rest(reasons));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
problem.set(String(error?.message ?? error));
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
busy.set(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
return (_t0([{ "aria-label": text('Sign in'), style: LAYOUT, onSubmit: (event) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
void submit();
|
|
55
|
+
} }, h(TextField, { label: text('Email'), value: email, error: emailProblem, placeholder: text('you@example.com'), name: "email", autocomplete: "email" }), h(TextField, { label: text('Password'), value: password, error: passwordProblem, password: true, name: "password", autocomplete: "current-password" }), { role: "alert" }, problem, h(Button, { label: text('Sign in'), loading: busy, disabled: busy, onClick: submit })]));
|
|
56
|
+
};
|
|
57
|
+
return { title: text('Sign in'), component };
|
|
58
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StageValue } from '@aweftjs/ui';
|
|
2
|
+
export declare const deps: string[];
|
|
3
|
+
interface VerifyProps {
|
|
4
|
+
readonly stage?: StageValue;
|
|
5
|
+
}
|
|
6
|
+
declare const _default: ({ imports }: {
|
|
7
|
+
imports: Readonly<Record<string, unknown>>;
|
|
8
|
+
}) => {
|
|
9
|
+
title: unknown;
|
|
10
|
+
component: (props: VerifyProps) => unknown;
|
|
11
|
+
};
|
|
12
|
+
export default _default;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { template as _template } from '@aweftjs/ui';
|
|
2
|
+
const _t0 = _template(["section", null, ["p", null], ["p", null]], [["props", []], ["child", [0], -1], ["props", [1]], ["child", [1], -1]]);
|
|
3
|
+
const _t1 = _template(["section", null, ["p", null], ["p", null], ["p", null]], [["props", []], ["child", [0], -1], ["child", [1], -1], ["props", [2]], ["child", [2], -1], ["child", [], -1]]);
|
|
4
|
+
// auth/Verify: the page a verification link opens, and the button that asks for one (design 290).
|
|
5
|
+
//
|
|
6
|
+
// With a token in the act's parameters or the URL's query it takes the link as soon as it
|
|
7
|
+
// mounts and says what happened. Without one it offers a signed-in person the mail. It picks no
|
|
8
|
+
// URL: the application names it in the acts map, and points the mail at that address.
|
|
9
|
+
import { all, mutable } from '@aweftjs/core';
|
|
10
|
+
import { Button, h, text } from '@aweftjs/ui';
|
|
11
|
+
import { tokenOf } from "./stage-token.js";
|
|
12
|
+
export const deps = ['auth/Session'];
|
|
13
|
+
const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
|
|
14
|
+
const said = (reasons) => reasons.map((one) => one.message).join(' ');
|
|
15
|
+
export default ({ imports }) => {
|
|
16
|
+
const session = imports['Session'];
|
|
17
|
+
const component = (props) => {
|
|
18
|
+
const token = tokenOf(props.stage);
|
|
19
|
+
const note = mutable('');
|
|
20
|
+
const problem = mutable('');
|
|
21
|
+
const busy = mutable(false);
|
|
22
|
+
const done = mutable(false);
|
|
23
|
+
const outcome = async (run, onOk) => {
|
|
24
|
+
if (busy.get())
|
|
25
|
+
return;
|
|
26
|
+
busy.set(true);
|
|
27
|
+
problem.set('');
|
|
28
|
+
try {
|
|
29
|
+
const answer = await run();
|
|
30
|
+
if ('refused' in answer)
|
|
31
|
+
problem.set(said(answer.refused));
|
|
32
|
+
else {
|
|
33
|
+
note.set(onOk);
|
|
34
|
+
done.set(true);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
problem.set(String(error?.message ?? error));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
busy.set(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
if (token !== undefined) {
|
|
45
|
+
void outcome(() => session.verify(token), text('Your email address is verified.'));
|
|
46
|
+
return (_t0([{ "aria-label": text('Verify your email'), style: LAYOUT }, note, { role: "alert" }, problem]));
|
|
47
|
+
}
|
|
48
|
+
const send = () => outcome(() => session.verify(), text('The link is on its way. Open it from your mail.'));
|
|
49
|
+
// Reads `user`, so the button follows a sign-in or a sign-out on the same page.
|
|
50
|
+
const anonymous = session.user.map((who) => who === null);
|
|
51
|
+
return (_t1([{ "aria-label": text('Verify your email'), style: LAYOUT }, anonymous.map((is) => is ? text('Sign in first, then ask for the link.') : text('We will send a link to your email address.')), note, { role: "alert" }, problem, h(Button, { label: text('Send the link'), loading: busy, disabled: all([anonymous, busy, done]).map(([is, sending, sent]) => is || sending || sent), onClick: send })]));
|
|
52
|
+
};
|
|
53
|
+
return { title: text('Verify your email'), component };
|
|
54
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The token a link put in the URL, as the two link acts read it (design 290).
|
|
2
|
+
/** The token the URL carries, from the act's own parameter first and the query second. */
|
|
3
|
+
export const tokenOf = (stage) => {
|
|
4
|
+
const named = stage?.params.get().token;
|
|
5
|
+
if (typeof named === 'string' && named !== '')
|
|
6
|
+
return named;
|
|
7
|
+
const queried = stage?.query.get().token;
|
|
8
|
+
return typeof queried === 'string' && queried !== '' ? queried : undefined;
|
|
9
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Source } from '@aweftjs/modules';
|
|
2
|
+
export { createAuth } from './auth-client.ts';
|
|
3
|
+
export type { Auth, AuthOptions, Entered, FetchInit, FetchResponse, Fetcher, Outcome } from './auth-client.ts';
|
|
4
|
+
/**
|
|
5
|
+
* The battery's page modules, for a stage's `sources`.
|
|
6
|
+
*
|
|
7
|
+
* Four of them. `auth/Session` is `createAuth` over the connection the stage handed the loader,
|
|
8
|
+
* and its instance is the `Auth`: `user`, `names`, `may`, `enter`, `leave`, `state`, `check`,
|
|
9
|
+
* `verify`, `change`, `forgot`, `reset` and `stop`. Any module of yours that needs to know who
|
|
10
|
+
* the page is names it in `deps`. The other three are act modules: `auth/SignIn`, the sign-in
|
|
11
|
+
* and sign-up form in one, because `enter` does both; `auth/Verify`, the page a verification
|
|
12
|
+
* link opens, and the button that asks for one; `auth/Reset`, the forgot form, and the
|
|
13
|
+
* new-password form a reset link opens.
|
|
14
|
+
*
|
|
15
|
+
* The battery picks no URL. Put an act on the address you want it at, and it lands there; the
|
|
16
|
+
* mail links point at those addresses through the server modules' `url`. Put a module of the
|
|
17
|
+
* same name in an earlier source to replace any of them, or a file exporting only `config` to
|
|
18
|
+
* configure one: `auth/Session` reads `origin` and `fetch` out of its config.
|
|
19
|
+
*
|
|
20
|
+
* Example:
|
|
21
|
+
* <StageContext sources={[app, authClient]} client={client}
|
|
22
|
+
* acts={{ '': Home, notes: 'notes/Page', join: 'auth/SignIn', verify: 'auth/Verify', reset: 'auth/Reset' }}
|
|
23
|
+
* refused="join">
|
|
24
|
+
* <Stage />
|
|
25
|
+
* </StageContext>
|
|
26
|
+
*/
|
|
27
|
+
export declare const authClient: Source;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The browser half of the battery, as an entry: what a page calls itself, and the source that
|
|
2
|
+
// puts the four client modules on the page (designs 245, 290).
|
|
3
|
+
import { fromBundle } from '@aweftjs/modules';
|
|
4
|
+
export { createAuth } from "./auth-client.js";
|
|
5
|
+
/**
|
|
6
|
+
* The battery's page modules, for a stage's `sources`.
|
|
7
|
+
*
|
|
8
|
+
* Four of them. `auth/Session` is `createAuth` over the connection the stage handed the loader,
|
|
9
|
+
* and its instance is the `Auth`: `user`, `names`, `may`, `enter`, `leave`, `state`, `check`,
|
|
10
|
+
* `verify`, `change`, `forgot`, `reset` and `stop`. Any module of yours that needs to know who
|
|
11
|
+
* the page is names it in `deps`. The other three are act modules: `auth/SignIn`, the sign-in
|
|
12
|
+
* and sign-up form in one, because `enter` does both; `auth/Verify`, the page a verification
|
|
13
|
+
* link opens, and the button that asks for one; `auth/Reset`, the forgot form, and the
|
|
14
|
+
* new-password form a reset link opens.
|
|
15
|
+
*
|
|
16
|
+
* The battery picks no URL. Put an act on the address you want it at, and it lands there; the
|
|
17
|
+
* mail links point at those addresses through the server modules' `url`. Put a module of the
|
|
18
|
+
* same name in an earlier source to replace any of them, or a file exporting only `config` to
|
|
19
|
+
* configure one: `auth/Session` reads `origin` and `fetch` out of its config.
|
|
20
|
+
*
|
|
21
|
+
* Example:
|
|
22
|
+
* <StageContext sources={[app, authClient]} client={client}
|
|
23
|
+
* acts={{ '': Home, notes: 'notes/Page', join: 'auth/SignIn', verify: 'auth/Verify', reset: 'auth/Reset' }}
|
|
24
|
+
* refused="join">
|
|
25
|
+
* <Stage />
|
|
26
|
+
* </StageContext>
|
|
27
|
+
*/
|
|
28
|
+
export const authClient = fromBundle({
|
|
29
|
+
'./auth/Session.ts': () => import("./client-modules/Session.js"),
|
|
30
|
+
'./auth/SignIn.tsx': () => import("./client-modules/SignIn.js"),
|
|
31
|
+
'./auth/Verify.tsx': () => import("./client-modules/Verify.js"),
|
|
32
|
+
'./auth/Reset.tsx': () => import("./client-modules/Reset.js"),
|
|
33
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Who is on a connection: the user's id and the session token, or neither, and where it came from. */
|
|
2
|
+
export interface AuthContext {
|
|
3
|
+
readonly user: string | null;
|
|
4
|
+
readonly session: string | null;
|
|
5
|
+
/** The peer address as far as the listener could tell, so a route can count by it. Absent from a context built by hand. */
|
|
6
|
+
readonly address?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** The user on a context, when the context is one of ours and has one. */
|
|
9
|
+
export declare const userOf: (context: unknown) => string | null;
|
|
10
|
+
/** The address on a context, when it carries one. */
|
|
11
|
+
export declare const addressOf: (context: unknown) => string | undefined;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// What the auth gate resolves for a connection or a request (designs 074, 275).
|
|
2
|
+
/** The user on a context, when the context is one of ours and has one. */
|
|
3
|
+
export const userOf = (context) => {
|
|
4
|
+
const user = context?.user;
|
|
5
|
+
return typeof user === 'string' ? user : null;
|
|
6
|
+
};
|
|
7
|
+
/** The address on a context, when it carries one. */
|
|
8
|
+
export const addressOf = (context) => {
|
|
9
|
+
const address = context?.address;
|
|
10
|
+
return typeof address === 'string' ? address : undefined;
|
|
11
|
+
};
|
package/dist/cookie.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every non-empty value of the cookies by that name, in the order the client sent them.
|
|
3
|
+
*
|
|
4
|
+
* Several, because a browser sends every cookie of that name whose scope matches, and one
|
|
5
|
+
* from another path or another application on the host is not this battery's to refuse. An
|
|
6
|
+
* empty value is what the battery's own clearing header leaves, and counts as absent.
|
|
7
|
+
*/
|
|
8
|
+
export declare const cookiesOf: (request: Request, name: string) => string[];
|
|
9
|
+
/**
|
|
10
|
+
* The `Set-Cookie` value that sets the session cookie, or clears it for `null`.
|
|
11
|
+
*
|
|
12
|
+
* HttpOnly so script never reads it, SameSite=Lax so a cross-site form cannot present it,
|
|
13
|
+
* Secure when the request itself came over TLS, and a lifetime only when one is configured.
|
|
14
|
+
*/
|
|
15
|
+
export declare const setCookie: (name: string, token: string | null, request: Request, maxAgeMs?: number) => string;
|
package/dist/cookie.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The session cookie: reading it off a request, and writing the header that sets or clears it.
|
|
2
|
+
/**
|
|
3
|
+
* Every non-empty value of the cookies by that name, in the order the client sent them.
|
|
4
|
+
*
|
|
5
|
+
* Several, because a browser sends every cookie of that name whose scope matches, and one
|
|
6
|
+
* from another path or another application on the host is not this battery's to refuse. An
|
|
7
|
+
* empty value is what the battery's own clearing header leaves, and counts as absent.
|
|
8
|
+
*/
|
|
9
|
+
export const cookiesOf = (request, name) => {
|
|
10
|
+
const header = request.headers.get('cookie');
|
|
11
|
+
if (header === null)
|
|
12
|
+
return [];
|
|
13
|
+
const values = [];
|
|
14
|
+
for (const part of header.split(';')) {
|
|
15
|
+
const at = part.indexOf('=');
|
|
16
|
+
if (at < 0 || part.slice(0, at).trim() !== name)
|
|
17
|
+
continue;
|
|
18
|
+
const value = part.slice(at + 1).trim();
|
|
19
|
+
if (value !== '')
|
|
20
|
+
values.push(value);
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The `Set-Cookie` value that sets the session cookie, or clears it for `null`.
|
|
26
|
+
*
|
|
27
|
+
* HttpOnly so script never reads it, SameSite=Lax so a cross-site form cannot present it,
|
|
28
|
+
* Secure when the request itself came over TLS, and a lifetime only when one is configured.
|
|
29
|
+
*/
|
|
30
|
+
export const setCookie = (name, token, request, maxAgeMs) => {
|
|
31
|
+
const parts = [`${name}=${token ?? ''}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
32
|
+
if (new URL(request.url).protocol === 'https:')
|
|
33
|
+
parts.push('Secure');
|
|
34
|
+
if (token === null)
|
|
35
|
+
parts.push('Max-Age=0');
|
|
36
|
+
else if (maxAgeMs !== undefined)
|
|
37
|
+
parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
|
|
38
|
+
return parts.join('; ');
|
|
39
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Source } from '@aweftjs/modules';
|
|
2
|
+
import type { Declaration } from '@aweftjs/store';
|
|
3
|
+
/**
|
|
4
|
+
* The auth modules, for `sources`.
|
|
5
|
+
*
|
|
6
|
+
* Put the application's own source first and a module of the same name there wins, which is
|
|
7
|
+
* how one of these is replaced. Each module reads the `store` the platform handed in, which
|
|
8
|
+
* on a server is the `store` given to `createServer`.
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
* const server = createServer({ sources: [own, auth], store, gate: 'auth/Gate', listener });
|
|
12
|
+
* await server.start();
|
|
13
|
+
*/
|
|
14
|
+
export declare const auth: Source;
|
|
15
|
+
/**
|
|
16
|
+
* The two modules that mail: `auth/Verify` and `auth/Password`. Both name `notify/Send` in
|
|
17
|
+
* their `deps`, so list `notify` beside this, and give each its `url` in a same-named file.
|
|
18
|
+
*
|
|
19
|
+
* Example:
|
|
20
|
+
* createServer({ sources: [own, auth, mail, notify], store, gate: 'auth/Gate', listener });
|
|
21
|
+
*/
|
|
22
|
+
export declare const mail: Source;
|
|
23
|
+
/**
|
|
24
|
+
* The paths the application declares on its store for these modules to query: `email` on
|
|
25
|
+
* user documents, `user` and `expires` on session and link documents.
|
|
26
|
+
*
|
|
27
|
+
* Example:
|
|
28
|
+
* const store = createStore({ driver, declare: { ...paths, title: ['title'] } });
|
|
29
|
+
*/
|
|
30
|
+
export declare const paths: Declaration;
|
|
31
|
+
export { holds } from './names.ts';
|
|
32
|
+
export type { Implies } from './names.ts';
|
|
33
|
+
export type { AuthContext } from './context.ts';
|
|
34
|
+
export type { RefuseSignUp, SignUp } from './modules/Enter.ts';
|
|
35
|
+
export type { Roles } from './modules/Roles.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The battery, as two sources of server modules: the six every application loads, the two
|
|
2
|
+
// that mail and so need notify, and the store paths they query (designs 074, 289, 290).
|
|
3
|
+
import { fromBundle } from '@aweftjs/modules';
|
|
4
|
+
/**
|
|
5
|
+
* The auth modules, for `sources`.
|
|
6
|
+
*
|
|
7
|
+
* Put the application's own source first and a module of the same name there wins, which is
|
|
8
|
+
* how one of these is replaced. Each module reads the `store` the platform handed in, which
|
|
9
|
+
* on a server is the `store` given to `createServer`.
|
|
10
|
+
*
|
|
11
|
+
* Example:
|
|
12
|
+
* const server = createServer({ sources: [own, auth], store, gate: 'auth/Gate', listener });
|
|
13
|
+
* await server.start();
|
|
14
|
+
*/
|
|
15
|
+
export const auth = fromBundle({
|
|
16
|
+
'./auth/Gate.ts': () => import("./modules/Gate.js"),
|
|
17
|
+
'./auth/Session.ts': () => import("./modules/Session.js"),
|
|
18
|
+
'./auth/Roles.ts': () => import("./modules/Roles.js"),
|
|
19
|
+
'./auth/Enter.ts': () => import("./modules/Enter.js"),
|
|
20
|
+
'./auth/Check.ts': () => import("./modules/Check.js"),
|
|
21
|
+
'./auth/State.ts': () => import("./modules/State.js"),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* The two modules that mail: `auth/Verify` and `auth/Password`. Both name `notify/Send` in
|
|
25
|
+
* their `deps`, so list `notify` beside this, and give each its `url` in a same-named file.
|
|
26
|
+
*
|
|
27
|
+
* Example:
|
|
28
|
+
* createServer({ sources: [own, auth, mail, notify], store, gate: 'auth/Gate', listener });
|
|
29
|
+
*/
|
|
30
|
+
export const mail = fromBundle({
|
|
31
|
+
'./auth/Verify.ts': () => import("./modules/Verify.js"),
|
|
32
|
+
'./auth/Password.ts': () => import("./modules/Password.js"),
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* The paths the application declares on its store for these modules to query: `email` on
|
|
36
|
+
* user documents, `user` and `expires` on session and link documents.
|
|
37
|
+
*
|
|
38
|
+
* Example:
|
|
39
|
+
* const store = createStore({ driver, declare: { ...paths, title: ['title'] } });
|
|
40
|
+
*/
|
|
41
|
+
export const paths = { email: ['email'], user: ['user'], expires: ['expires'] };
|
|
42
|
+
export { holds } from "./names.js";
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Refusal } from '@aweftjs/core';
|
|
2
|
+
import type { Store } from '@aweftjs/store';
|
|
3
|
+
/** What a link document holds. `user` and `expires` are paths the battery declares, so the sweep is a query. `taken` is set once and queried by nothing. */
|
|
4
|
+
export interface LinkDocument extends Record<string, unknown> {
|
|
5
|
+
user: string;
|
|
6
|
+
expires: number;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
taken?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** What a token names before its end: the user of a live link, or that the link was taken. */
|
|
11
|
+
export type Link = {
|
|
12
|
+
readonly user: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly taken: true;
|
|
15
|
+
};
|
|
16
|
+
/** What a route answers for a link it will not open: one nobody issued or past its end, and one already taken. */
|
|
17
|
+
export declare const NOT_LIVE: Refusal;
|
|
18
|
+
export declare const TAKEN: Refusal;
|
|
19
|
+
export interface Links {
|
|
20
|
+
/** Mint a link for a user. Returns its token. */
|
|
21
|
+
issue(user: string): Promise<string>;
|
|
22
|
+
/** What the token names, leaving it. Undefined for a token nobody issued or one past its end. */
|
|
23
|
+
peek(token: unknown): Promise<Link | undefined>;
|
|
24
|
+
/** Take the link: the user it was for, and the document is marked taken. Undefined as `peek`. */
|
|
25
|
+
take(token: unknown): Promise<Link | undefined>;
|
|
26
|
+
/** Remove every link past its end, taken or not. Returns how many. */
|
|
27
|
+
sweep(): Promise<number>;
|
|
28
|
+
/** Stop the sweep timer. */
|
|
29
|
+
stop(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The links of one prefix, each living `lifetimeMs` and swept every `sweepMs`.
|
|
33
|
+
*
|
|
34
|
+
* The first sweep runs when this is made, so a restart clears what expired while the process
|
|
35
|
+
* was down; the timer never keeps the process alive.
|
|
36
|
+
*/
|
|
37
|
+
export declare const links: (store: Store, prefix: string, lifetimeMs: number, sweepMs: number) => Links;
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A one-time link: a token naming a `<prefix>:<token>` document that says who it is for and
|
|
2
|
+
// when it stops being valid (design 290). Verification and reset links are both this. A taken
|
|
3
|
+
// link keeps its document, marked, until its end, so a second opening is told it was taken
|
|
4
|
+
// rather than that it never was (design 294).
|
|
5
|
+
import { atomic } from '@aweftjs/core';
|
|
6
|
+
import { isToken, mintToken } from "./token.js";
|
|
7
|
+
/** What a route answers for a link it will not open: one nobody issued or past its end, and one already taken. */
|
|
8
|
+
export const NOT_LIVE = { code: 'token', message: 'this link is not one that can be used' };
|
|
9
|
+
export const TAKEN = { code: 'taken', message: 'this link has already been used' };
|
|
10
|
+
/**
|
|
11
|
+
* The links of one prefix, each living `lifetimeMs` and swept every `sweepMs`.
|
|
12
|
+
*
|
|
13
|
+
* The first sweep runs when this is made, so a restart clears what expired while the process
|
|
14
|
+
* was down; the timer never keeps the process alive.
|
|
15
|
+
*/
|
|
16
|
+
export const links = (store, prefix, lifetimeMs, sweepMs) => {
|
|
17
|
+
const doc = (token) => `${prefix}:${token}`;
|
|
18
|
+
const linkOf = (held) => {
|
|
19
|
+
if (typeof held.user !== 'string' || typeof held.expires !== 'number' || held.expires <= Date.now())
|
|
20
|
+
return undefined;
|
|
21
|
+
return held.taken === true ? { taken: true } : { user: held.user };
|
|
22
|
+
};
|
|
23
|
+
const sweep = async () => {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
let removed = 0;
|
|
26
|
+
for (const { doc: name, fields } of await store.find({ where: [{ field: 'expires', op: 'lt', value: now }] })) {
|
|
27
|
+
// The path is declared for every document, so the answer is filtered to this prefix.
|
|
28
|
+
if (!name.startsWith(`${prefix}:`) || typeof fields.expires !== 'number' || fields.expires >= now)
|
|
29
|
+
continue;
|
|
30
|
+
await store.remove(name);
|
|
31
|
+
removed += 1;
|
|
32
|
+
}
|
|
33
|
+
return removed;
|
|
34
|
+
};
|
|
35
|
+
void sweep().catch(() => undefined);
|
|
36
|
+
const timer = setInterval(() => { void sweep().catch(() => undefined); }, sweepMs);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
return {
|
|
39
|
+
issue: async (user) => {
|
|
40
|
+
const token = mintToken();
|
|
41
|
+
const handle = await store.open(doc(token));
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
atomic(() => {
|
|
44
|
+
Object.assign(handle.root, { user, expires: now + lifetimeMs, createdAt: now });
|
|
45
|
+
});
|
|
46
|
+
await store.settled(handle);
|
|
47
|
+
await store.close(handle);
|
|
48
|
+
return token;
|
|
49
|
+
},
|
|
50
|
+
peek: async (token) => {
|
|
51
|
+
// A document that was never written has no commits, and `open` would create one.
|
|
52
|
+
if (!isToken(token) || await store.head(doc(token)) === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
const handle = await store.open(doc(token));
|
|
55
|
+
const link = linkOf(handle.root);
|
|
56
|
+
await store.close(handle);
|
|
57
|
+
return link;
|
|
58
|
+
},
|
|
59
|
+
take: async (token) => {
|
|
60
|
+
if (!isToken(token) || await store.head(doc(token)) === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
const handle = await store.open(doc(token));
|
|
63
|
+
const held = handle.root;
|
|
64
|
+
// Two takes of one token in flight open the same live document, so the first to get
|
|
65
|
+
// here marks it and the second reads the mark.
|
|
66
|
+
const link = linkOf(held);
|
|
67
|
+
if (link !== undefined && 'user' in link) {
|
|
68
|
+
atomic(() => { held.taken = true; });
|
|
69
|
+
await store.settled(handle);
|
|
70
|
+
}
|
|
71
|
+
await store.close(handle);
|
|
72
|
+
return link;
|
|
73
|
+
},
|
|
74
|
+
sweep,
|
|
75
|
+
stop: () => { clearInterval(timer); },
|
|
76
|
+
};
|
|
77
|
+
};
|
package/dist/mail.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Refusal } from '@aweftjs/server';
|
|
2
|
+
/** What one channel of a send answered, as `notify/Send` records it. */
|
|
3
|
+
export type MailDelivery = {
|
|
4
|
+
readonly ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
readonly ok: false;
|
|
7
|
+
readonly error: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly skipped: string;
|
|
10
|
+
};
|
|
11
|
+
/** The part of `notify/Send` these modules call. */
|
|
12
|
+
export interface Mailer {
|
|
13
|
+
send(options: {
|
|
14
|
+
readonly to: {
|
|
15
|
+
readonly user: string;
|
|
16
|
+
};
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly body: string;
|
|
19
|
+
readonly html: string;
|
|
20
|
+
readonly channels: readonly ['email'];
|
|
21
|
+
}): Promise<{
|
|
22
|
+
readonly delivery: {
|
|
23
|
+
readonly email?: MailDelivery | undefined;
|
|
24
|
+
};
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
/** The link an application's `url` makes for a token. */
|
|
28
|
+
export type LinkUrl = (token: string) => string;
|
|
29
|
+
/** What a mail route's call answers: done, or the reasons. */
|
|
30
|
+
export type Outcome = {
|
|
31
|
+
readonly ok: true;
|
|
32
|
+
} | {
|
|
33
|
+
readonly refused: readonly Refusal[];
|
|
34
|
+
};
|
|
35
|
+
/** Non-empty text, or `invalid-config`. */
|
|
36
|
+
export declare const textOf: (module: string, config: Readonly<Record<string, unknown>>, key: string) => string;
|
|
37
|
+
/** The `url` function, which has no default because a battery picks no URL. */
|
|
38
|
+
export declare const urlOf: (module: string, config: Readonly<Record<string, unknown>>) => LinkUrl;
|
|
39
|
+
/**
|
|
40
|
+
* The `mail` refusal (design 293): `message` is the sentence a page shows the person, `detail`
|
|
41
|
+
* is what the mailer said, and `fix` is for whoever runs it. The route answers 502 and the
|
|
42
|
+
* token still stands.
|
|
43
|
+
*/
|
|
44
|
+
export interface MailRefusal extends Refusal {
|
|
45
|
+
readonly detail: string;
|
|
46
|
+
readonly fix: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const mailFailed: (detail: string) => MailRefusal;
|
|
49
|
+
/**
|
|
50
|
+
* Mail a person one link. Answers the refusal when it did not go, and nothing when it did.
|
|
51
|
+
*
|
|
52
|
+
* The body is the sentence and the address as text; the HTML is the same with the address as a
|
|
53
|
+
* link, so a reader whose mail shows no HTML has the address to copy.
|
|
54
|
+
*/
|
|
55
|
+
export declare const mailLink: (mailer: Mailer, user: string, subject: string, sentence: string, url: string) => Promise<MailRefusal | undefined>;
|
package/dist/mail.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What the two mail modules share: the mailer they name in `deps`, the link mail, and the
|
|
2
|
+
// configuration checks (design 290). `notify/Send` is the mailer; its shape is stated here
|
|
3
|
+
// rather than imported, so `auth` alone carries nothing of notify, and the integration suite
|
|
4
|
+
// loads the real one to keep the two agreeing.
|
|
5
|
+
import { invalidConfig } from "./props.js";
|
|
6
|
+
const escape = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
7
|
+
/** Non-empty text, or `invalid-config`. */
|
|
8
|
+
export const textOf = (module, config, key) => {
|
|
9
|
+
const held = config[key];
|
|
10
|
+
if (typeof held !== 'string' || held === '')
|
|
11
|
+
throw invalidConfig(module, `${key} ${JSON.stringify(held)}`, 'Give that setting some text.');
|
|
12
|
+
return held;
|
|
13
|
+
};
|
|
14
|
+
/** The `url` function, which has no default because a battery picks no URL. */
|
|
15
|
+
export const urlOf = (module, config) => {
|
|
16
|
+
const held = config.url;
|
|
17
|
+
if (typeof held !== 'function') {
|
|
18
|
+
throw invalidConfig(module, `url ${JSON.stringify(held)}`, 'Give url a function of the token answering the address of the page that takes it: (token) => `https://app.example/verify?token=${token}`.');
|
|
19
|
+
}
|
|
20
|
+
return held;
|
|
21
|
+
};
|
|
22
|
+
export const mailFailed = (detail) => ({
|
|
23
|
+
code: 'mail',
|
|
24
|
+
message: 'the mail could not be sent; try again later',
|
|
25
|
+
detail,
|
|
26
|
+
fix: 'Check the email setting notify/Send was given; what the mailer answered is in detail.',
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Mail a person one link. Answers the refusal when it did not go, and nothing when it did.
|
|
30
|
+
*
|
|
31
|
+
* The body is the sentence and the address as text; the HTML is the same with the address as a
|
|
32
|
+
* link, so a reader whose mail shows no HTML has the address to copy.
|
|
33
|
+
*/
|
|
34
|
+
export const mailLink = async (mailer, user, subject, sentence, url) => {
|
|
35
|
+
let delivery;
|
|
36
|
+
try {
|
|
37
|
+
({ delivery: { email: delivery } } = await mailer.send({
|
|
38
|
+
to: { user },
|
|
39
|
+
title: subject,
|
|
40
|
+
body: `${sentence} ${url}`,
|
|
41
|
+
html: `<p>${escape(sentence)}</p><p><a href="${escape(url)}">${escape(url)}</a></p>`,
|
|
42
|
+
channels: ['email'],
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return mailFailed(String(error?.message ?? error));
|
|
47
|
+
}
|
|
48
|
+
if (delivery === null || typeof delivery !== 'object')
|
|
49
|
+
return mailFailed('the mailer tried no email channel');
|
|
50
|
+
if ('skipped' in delivery)
|
|
51
|
+
return mailFailed(String(delivery.skipped));
|
|
52
|
+
return delivery.ok === true ? undefined : mailFailed(String(delivery.error ?? 'the mailer gave no reason'));
|
|
53
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
2
|
+
export interface Check {
|
|
3
|
+
readonly public: true;
|
|
4
|
+
exists(email: string): Promise<boolean>;
|
|
5
|
+
call(args: unknown): Promise<{
|
|
6
|
+
exists: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
declare const _default: (props: ModuleProps) => Check;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// auth/Check: does anyone have this email. Public, so a sign-in form can ask before it asks
|
|
2
|
+
// for a password.
|
|
3
|
+
import { codecError } from '@aweftjs/codec';
|
|
4
|
+
import { storeOf } from "../props.js";
|
|
5
|
+
import { findUser, looksLikeEmail, normalEmail } from "../users.js";
|
|
6
|
+
export default (props) => {
|
|
7
|
+
const store = storeOf(props);
|
|
8
|
+
const exists = async (email) => looksLikeEmail(normalEmail(email)) && (await findUser(store, email)) !== undefined;
|
|
9
|
+
return {
|
|
10
|
+
public: true,
|
|
11
|
+
exists,
|
|
12
|
+
call: async (args) => {
|
|
13
|
+
const email = args?.email;
|
|
14
|
+
if (typeof email !== 'string') {
|
|
15
|
+
throw codecError('malformed', 'auth/Check was called without an email string', 'Call it as { email: "someone@example.com" }.');
|
|
16
|
+
}
|
|
17
|
+
return { exists: await exists(email) };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
};
|