@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +548 -0
  3. package/dist/auth-client.d.ts +248 -0
  4. package/dist/auth-client.js +287 -0
  5. package/dist/client-modules/Reset.d.ts +12 -0
  6. package/dist/client-modules/Reset.js +61 -0
  7. package/dist/client-modules/Session.d.ts +6 -0
  8. package/dist/client-modules/Session.js +59 -0
  9. package/dist/client-modules/SignIn.d.ts +13 -0
  10. package/dist/client-modules/SignIn.js +58 -0
  11. package/dist/client-modules/Verify.d.ts +12 -0
  12. package/dist/client-modules/Verify.js +54 -0
  13. package/dist/client-modules/stage-token.d.ts +3 -0
  14. package/dist/client-modules/stage-token.js +9 -0
  15. package/dist/client.d.ts +27 -0
  16. package/dist/client.js +33 -0
  17. package/dist/context.d.ts +11 -0
  18. package/dist/context.js +11 -0
  19. package/dist/cookie.d.ts +15 -0
  20. package/dist/cookie.js +39 -0
  21. package/dist/index.d.ts +35 -0
  22. package/dist/index.js +42 -0
  23. package/dist/links.d.ts +37 -0
  24. package/dist/links.js +77 -0
  25. package/dist/mail.d.ts +55 -0
  26. package/dist/mail.js +53 -0
  27. package/dist/modules/Check.d.ts +10 -0
  28. package/dist/modules/Check.js +20 -0
  29. package/dist/modules/Enter.d.ts +57 -0
  30. package/dist/modules/Enter.js +143 -0
  31. package/dist/modules/Gate.d.ts +6 -0
  32. package/dist/modules/Gate.js +43 -0
  33. package/dist/modules/Password.d.ts +34 -0
  34. package/dist/modules/Password.js +138 -0
  35. package/dist/modules/Roles.d.ts +32 -0
  36. package/dist/modules/Roles.js +135 -0
  37. package/dist/modules/Session.d.ts +39 -0
  38. package/dist/modules/Session.js +159 -0
  39. package/dist/modules/State.d.ts +8 -0
  40. package/dist/modules/State.js +22 -0
  41. package/dist/modules/Verify.d.ts +32 -0
  42. package/dist/modules/Verify.js +97 -0
  43. package/dist/names.d.ts +25 -0
  44. package/dist/names.js +44 -0
  45. package/dist/password.d.ts +4 -0
  46. package/dist/password.js +41 -0
  47. package/dist/props.d.ts +15 -0
  48. package/dist/props.js +35 -0
  49. package/dist/token.d.ts +3 -0
  50. package/dist/token.js +11 -0
  51. package/dist/users.d.ts +9 -0
  52. package/dist/users.js +12 -0
  53. package/errors.txt +29 -0
  54. package/package.json +62 -0
  55. package/src/auth-client.ts +531 -0
  56. package/src/client-modules/Reset.tsx +97 -0
  57. package/src/client-modules/Session.ts +70 -0
  58. package/src/client-modules/SignIn.tsx +110 -0
  59. package/src/client-modules/Verify.tsx +82 -0
  60. package/src/client-modules/stage-token.ts +11 -0
  61. package/src/client.ts +38 -0
  62. package/src/context.ts +21 -0
  63. package/src/cookie.ts +35 -0
  64. package/src/index.ts +53 -0
  65. package/src/links.ts +107 -0
  66. package/src/mail.ts +88 -0
  67. package/src/modules/Check.ts +31 -0
  68. package/src/modules/Enter.ts +189 -0
  69. package/src/modules/Gate.ts +47 -0
  70. package/src/modules/Password.ts +155 -0
  71. package/src/modules/Roles.ts +163 -0
  72. package/src/modules/Session.ts +196 -0
  73. package/src/modules/State.ts +30 -0
  74. package/src/modules/Verify.ts +118 -0
  75. package/src/names.ts +49 -0
  76. package/src/password.ts +43 -0
  77. package/src/props.ts +47 -0
  78. package/src/token.ts +15 -0
  79. package/src/users.ts +18 -0
  80. package/surface.txt +19 -0
  81. package/text.json +50 -0
@@ -0,0 +1,248 @@
1
+ import type { Client, Handle } from '@aweftjs/client';
2
+ import { type Derived } from '@aweftjs/core';
3
+ import type { Entered } from './modules/Enter.ts';
4
+ export type { Entered } from './modules/Enter.ts';
5
+ /** What the mail and password calls answer: done, or the route's reasons. */
6
+ export type Outcome = {
7
+ readonly ok: true;
8
+ } | {
9
+ readonly refused: Refusals;
10
+ };
11
+ /** What a refusal from the sign-in route carries, taken from the module's own answer shape. */
12
+ type Refusals = Extract<Entered, {
13
+ readonly refused: unknown;
14
+ }>['refused'];
15
+ /** What `fetch` is given, stated here so this declaration names no DOM type. */
16
+ export interface FetchInit {
17
+ method: string;
18
+ headers: Record<string, string>;
19
+ body?: string;
20
+ credentials: 'same-origin';
21
+ }
22
+ /** What `fetch` answers, stated here for the same reason. */
23
+ export interface FetchResponse {
24
+ status: number;
25
+ ok: boolean;
26
+ json(): Promise<unknown>;
27
+ }
28
+ /** The two HTTP calls this half makes. The global `fetch` is one of these already. */
29
+ export type Fetcher = (url: string, init: FetchInit) => Promise<FetchResponse>;
30
+ /** What the client half may be told about how it runs. Both fields have a default in a page. */
31
+ export interface AuthOptions {
32
+ /** Where the session routes are. Defaults to the page's own origin. */
33
+ readonly origin?: string | undefined;
34
+ /** Makes the two HTTP calls. Defaults to the global `fetch`. */
35
+ readonly fetch?: Fetcher | undefined;
36
+ }
37
+ /** Identity over one connection: who the page is, what they hold, and how it signs in and out. */
38
+ export interface Auth {
39
+ /**
40
+ * Who the connection is, as a read-only cell: `undefined` until the server has answered,
41
+ * `null` for an anonymous connection, the user's id otherwise. Writing it throws `read-only`.
42
+ */
43
+ readonly user: Derived<string | null | undefined>;
44
+ /**
45
+ * The names the person was granted, as a read-only cell: `undefined` until the server has
46
+ * answered, `[]` for an anonymous connection, the granted list otherwise, following the
47
+ * server while the socket is open, so a grant made on the server reaches the page with no
48
+ * reconnect (design 289). For showing and hiding: the server's gate is what refuses.
49
+ */
50
+ readonly names: Derived<readonly string[] | undefined>;
51
+ /**
52
+ * Does the person hold a name, by the same rule the server's gate applies: granted, covered
53
+ * by a granted name (`products` covers `products.abc123.read`, `*` covers everything), or
54
+ * implied by one through the table the server answered.
55
+ *
56
+ * Params:
57
+ * name: the name asked about
58
+ *
59
+ * Returns: false until `names` has been answered, then the answer.
60
+ *
61
+ * Example:
62
+ * const canDelete = auth.names.map(() => auth.may('posts.delete'));
63
+ */
64
+ may(name: string): boolean;
65
+ /**
66
+ * Sign in, or sign up when nobody has the email.
67
+ *
68
+ * Params:
69
+ * email: the address to sign in as
70
+ * password: their password
71
+ * extra: fields the server's sign-up rule reads (an invite token, a role picked on the
72
+ * form), sent beside the two; a sign-in carries them too and the server ignores them
73
+ *
74
+ * Returns: `{ user, created }` once `user` reads the new id. The client reconnects first,
75
+ * because a cookie cannot be set on an open socket and identity is fixed per connection.
76
+ * A wrong password, a malformed address, or a sign-up the server's rule closed the door
77
+ * to resolves with `{ refused }` and reconnects nothing.
78
+ *
79
+ * Rejects with `enter-failed` for any other status, and with whatever `fetch` threw. On a
80
+ * stopped auth it rejects `stopped` before the route is called at all, and with `closed` when
81
+ * the route answered but the client was closed, so no socket can carry the new identity.
82
+ *
83
+ * Example:
84
+ * const outcome = await auth.enter('ada@example.com', 'correct horse battery staple');
85
+ * if ('refused' in outcome) show(outcome.refused);
86
+ */
87
+ enter(email: string, password: string, extra?: Readonly<Record<string, unknown>>): Promise<Entered>;
88
+ /**
89
+ * Sign out.
90
+ *
91
+ * Params: none.
92
+ *
93
+ * Returns: nothing, once `user` reads `null` again. The state handle is stopped and the
94
+ * client reconnects, so the next connection is anonymous.
95
+ *
96
+ * Rejects with `leave-failed` when the route answers anything but `ok`, with `stopped` on a
97
+ * stopped auth before the route is called, and with `closed` when the route answered but the
98
+ * client was closed.
99
+ *
100
+ * Example:
101
+ * await auth.leave();
102
+ */
103
+ leave(): Promise<void>;
104
+ /**
105
+ * Share the signed-in user's own state document.
106
+ *
107
+ * Params: none. `T` is the shape the application keeps in it.
108
+ *
109
+ * Returns: a handle over the user's `state` document, `document`, `ready` and `stop()` as
110
+ * `client.share` answers them. On an anonymous connection its `ready` rejects `anonymous`
111
+ * at once rather than waiting for a topic the server will never offer; before the server
112
+ * has answered, it waits and then does one or the other.
113
+ *
114
+ * One connection carries one state document, so asking twice hands back the same handle,
115
+ * stopped or not: the server offers the topic once per socket. `enter` and `leave` stop it
116
+ * and open a socket, and the call after either gives a new handle, because another user's
117
+ * state is another document.
118
+ *
119
+ * When `user` changes underneath the page (the server forgot the session, or another user's
120
+ * cookie replaced it, and the client came back on its own), the handle the page holds is
121
+ * stopped and follows the server no further. The next call gives a handle for whoever the
122
+ * connection is now, and a page follows `user` to notice. After `stop()` this refuses
123
+ * `stopped`.
124
+ *
125
+ * Example:
126
+ * const state = await auth.state<State>().ready;
127
+ * state.theme = 'dark';
128
+ */
129
+ state<T extends object>(): Handle<T>;
130
+ /**
131
+ * Does anyone have this email, so a form can ask before it asks for a password.
132
+ *
133
+ * Params:
134
+ * email: the address to look for
135
+ *
136
+ * Returns: whether an account has it. On a stopped auth it rejects `stopped` instead.
137
+ *
138
+ * Example:
139
+ * const known = await auth.check('ada@example.com');
140
+ */
141
+ check(email: string): Promise<boolean>;
142
+ /**
143
+ * Ask for a verification mail, or take the link from one.
144
+ *
145
+ * Params:
146
+ * token: the token from the link; with none, a mail is sent to the signed-in user
147
+ *
148
+ * Returns: `{ ok: true }`, or `{ refused }` with the route's reasons: `private` when nobody
149
+ * is signed in, `verified` when the email already is, `attempts` when too many were asked
150
+ * for, `mail` when the mailer did not take it, `token` when the link is not one that can be
151
+ * used, `taken` when it already was. Nothing reconnects: the name `verified` reaches `names`
152
+ * through the share.
153
+ *
154
+ * Rejects with `verify-failed` for any other status, with `stopped` on a stopped auth.
155
+ *
156
+ * Example:
157
+ * await auth.verify(); // the mail goes out
158
+ * await auth.verify(stage.query.get().token); // the link was opened
159
+ */
160
+ verify(token?: string): Promise<Outcome>;
161
+ /**
162
+ * Change the signed-in user's password.
163
+ *
164
+ * Params:
165
+ * current: the password they have
166
+ * password: the one they want
167
+ *
168
+ * Returns: `{ ok: true }` once every other session of theirs is ended, this one kept; or
169
+ * `{ refused }`: `password` for a wrong current one or a new one the rules refuse,
170
+ * `attempts` for too many tries, `private` when nobody is signed in.
171
+ *
172
+ * Rejects with `change-failed` for any other status, with `stopped` on a stopped auth.
173
+ *
174
+ * Example:
175
+ * const outcome = await auth.change(current.get(), next.get());
176
+ */
177
+ change(current: string, password: string): Promise<Outcome>;
178
+ /**
179
+ * Ask for a reset mail.
180
+ *
181
+ * Params:
182
+ * email: the address; an address nobody has gets the same `{ ok: true }` and no mail
183
+ *
184
+ * Returns: `{ ok: true }`, or `{ refused }`: `email` for text that is not an address,
185
+ * `attempts` for too many asks, `mail` when the mailer did not take it.
186
+ *
187
+ * Rejects with `forgot-failed` for any other status, with `stopped` on a stopped auth.
188
+ *
189
+ * Example:
190
+ * await auth.forgot('ada@example.com');
191
+ */
192
+ forgot(email: string): Promise<Outcome>;
193
+ /**
194
+ * Set a new password from a reset link.
195
+ *
196
+ * Params:
197
+ * token: the token from the link
198
+ * password: the new password
199
+ *
200
+ * Returns: `{ ok: true }` once every session of the person is ended and the client has
201
+ * reconnected, so a page that was signed in as them reads `user` as `null`; or `{ refused }`:
202
+ * `token` for a link that is not live, `taken` for one already used, `password` for one the
203
+ * rules refuse.
204
+ *
205
+ * Rejects with `reset-failed` for any other status, with `stopped` on a stopped auth, and
206
+ * with `closed` when the route answered but the client was closed.
207
+ *
208
+ * Example:
209
+ * const outcome = await auth.reset(stage.query.get().token, password.get());
210
+ */
211
+ reset(token: string, password: string): Promise<Outcome>;
212
+ /**
213
+ * Stop following the connection.
214
+ *
215
+ * Params: none.
216
+ *
217
+ * Returns: nothing. The status watcher goes and the current state handle stops; the client
218
+ * is left open, because it is not this half's to close. Every method after this refuses with
219
+ * `stopped`. Calling it twice is not an error.
220
+ *
221
+ * Example:
222
+ * auth.stop();
223
+ */
224
+ stop(): void;
225
+ }
226
+ /**
227
+ * Add identity to a connection.
228
+ *
229
+ * Params:
230
+ * client: the connection, from `createClient`. This never opens or closes the connection
231
+ * for good; after sign-in and sign-out it asks the client to reconnect, because
232
+ * identity is fixed per socket
233
+ * options.origin: where the session routes are; the page's own origin by default
234
+ * options.fetch: makes the two HTTP calls; the global `fetch` by default
235
+ *
236
+ * Returns: `user`, `enter`, `leave`, `state`, `check` and `stop`. `user` reads `undefined`
237
+ * until the first socket answers, and is asked again on every socket that opens.
238
+ *
239
+ * `enter`, `leave` and every other call that sends over HTTP rejects with `no-origin` when no
240
+ * `origin` was given and there is no page to read one from. Making the auth is safe anywhere.
241
+ *
242
+ * Example:
243
+ * const client = createClient();
244
+ * const auth = createAuth(client);
245
+ * auth.user.effect((who) => header.textContent = who ?? 'signed out');
246
+ * await auth.enter('ada@example.com', 'correct horse battery staple');
247
+ */
248
+ export declare const createAuth: (client: Client, options?: AuthOptions) => Auth;
@@ -0,0 +1,287 @@
1
+ // The browser half of the battery: who the page is, over a connection it already has
2
+ // (design 185).
3
+ //
4
+ // Every import here is a type or one of the four values `core` and `codec` hand out, so a page
5
+ // bundle that reaches for it carries no server module, no store and no Node module. Identity is
6
+ // fixed for a connection's life, so this asks once per socket and reconnects whenever the cookie
7
+ // changes underneath it.
8
+ import { codecError } from '@aweftjs/codec';
9
+ import { immutable, mutable, observer } from '@aweftjs/core';
10
+ import { holds, isName } from "./names.js";
11
+ const NO_ORIGIN_FIX = 'Pass origin to createAuth; outside a page there is no origin to read one from.';
12
+ const ANONYMOUS_FIX = 'Wait for user to read a string, or call enter first; an anonymous connection has no state.';
13
+ const ENTER_FIX = 'Check the server is running and that auth/Enter is loaded, then try again.';
14
+ const LEAVE_FIX = 'Check the server is running and that auth/Session is loaded, then try again.';
15
+ const STOPPED_FIX = 'Make a new auth with createAuth; a stopped one follows no connection.';
16
+ const CLIENT_CLOSED_FIX = 'Make a new client with createClient, and a new auth over it.';
17
+ const VERIFY_FIX = 'Check the server is running and that auth/Verify is loaded from the mail source, then try again.';
18
+ const CHANGE_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
19
+ const FORGOT_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
20
+ const RESET_FIX = 'Check the server is running and that auth/Password is loaded from the mail source, then try again.';
21
+ const anonymous = () => codecError('anonymous', 'there is no signed-in user to share a state document for', ANONYMOUS_FIX);
22
+ const halted = (detail) => codecError('stopped', detail, STOPPED_FIX);
23
+ /** The page's own origin, the one address this half will take without being told. */
24
+ const pageOrigin = () => {
25
+ const held = globalThis.location?.origin;
26
+ if (typeof held !== 'string' || held === '') {
27
+ throw codecError('no-origin', 'there is no page origin to take the session routes from', NO_ORIGIN_FIX);
28
+ }
29
+ return held;
30
+ };
31
+ const globalFetch = (url, init) => globalThis.fetch(url, init);
32
+ /**
33
+ * Add identity to a connection.
34
+ *
35
+ * Params:
36
+ * client: the connection, from `createClient`. This never opens or closes the connection
37
+ * for good; after sign-in and sign-out it asks the client to reconnect, because
38
+ * identity is fixed per socket
39
+ * options.origin: where the session routes are; the page's own origin by default
40
+ * options.fetch: makes the two HTTP calls; the global `fetch` by default
41
+ *
42
+ * Returns: `user`, `enter`, `leave`, `state`, `check` and `stop`. `user` reads `undefined`
43
+ * until the first socket answers, and is asked again on every socket that opens.
44
+ *
45
+ * `enter`, `leave` and every other call that sends over HTTP rejects with `no-origin` when no
46
+ * `origin` was given and there is no page to read one from. Making the auth is safe anywhere.
47
+ *
48
+ * Example:
49
+ * const client = createClient();
50
+ * const auth = createAuth(client);
51
+ * auth.user.effect((who) => header.textContent = who ?? 'signed out');
52
+ * await auth.enter('ada@example.com', 'correct horse battery staple');
53
+ */
54
+ export const createAuth = (client, options = {}) => {
55
+ const send = options.fetch ?? globalFetch;
56
+ // Read when a route is called, not when the auth is made: a module that holds one is built by
57
+ // a static render as well as by a page, and only a page has an origin (design 245).
58
+ const routeUrl = (path = '/api/session') => `${options.origin ?? pageOrigin()}${path}`;
59
+ const identity = mutable(undefined);
60
+ const names = mutable(undefined);
61
+ let implies = {};
62
+ /** The roles share of the socket that is open, and how to stop following it. */
63
+ let roles;
64
+ let stopped = false;
65
+ let held;
66
+ /** Who the held handle was made for, so an identity that changes underneath it is visible. */
67
+ let heldFor;
68
+ const stopRoles = () => {
69
+ roles?.off?.();
70
+ roles?.handle.stop();
71
+ roles = undefined;
72
+ };
73
+ // The names follow the shared document: read once it is here, and again on every commit
74
+ // the server makes to it, so a grant reaches the page while the socket is open.
75
+ const followRoles = (who) => {
76
+ stopRoles();
77
+ if (who === null) {
78
+ implies = {};
79
+ names.set([]);
80
+ return;
81
+ }
82
+ const handle = client.share('roles');
83
+ const mine = { handle };
84
+ roles = mine;
85
+ void Promise.all([handle.ready, client.ask('auth/Roles')]).then(([doc, answer]) => {
86
+ if (roles !== mine)
87
+ return;
88
+ const table = answer?.implies;
89
+ implies = table !== null && typeof table === 'object' ? table : {};
90
+ const read = () => [...(doc.names ?? [])];
91
+ names.set(read());
92
+ mine.off = observer(doc).skip(Infinity).watch(() => { names.set(read()); });
93
+ }, () => { });
94
+ };
95
+ // Identity is fixed at the handshake, so every socket is a new answer and the old one is
96
+ // worth nothing. An ask that rejects took the socket with it; the next one asks again.
97
+ const refresh = () => {
98
+ client.ask('auth/Session').then((answer) => {
99
+ if (stopped)
100
+ return;
101
+ const who = answer?.user;
102
+ const next = typeof who === 'string' ? who : null;
103
+ identity.set(next);
104
+ followRoles(next);
105
+ // The server forgot the session, or another user's cookie replaced it. The handle the
106
+ // page holds is the old user's document and every later socket would re-share it, so
107
+ // it stops here and the next `state()` answers for whoever this is now.
108
+ if (held !== undefined && heldFor !== next)
109
+ stopState();
110
+ }, () => { });
111
+ };
112
+ const release = client.status.watch((now) => {
113
+ if (now === 'open')
114
+ refresh();
115
+ });
116
+ if (client.status.get() === 'open')
117
+ refresh();
118
+ /** The next value `user` takes that is not `undefined`, which is what a reconnect settles to. */
119
+ const answered = () => {
120
+ let off;
121
+ const known = new Promise((done) => {
122
+ off = identity.watch((who) => {
123
+ if (who === undefined)
124
+ return;
125
+ off?.();
126
+ off = undefined;
127
+ done(who);
128
+ });
129
+ });
130
+ return { known, cancel: () => { off?.(); off = undefined; } };
131
+ };
132
+ const stopState = () => {
133
+ held?.stop();
134
+ held = undefined;
135
+ heldFor = undefined;
136
+ };
137
+ const keep = (handle, who) => {
138
+ held = handle;
139
+ heldFor = who;
140
+ return handle;
141
+ };
142
+ // The one shape every route answer takes: the reasons for the statuses that are a refusal,
143
+ // the body for a success, and a rejection naming the call for anything else.
144
+ const post = async (reason, fix, what, path, body, refusals, method = 'POST') => {
145
+ if (stopped)
146
+ throw halted(`the auth is stopped and no ${what} was sent`);
147
+ const route = routeUrl(path);
148
+ const answer = await send(route, {
149
+ method,
150
+ headers: body === undefined ? {} : { 'content-type': 'application/json' },
151
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
152
+ credentials: 'same-origin',
153
+ });
154
+ if (refusals.includes(answer.status)) {
155
+ const refused = (await answer.json())?.reasons;
156
+ return { refused: (Array.isArray(refused) ? refused : []) };
157
+ }
158
+ if (!answer.ok)
159
+ throw codecError(reason, `${method} ${route} answered ${String(answer.status)}`, fix);
160
+ return { ok: true, body: await answer.json() };
161
+ };
162
+ // Both routes change who the cookie says this browser is, and the socket that is open was
163
+ // identified before that. Dropping it is the whole reason these two reconnect.
164
+ const again = async () => {
165
+ stopState();
166
+ stopRoles();
167
+ identity.set(undefined);
168
+ names.set(undefined);
169
+ const next = answered();
170
+ client.reconnect();
171
+ // A live client goes to `connecting` inside `reconnect()`; a closed one does nothing at
172
+ // all, so no socket would ever carry the new identity and this would wait forever.
173
+ if (client.status.get() !== 'connecting') {
174
+ next.cancel();
175
+ throw codecError('closed', 'the session route answered but the client is closed, so no socket can carry the new identity', CLIENT_CLOSED_FIX);
176
+ }
177
+ return await next.known;
178
+ };
179
+ const outcome = ({ body: _body, ...rest }) => rest;
180
+ return {
181
+ user: immutable(identity),
182
+ names: immutable(names),
183
+ // The same answer the server's `may` gives, text that is not a name included.
184
+ may: (name) => {
185
+ const granted = names.get();
186
+ return granted !== undefined && isName(name) && holds(granted, implies, name);
187
+ },
188
+ enter: async (email, password, extra) => {
189
+ const answer = await post('enter-failed', ENTER_FIX, 'sign-in', '/api/session', { ...extra, email, password }, [400, 401, 403]);
190
+ if ('refused' in answer)
191
+ return answer;
192
+ const body = answer.body;
193
+ await again();
194
+ return { user: body.user, created: body.created };
195
+ },
196
+ leave: async () => {
197
+ await post('leave-failed', LEAVE_FIX, 'sign-out', '/api/session', undefined, [], 'DELETE');
198
+ // The connection after a sign-out carries no session, so the value it settles to is null.
199
+ await again();
200
+ },
201
+ verify: async (token) => outcome(token === undefined
202
+ ? await post('verify-failed', VERIFY_FIX, 'verification', '/api/verify/send', undefined, [401, 409, 429, 502])
203
+ : await post('verify-failed', VERIFY_FIX, 'verification', '/api/verify', { token }, [400])),
204
+ change: async (current, password) => outcome(await post('change-failed', CHANGE_FIX, 'password change', '/api/password', { current, password }, [400, 401, 429])),
205
+ forgot: async (email) => outcome(await post('forgot-failed', FORGOT_FIX, 'reset mail', '/api/password/forgot', { email }, [400, 429, 502])),
206
+ reset: async (token, password) => {
207
+ const answer = outcome(await post('reset-failed', RESET_FIX, 'reset', '/api/password/reset', { token, password }, [400]));
208
+ if ('refused' in answer)
209
+ return answer;
210
+ // Every session of the person is over, this page's included when it was theirs.
211
+ await again();
212
+ return answer;
213
+ },
214
+ state: () => {
215
+ if (stopped) {
216
+ const ready = Promise.reject(halted('the auth is stopped and follows no connection'));
217
+ ready.catch(() => { });
218
+ return { document: undefined, ready, stop: () => { } };
219
+ }
220
+ // One connection carries one state document, and the server offers the topic once.
221
+ // A second share of a name the peer has already paired waits for an offer that never
222
+ // comes, and so does a share made after `stop()`. So this hands back the same handle
223
+ // for the connection's life. `enter` and `leave` let go of it, and the socket they
224
+ // open offers the topic again.
225
+ if (held !== undefined)
226
+ return held;
227
+ const who = identity.get();
228
+ if (who === null) {
229
+ const ready = Promise.reject(anonymous());
230
+ // A page that renders `user` and never reads this promise is not killed by it,
231
+ // which is the rule `sync`'s link already keeps for a topic that ends early.
232
+ ready.catch(() => { });
233
+ return keep({ document: undefined, ready, stop: () => { } }, null);
234
+ }
235
+ if (who !== undefined)
236
+ return keep(client.share('state'), who);
237
+ // Nobody has answered yet. The handle exists now because the page asked for it now,
238
+ // and it stands in for the share the first answer either makes or refuses.
239
+ let inner;
240
+ let off;
241
+ const ready = new Promise((settle, fail) => {
242
+ off = identity.watch((now) => {
243
+ if (now === undefined)
244
+ return;
245
+ off?.();
246
+ off = undefined;
247
+ // The first answer is who this handle is for, and `refresh` reads it back to
248
+ // tell an identity that changed later from this one arriving.
249
+ if (held === waited)
250
+ heldFor = now;
251
+ if (now === null) {
252
+ fail(anonymous());
253
+ return;
254
+ }
255
+ inner = client.share('state');
256
+ inner.ready.then(settle, fail);
257
+ });
258
+ });
259
+ ready.catch(() => { });
260
+ const waited = {
261
+ get document() {
262
+ return inner?.document;
263
+ },
264
+ ready,
265
+ stop: () => {
266
+ off?.();
267
+ off = undefined;
268
+ inner?.stop();
269
+ },
270
+ };
271
+ return keep(waited, undefined);
272
+ },
273
+ check: async (email) => {
274
+ if (stopped)
275
+ throw halted('the auth is stopped and no lookup was sent');
276
+ return (await client.ask('auth/Check', { email })).exists;
277
+ },
278
+ stop: () => {
279
+ if (stopped)
280
+ return;
281
+ stopped = true;
282
+ release();
283
+ stopState();
284
+ stopRoles();
285
+ },
286
+ };
287
+ };
@@ -0,0 +1,12 @@
1
+ import type { StageValue } from '@aweftjs/ui';
2
+ export declare const deps: string[];
3
+ interface ResetProps {
4
+ readonly stage?: StageValue;
5
+ }
6
+ declare const _default: ({ imports }: {
7
+ imports: Readonly<Record<string, unknown>>;
8
+ }) => {
9
+ title: unknown;
10
+ component: (props: ResetProps) => unknown;
11
+ };
12
+ export default _default;
@@ -0,0 +1,61 @@
1
+ import { template as _template } from '@aweftjs/ui';
2
+ const _t0 = _template(["form", null, ["p", null], ["p", null]], [["props", []], ["child", [], 0], ["child", [0], -1], ["props", [1]], ["child", [1], -1], ["child", [], -1]]);
3
+ // auth/Reset: the forgot form, and the new-password form a reset link opens (design 290).
4
+ //
5
+ // Without a token in the act's parameters or the URL's query it asks for an address and mails
6
+ // the link; with one it asks for the new password and sets it. It picks no URL: the application
7
+ // names it in the acts map, and points the mail at that address.
8
+ import { mutable } from '@aweftjs/core';
9
+ import { Button, TextField, h, text } from '@aweftjs/ui';
10
+ import { tokenOf } from "./stage-token.js";
11
+ export const deps = ['auth/Session'];
12
+ const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
13
+ const about = (reasons, code) => reasons.filter((one) => one.code === code).map((one) => one.message).join(' ');
14
+ const rest = (reasons, codes) => reasons.filter((one) => !codes.includes(one.code)).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 email = mutable('');
20
+ const password = mutable('');
21
+ const fieldProblem = mutable('');
22
+ const problem = mutable('');
23
+ const note = mutable('');
24
+ const busy = mutable(false);
25
+ const done = mutable(false);
26
+ const run = async (call, field, onOk) => {
27
+ if (busy.get())
28
+ return;
29
+ busy.set(true);
30
+ fieldProblem.set('');
31
+ problem.set('');
32
+ try {
33
+ const answer = await call();
34
+ if ('refused' in answer) {
35
+ fieldProblem.set(about(answer.refused, field));
36
+ problem.set(rest(answer.refused, [field]));
37
+ }
38
+ else {
39
+ note.set(onOk);
40
+ done.set(true);
41
+ }
42
+ }
43
+ catch (error) {
44
+ problem.set(String(error?.message ?? error));
45
+ }
46
+ finally {
47
+ busy.set(false);
48
+ }
49
+ };
50
+ const submit = () => token === undefined
51
+ ? run(() => session.forgot(email.get()), 'email', text('If that address has an account, a link is on its way.'))
52
+ : run(() => session.reset(token, password.get()), 'password', text('Your password is set. Sign in with it.'));
53
+ return (_t0([{ "aria-label": text('Reset your password'), style: LAYOUT, onSubmit: (event) => {
54
+ event.preventDefault();
55
+ void submit();
56
+ } }, token === undefined
57
+ ? h(TextField, { label: text('Email'), value: email, error: fieldProblem, placeholder: text('you@example.com'), name: "email", autocomplete: "email" })
58
+ : h(TextField, { label: text('New password'), value: password, error: fieldProblem, password: true, name: "password", autocomplete: "new-password" }), note, { role: "alert" }, problem, h(Button, { label: token === undefined ? text('Send the link') : text('Set the password'), loading: busy, disabled: done, onClick: submit })]));
59
+ };
60
+ return { title: text('Reset your password'), component };
61
+ };
@@ -0,0 +1,6 @@
1
+ import { type Auth } from '../auth-client.ts';
2
+ declare const _default: ({ client, config }: {
3
+ client?: unknown;
4
+ config: Readonly<Record<string, unknown>>;
5
+ }) => Auth;
6
+ export default _default;
@@ -0,0 +1,59 @@
1
+ // auth/Session on the page: `createAuth` over the connection the stage handed in (design 245).
2
+ import { codecError } from '@aweftjs/codec';
3
+ import { immutable, mutable } from '@aweftjs/core';
4
+ import { createAuth } from "../auth-client.js";
5
+ const NO_CLIENT_FIX = 'Pass the client createClient answered as the StageContext client, or none at all.';
6
+ const ANONYMOUS_FIX = 'Wait for user to read a string, or call enter first; an anonymous connection has no state.';
7
+ const isClient = (value) => {
8
+ const held = value;
9
+ return held !== null && typeof held === 'object'
10
+ && typeof held.ask === 'function' && typeof held.share === 'function'
11
+ && typeof held.status === 'object' && held.status !== null;
12
+ };
13
+ const noClient = (what) => codecError('no-client', `auth/Session has no connection, so ${what}`, NO_CLIENT_FIX);
14
+ /**
15
+ * Identity with no connection at all: anonymous, at once and for good (design 245).
16
+ *
17
+ * What a static render gets, because `render` has no socket to give. `user` reads `null` from the
18
+ * start, which is a known answer rather than a wait: a factory that awaits identity would
19
+ * otherwise never return, and `render` waits on every pending promise, so the whole render hung.
20
+ * A gate refuses at once instead, and the sign-in act is what a static render of a gated page
21
+ * holds.
22
+ */
23
+ const anonymous = () => {
24
+ const who = immutable(mutable(null));
25
+ const nothing = immutable(mutable([]));
26
+ return {
27
+ user: who,
28
+ names: nothing,
29
+ may: () => false,
30
+ enter: async () => { throw noClient('no sign-in was sent'); },
31
+ leave: async () => { throw noClient('no sign-out was sent'); },
32
+ check: async () => { throw noClient('no lookup was sent'); },
33
+ verify: async () => { throw noClient('no verification was sent'); },
34
+ change: async () => { throw noClient('no password change was sent'); },
35
+ forgot: async () => { throw noClient('no reset mail was sent'); },
36
+ reset: async () => { throw noClient('no reset was sent'); },
37
+ // The same refusal the real one gives an anonymous connection, so a page that reads it
38
+ // takes one path rather than two.
39
+ state: () => {
40
+ const ready = Promise.reject(codecError('anonymous', 'there is no signed-in user to share a state document for', ANONYMOUS_FIX));
41
+ ready.catch(() => { });
42
+ return { document: undefined, ready, stop: () => { } };
43
+ },
44
+ stop: () => { },
45
+ };
46
+ };
47
+ export default ({ client, config }) => {
48
+ if (client === undefined)
49
+ return anonymous();
50
+ if (!isClient(client)) {
51
+ throw codecError('no-client', 'auth/Session was handed a client with no status, ask or share on it', NO_CLIENT_FIX);
52
+ }
53
+ const origin = config['origin'];
54
+ const fetch = config['fetch'];
55
+ return createAuth(client, {
56
+ ...(typeof origin === 'string' ? { origin } : {}),
57
+ ...(typeof fetch === 'function' ? { fetch: fetch } : {}),
58
+ });
59
+ };