@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,189 @@
1
+ // auth/Enter: sign in, or sign up when the email is new, and hand the browser its cookie
2
+ // (design 074). The route counts attempts, bounds the hashing in flight and the password's
3
+ // length before anything is hashed (design 275), and asks the application's sign-up rule at
4
+ // the door (design 291).
5
+
6
+ import { createId, idToText } from '@aweftjs/codec';
7
+ import { atomic } from '@aweftjs/core';
8
+ import type { ModuleProps } from '@aweftjs/modules';
9
+ import { type Refusal, sliding } from '@aweftjs/server';
10
+ import type { Store } from '@aweftjs/store';
11
+
12
+ import { type AuthContext, addressOf } from '../context.ts';
13
+ import { hashPassword, verifyPassword } from '../password.ts';
14
+ import { bodyOf, invalidConfig, json, numberOf, storeOf } from '../props.ts';
15
+ import { findUser, idOfUserDoc, looksLikeEmail, normalEmail, userDoc } from '../users.ts';
16
+ import type { Roles } from './Roles.ts';
17
+ import type { Session } from './Session.ts';
18
+
19
+ export const deps = ['auth/Session', 'auth/Roles'];
20
+
21
+ export const defaults = {
22
+ attemptsPerEmail: 5,
23
+ attemptsPerAddress: 20,
24
+ attemptsWindowMs: 900_000,
25
+ hashesInFlight: 8,
26
+ passwordMin: 8,
27
+ passwordMax: 256,
28
+ refusePassword: null,
29
+ refuseSignUp: null,
30
+ };
31
+
32
+ /** What `refuseSignUp` is asked about: a sign-up the route is about to make (design 291). */
33
+ export interface SignUp {
34
+ /** The address as it will be stored, normalised; nobody holds it yet. */
35
+ readonly email: string;
36
+ /** Every field of the request body but `email` and `password`: an invite token, a role picked on the form. */
37
+ readonly extra: Readonly<Record<string, unknown>>;
38
+ /** What the gate identified for the request: `user` is null, `address` is the peer's. */
39
+ readonly context: AuthContext;
40
+ /** The store the battery writes the user into, for a rule that reads a document of yours. */
41
+ readonly store: Store;
42
+ }
43
+
44
+ /** The application's sign-up rule: a refusal closes the door to that sign-up, nothing opens it. */
45
+ export type RefuseSignUp = (signUp: SignUp) => Refusal | undefined | Promise<Refusal | undefined>;
46
+
47
+ /** What a `user:<id>` document holds. `password` is the hash, never the password. */
48
+ export interface UserDocument extends Record<string, unknown> {
49
+ email: string;
50
+ name: string | null;
51
+ password: string;
52
+ emailVerified: boolean;
53
+ createdAt: number;
54
+ modifiedAt: number;
55
+ }
56
+
57
+ export type Entered = { readonly user: string; readonly created: boolean } | { readonly refused: readonly Refusal[] };
58
+
59
+ export interface Enter {
60
+ readonly public: true;
61
+ /** Sign in, or sign up when nobody has the email. Refuses a wrong password. */
62
+ enter(email: string, password: string): Promise<Entered>;
63
+ /**
64
+ * The reasons a password is refused: not text, outside the configured length, or refused by
65
+ * `refusePassword`. Empty when it is taken. What the sign-in route checks, for a route that
66
+ * sets a password elsewhere (design 290).
67
+ */
68
+ checkPassword(password: unknown): Promise<readonly Refusal[]>;
69
+ readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
70
+ }
71
+
72
+ const MODULE = 'auth/Enter';
73
+ const refuse = (detail: string, fix: string): Error => invalidConfig(MODULE, detail, fix);
74
+
75
+ export default ({ imports, config, ...props }: ModuleProps): Enter => {
76
+ const store = storeOf(props);
77
+ const Session = imports.Session as Session;
78
+ const Roles = imports.Roles as Roles;
79
+ const windowMs = numberOf(MODULE, config, 'attemptsWindowMs');
80
+ const perEmail = sliding({ count: numberOf(MODULE, config, 'attemptsPerEmail'), windowMs });
81
+ const perAddress = sliding({ count: numberOf(MODULE, config, 'attemptsPerAddress'), windowMs });
82
+ const hashesInFlight = numberOf(MODULE, config, 'hashesInFlight');
83
+ const passwordMin = numberOf(MODULE, config, 'passwordMin');
84
+ const passwordMax = numberOf(MODULE, config, 'passwordMax');
85
+ if (passwordMax < passwordMin) throw refuse(`passwordMax ${String(passwordMax)} under passwordMin ${String(passwordMin)}`, 'Give passwordMax at least passwordMin.');
86
+ if (config.refusePassword !== null && typeof config.refusePassword !== 'function') {
87
+ throw refuse(`refusePassword ${JSON.stringify(config.refusePassword)}`, 'Give refusePassword a function of the password answering true to refuse it, or null.');
88
+ }
89
+ const refusePassword = config.refusePassword as ((password: string) => boolean | Promise<boolean>) | null;
90
+ if (config.refuseSignUp !== null && typeof config.refuseSignUp !== 'function') {
91
+ throw refuse(`refuseSignUp ${JSON.stringify(config.refuseSignUp)}`, 'Give refuseSignUp a function of the sign-up answering a refusal to refuse it and nothing to allow it, or null.');
92
+ }
93
+ const refuseSignUp = config.refuseSignUp as RefuseSignUp | null;
94
+ let hashing = 0;
95
+
96
+ // The two checks that cost nothing, before anything is counted or hashed.
97
+ const shapeOf = (password: unknown): Refusal | undefined => {
98
+ if (typeof password !== 'string' || password === '') return { code: 'password', message: 'password is text' };
99
+ const length = [...password].length;
100
+ if (length < passwordMin || length > passwordMax) {
101
+ return { code: 'password', message: `password is ${String(passwordMin)} to ${String(passwordMax)} characters` };
102
+ }
103
+ return undefined;
104
+ };
105
+
106
+ const notAllowed = (): Refusal => ({ code: 'password', message: 'that password is not allowed here' });
107
+
108
+ const checkPassword = async (password: unknown): Promise<readonly Refusal[]> => {
109
+ const shape = shapeOf(password);
110
+ if (shape !== undefined) return [shape];
111
+ return refusePassword !== null && await refusePassword(password as string) ? [notAllowed()] : [];
112
+ };
113
+
114
+ const enter = async (email: string, password: string): Promise<Entered> => {
115
+ const found = await findUser(store, email);
116
+ if (found === undefined) {
117
+ const id = idToText(createId());
118
+ const hash = await hashPassword(password);
119
+ const handle = await store.open(userDoc(id));
120
+ const now = Date.now();
121
+ atomic(() => {
122
+ Object.assign(handle.root, {
123
+ email: normalEmail(email), name: null, password: hash, emailVerified: false, createdAt: now, modifiedAt: now,
124
+ } satisfies UserDocument);
125
+ });
126
+ await store.settled(handle);
127
+ await store.close(handle);
128
+ await Roles.first(id);
129
+ return { user: id, created: true };
130
+ }
131
+ const handle = await store.open(found);
132
+ const ok = await verifyPassword(password, (handle.root as UserDocument).password);
133
+ await store.close(handle);
134
+ if (!ok) return { refused: [{ code: 'password', message: 'the password is wrong' }] };
135
+ return { user: idOfUserDoc(found), created: false };
136
+ };
137
+
138
+ const tooMany = (retryAfter: number): Response =>
139
+ json(429, { reasons: [{ code: 'attempts', message: 'too many sign-in attempts; wait and try again' }] }, { 'retry-after': String(retryAfter) });
140
+
141
+ return {
142
+ public: true,
143
+ enter,
144
+ checkPassword,
145
+ routes: {
146
+ 'POST /api/session': async (request, context) => {
147
+ const body = await bodyOf(request);
148
+ const email = body?.email;
149
+ const password = body?.password;
150
+ if (typeof email !== 'string' || !looksLikeEmail(normalEmail(email))) {
151
+ return json(400, { reasons: [{ code: 'email', message: 'email is an address' }] });
152
+ }
153
+ const shape = shapeOf(password);
154
+ if (shape !== undefined) return json(400, { reasons: [shape] });
155
+ // Counted before anything is hashed, so a flood buys no hashing. The email's count
156
+ // is cleared on success below; the address's is not, since it counts requests.
157
+ const key = normalEmail(email);
158
+ const byAddress = perAddress.take(addressOf(context) ?? 'unknown');
159
+ if (!byAddress.ok) return tooMany(byAddress.retryAfter);
160
+ const byEmail = perEmail.take(key);
161
+ if (!byEmail.ok) return tooMany(byEmail.retryAfter);
162
+ if (refusePassword !== null && await refusePassword(password as string)) return json(400, { reasons: [notAllowed()] });
163
+ // The application's rule, asked of a sign-up only: a known email is a sign-in and there
164
+ // is no door to close. After the password rule, so a refused password spends no invite.
165
+ if (refuseSignUp !== null && await findUser(store, key) === undefined) {
166
+ const extra = Object.fromEntries(Object.entries(body ?? {}).filter(([name]) => name !== 'email' && name !== 'password'));
167
+ const refusal = await refuseSignUp({ email: key, extra, context, store });
168
+ if (refusal !== undefined) return json(403, { reasons: [refusal] });
169
+ }
170
+ if (hashing >= hashesInFlight) {
171
+ return json(503, { reasons: [{ code: 'busy', message: 'too many sign-ins are being checked; try again in a moment' }] }, { 'retry-after': '1' });
172
+ }
173
+ hashing += 1;
174
+ let outcome: Entered;
175
+ try {
176
+ outcome = await enter(email, password as string);
177
+ } finally {
178
+ hashing -= 1;
179
+ }
180
+ if ('refused' in outcome) return json(401, { reasons: outcome.refused });
181
+ perEmail.clear(key);
182
+ const token = await Session.issue(outcome.user);
183
+ return json(outcome.created ? 201 : 200, { user: outcome.user, created: outcome.created }, {
184
+ 'set-cookie': Session.setCookie(token, request),
185
+ });
186
+ },
187
+ },
188
+ };
189
+ };
@@ -0,0 +1,47 @@
1
+ // auth/Gate: the gate that reads `public` and `needs` (designs 071, 074, 289).
2
+
3
+ import type { ModuleProps } from '@aweftjs/modules';
4
+ import type { Gate as ServerGate, Named, Refusal } from '@aweftjs/server';
5
+
6
+ import { type AuthContext, userOf } from '../context.ts';
7
+ import { isName } from '../names.ts';
8
+ import type { Roles } from './Roles.ts';
9
+ import type { Session } from './Session.ts';
10
+
11
+ export const deps = ['auth/Session', 'auth/Roles'];
12
+
13
+ const isPublic = (instance: unknown): boolean =>
14
+ instance !== null && typeof instance === 'object' && (instance as { public?: unknown }).public === true;
15
+
16
+ /**
17
+ * The names a module declares it needs: one, a list, or none. A declaration that is neither a
18
+ * name nor a list of names is `undefined`: the module is refused to everyone, because a word
19
+ * that reads as narrow and admits broadly is the wrong way to fail.
20
+ */
21
+ const needsOf = (instance: unknown): readonly string[] | undefined => {
22
+ if (instance === null || typeof instance !== 'object' || !('needs' in instance)) return [];
23
+ const held: unknown = (instance as { needs?: unknown }).needs;
24
+ if (held === undefined) return [];
25
+ if (isName(held)) return [held];
26
+ return Array.isArray(held) && held.every(isName) ? held : undefined;
27
+ };
28
+
29
+ export default ({ imports }: ModuleProps): ServerGate<AuthContext> => {
30
+ const Session = imports.Session as Session;
31
+ const Roles = imports.Roles as Roles;
32
+ return {
33
+ identify: (request, peer) => Session.whoIs(request, peer),
34
+ access: async ({ name, instance }: Named, context): Promise<Refusal[]> => {
35
+ const user = userOf(context);
36
+ const needs = needsOf(instance);
37
+ if (needs === undefined) return [{ code: 'needs', message: `${name} declares needs that is not a name or a list of names` }];
38
+ // A module that needs a name needs a person, whatever else it declares.
39
+ if (needs.length === 0 && isPublic(instance)) return [];
40
+ if (user === null) return [{ code: 'private', message: `${name} needs a signed-in user` }];
41
+ for (const wanted of needs) {
42
+ if (!(await Roles.may(user, wanted))) return [{ code: 'needs', message: `${name} needs ${wanted}` }];
43
+ }
44
+ return [];
45
+ },
46
+ };
47
+ };
@@ -0,0 +1,155 @@
1
+ // auth/Password: change with the current password, forgot by mail, reset by the link (design 290).
2
+
3
+ import { atomic } from '@aweftjs/core';
4
+ import type { ModuleProps } from '@aweftjs/modules';
5
+ import { type Refusal, sliding } from '@aweftjs/server';
6
+
7
+ import { type AuthContext, addressOf, userOf } from '../context.ts';
8
+ import { type Links, NOT_LIVE, TAKEN, links } from '../links.ts';
9
+ import { type Mailer, type Outcome, mailLink, textOf, urlOf } from '../mail.ts';
10
+ import { hashPassword, verifyPassword } from '../password.ts';
11
+ import { bodyOf, json, numberOf, storeOf } from '../props.ts';
12
+ import { findUser, idOfUserDoc, looksLikeEmail, normalEmail, userDoc } from '../users.ts';
13
+ import type { Enter, UserDocument } from './Enter.ts';
14
+ import type { Session } from './Session.ts';
15
+
16
+ export const deps = ['auth/Session', 'auth/Enter', 'notify/Send'];
17
+
18
+ export const defaults = {
19
+ subject: 'Reset your password',
20
+ url: null,
21
+ resetMs: 3_600_000,
22
+ attemptsPerUser: 5,
23
+ attemptsWindowMs: 900_000,
24
+ forgotPerEmail: 5,
25
+ forgotPerAddress: 20,
26
+ forgotWindowMs: 86_400_000,
27
+ sweepMs: 3_600_000,
28
+ };
29
+
30
+ export type Reset = { readonly user: string } | { readonly refused: readonly Refusal[] };
31
+
32
+ export interface Password {
33
+ readonly public: true;
34
+ /** Set a new password for a person who gave the current one. Every other session of theirs is ended. */
35
+ change(user: string, current: unknown, password: unknown, keep?: string): Promise<Outcome>;
36
+ /** Mail the person with this address a link, or nothing for an address nobody has; `ok` either way. */
37
+ forgot(email: string): Promise<Outcome>;
38
+ /** Take a link and set the password. Every session of the person is ended. Refuses `taken` for a link already used and `token` for one that never was or is past its end. */
39
+ reset(token: unknown, password: unknown): Promise<Reset>;
40
+ stop(): void;
41
+ readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
42
+ }
43
+
44
+ const MODULE = 'auth/Password';
45
+
46
+ const WRONG: Refusal = { code: 'password', message: 'the current password is wrong' };
47
+
48
+ export default ({ imports, config, ...props }: ModuleProps): Password => {
49
+ const store = storeOf(props);
50
+ const Session = imports.Session as Session;
51
+ const Enter = imports.Enter as Enter;
52
+ const mailer = imports.Send as Mailer;
53
+ const subject = textOf(MODULE, config, 'subject');
54
+ const url = urlOf(MODULE, config);
55
+ const attempts = sliding({ count: numberOf(MODULE, config, 'attemptsPerUser'), windowMs: numberOf(MODULE, config, 'attemptsWindowMs') });
56
+ const forgotWindowMs = numberOf(MODULE, config, 'forgotWindowMs');
57
+ const perEmail = sliding({ count: numberOf(MODULE, config, 'forgotPerEmail'), windowMs: forgotWindowMs });
58
+ const perAddress = sliding({ count: numberOf(MODULE, config, 'forgotPerAddress'), windowMs: forgotWindowMs });
59
+ const held: Links = links(store, 'reset', numberOf(MODULE, config, 'resetMs'), numberOf(MODULE, config, 'sweepMs'));
60
+
61
+ const rewrite = async (user: string, password: string): Promise<void> => {
62
+ const hash = await hashPassword(password);
63
+ const handle = await store.open(userDoc(user));
64
+ atomic(() => {
65
+ const root = handle.root as Partial<UserDocument>;
66
+ root.password = hash;
67
+ root.modifiedAt = Date.now();
68
+ });
69
+ await store.settled(handle);
70
+ await store.close(handle);
71
+ };
72
+
73
+ const change = async (user: string, current: unknown, password: unknown, keep?: string): Promise<Outcome> => {
74
+ if (await store.head(userDoc(user)) === 0) return { refused: [WRONG] };
75
+ const handle = await store.open(userDoc(user));
76
+ const ok = typeof current === 'string' && await verifyPassword(current, (handle.root as Partial<UserDocument>).password);
77
+ await store.close(handle);
78
+ if (!ok) return { refused: [WRONG] };
79
+ const refused = await Enter.checkPassword(password);
80
+ if (refused.length > 0) return { refused };
81
+ await rewrite(user, password as string);
82
+ await Session.revokeAll(user, keep);
83
+ return { ok: true };
84
+ };
85
+
86
+ const forgot = async (email: string): Promise<Outcome> => {
87
+ const found = await findUser(store, email);
88
+ if (found === undefined) return { ok: true };
89
+ const user = idOfUserDoc(found);
90
+ const token = await held.issue(user);
91
+ const failed = await mailLink(mailer, user, subject, 'Set a new password by opening this link:', url(token));
92
+ return failed === undefined ? { ok: true } : { refused: [failed] };
93
+ };
94
+
95
+ // The link is looked at before the password is checked, so a stranger's guess at a token
96
+ // costs no `refusePassword` lookup, and taken after, so a refused password burns no link.
97
+ const reset = async (token: unknown, password: unknown): Promise<Reset> => {
98
+ const seen = await held.peek(token);
99
+ if (seen === undefined || 'taken' in seen) return { refused: [seen === undefined ? NOT_LIVE : TAKEN] };
100
+ const refused = await Enter.checkPassword(password);
101
+ if (refused.length > 0) return { refused };
102
+ const link = await held.take(token);
103
+ if (link === undefined || 'taken' in link) return { refused: [link === undefined ? NOT_LIVE : TAKEN] };
104
+ const { user } = link;
105
+ await rewrite(user, password as string);
106
+ await Session.revokeAll(user);
107
+ return { user };
108
+ };
109
+
110
+ const tooMany = (retryAfter: number): Response =>
111
+ json(429, { reasons: [{ code: 'attempts', message: 'too many attempts; wait and try again' }] }, { 'retry-after': String(retryAfter) });
112
+
113
+ return {
114
+ public: true,
115
+ change,
116
+ forgot,
117
+ reset,
118
+ stop: () => { held.stop(); },
119
+ routes: {
120
+ 'POST /api/password': async (request, context) => {
121
+ const user = userOf(context);
122
+ if (user === null) return json(401, { reasons: [{ code: 'private', message: 'sign in to change your password' }] });
123
+ // The current password is a password being guessed, so it is counted like a sign-in.
124
+ const taken = attempts.take(user);
125
+ if (!taken.ok) return tooMany(taken.retryAfter);
126
+ const body = await bodyOf(request);
127
+ const outcome = await change(user, body?.current, body?.password, context.session ?? undefined);
128
+ if ('refused' in outcome) return json(outcome.refused[0] === WRONG ? 401 : 400, { reasons: outcome.refused });
129
+ attempts.clear(user);
130
+ return json(200, outcome);
131
+ },
132
+ 'POST /api/password/forgot': async (request, context) => {
133
+ const body = await bodyOf(request);
134
+ const email = body?.email;
135
+ if (typeof email !== 'string' || !looksLikeEmail(normalEmail(email))) {
136
+ return json(400, { reasons: [{ code: 'email', message: 'email is an address' }] });
137
+ }
138
+ // Counted before the lookup, because the send is what a stranger must not steer.
139
+ const byAddress = perAddress.take(addressOf(context) ?? 'unknown');
140
+ if (!byAddress.ok) return tooMany(byAddress.retryAfter);
141
+ const byEmail = perEmail.take(normalEmail(email));
142
+ if (!byEmail.ok) return tooMany(byEmail.retryAfter);
143
+ const outcome = await forgot(email);
144
+ if ('refused' in outcome) return json(502, { reasons: outcome.refused });
145
+ return json(200, outcome);
146
+ },
147
+ 'POST /api/password/reset': async (request) => {
148
+ const body = await bodyOf(request);
149
+ const outcome = await reset(body?.token, body?.password);
150
+ if ('refused' in outcome) return json(400, { reasons: outcome.refused });
151
+ return json(200, outcome);
152
+ },
153
+ },
154
+ };
155
+ };
@@ -0,0 +1,163 @@
1
+ // auth/Roles: the names a person holds, kept in `roles:<user>` and read live (design 289).
2
+
3
+ import { codecError } from '@aweftjs/codec';
4
+ import { atomic, createArray } from '@aweftjs/core';
5
+ import type { ModuleProps } from '@aweftjs/modules';
6
+ import type { Connection } from '@aweftjs/server';
7
+
8
+ import { type AuthContext, userOf } from '../context.ts';
9
+ import { type Implies, holds, isName } from '../names.ts';
10
+ import { notGated, storeOf } from '../props.ts';
11
+
12
+ export const defaults = { implies: {}, first: [] };
13
+
14
+ /** What a `roles:<user>` document holds. */
15
+ export interface RolesDocument extends Record<string, unknown> {
16
+ names: string[];
17
+ modifiedAt: number;
18
+ }
19
+
20
+ export interface Roles {
21
+ /** Does the person hold the name: granted, covered by a granted name, or implied by one. Reads the store. False for text that is not a name, since nobody holds one. */
22
+ may(user: string, name: string): Promise<boolean>;
23
+ /** Give the person these names. A name already held is left as it is. */
24
+ grant(user: string, ...names: string[]): Promise<void>;
25
+ /** Take these names away. A name not held is nothing. */
26
+ revoke(user: string, ...names: string[]): Promise<void>;
27
+ /** What the person was granted, and nothing implied. */
28
+ names(user: string): Promise<string[]>;
29
+ /** Grant the configured `first` names when nobody signed up before this person. True when they were the first. */
30
+ first(user: string): Promise<boolean>;
31
+ /** The table, for a page that runs the same check. */
32
+ call(args: unknown, context: unknown): { implies: Implies };
33
+ connection(connection: Connection<AuthContext>): Promise<() => Promise<void>>;
34
+ }
35
+
36
+ const refuse = (detail: string, fix: string): Error => codecError('invalid-config', `auth/Roles was given ${detail}`, fix);
37
+
38
+ const NAME_FIX = 'A name is non-empty text with no whitespace: admin, verified, products.abc123.';
39
+ const LIST_FIX = 'Give first, and each entry of implies, a list of names: non-empty text with no whitespace.';
40
+
41
+ const namesOf = (value: unknown, what: string): readonly string[] => {
42
+ if (!Array.isArray(value) || !value.every(isName)) throw refuse(`${what} ${JSON.stringify(value)}`, LIST_FIX);
43
+ return value as string[];
44
+ };
45
+
46
+ const impliesOf = (value: unknown): Implies => {
47
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
48
+ throw refuse(`implies ${JSON.stringify(value)}`, 'Give implies an object from a name to the list of names it implies.');
49
+ }
50
+ // No prototype, so a key such as `__proto__` is a name in the table and not a write to Object.
51
+ const table: Record<string, readonly string[]> = Object.create(null) as Record<string, readonly string[]>;
52
+ for (const [key, listed] of Object.entries(value as Record<string, unknown>)) {
53
+ if (!isName(key)) throw refuse(`the implies key ${JSON.stringify(key)}`, NAME_FIX);
54
+ table[key] = namesOf(listed, `implies.${key}`);
55
+ }
56
+ return table;
57
+ };
58
+
59
+ const invalidName = (name: unknown): Error =>
60
+ codecError('invalid-name', `auth/Roles was handed the name ${JSON.stringify(name)}`, NAME_FIX);
61
+
62
+ const invalidUser = (user: unknown): Error =>
63
+ codecError('invalid-user', `auth/Roles was handed the user ${JSON.stringify(user)}`, 'Hand it the id the gate put on the context, which is text.');
64
+
65
+ /** The one document this module keeps that is nobody's: whether a first sign-up has been seen. */
66
+ const FIRST = 'auth:first';
67
+
68
+ /** The page's write, refused: the names are the server's to give. */
69
+ const READ_ONLY = { accept: () => [{ code: 'read-only', message: 'names are granted by the server; a page reads them' }] };
70
+
71
+ export default async ({ config, ...props }: ModuleProps): Promise<Roles> => {
72
+ const store = storeOf(props);
73
+ const implies = impliesOf(config.implies);
74
+ const first = namesOf(config.first, 'first');
75
+ const doc = (user: string): string => `roles:${user}`;
76
+
77
+ const checked = (user: unknown, names: readonly unknown[]): string[] => {
78
+ if (typeof user !== 'string' || user === '') throw invalidUser(user);
79
+ for (const name of names) if (!isName(name)) throw invalidName(name);
80
+ return names as string[];
81
+ };
82
+
83
+ const writeFirst = async (granted: string | null): Promise<boolean> => {
84
+ const marker = await store.open(FIRST);
85
+ const root = marker.root as { granted?: string | null };
86
+ // Two sign-ups in flight open the same live document, so the first to get here writes
87
+ // it and the second reads it written.
88
+ const mine = root.granted === undefined;
89
+ if (mine) atomic(() => { Object.assign(root, { granted, at: Date.now() }); });
90
+ await store.settled(marker);
91
+ await store.close(marker);
92
+ return mine;
93
+ };
94
+
95
+ // The marker says whether a first sign-up has been seen. Where people exist and no marker
96
+ // does, this module was added to a store that already had them, and the next stranger to
97
+ // sign up is not the first: the index is read once, here, and the marker written for nobody.
98
+ if (await store.head(FIRST) === 0) {
99
+ const hits = await store.find({ where: [{ field: 'email', op: 'gt', value: '' }] });
100
+ if (hits.some((hit) => hit.doc.startsWith('user:'))) await writeFirst(null);
101
+ }
102
+
103
+ // `open` creates a document, and a check must not write one per person it asks about.
104
+ const granted = async (user: string): Promise<string[]> => {
105
+ if (await store.head(doc(user)) === 0) return [];
106
+ const handle = await store.open(doc(user));
107
+ const held = [...((handle.root as Partial<RolesDocument>).names ?? [])];
108
+ await store.close(handle);
109
+ return held;
110
+ };
111
+
112
+ const write = async (user: string, change: (names: string[]) => void): Promise<void> => {
113
+ const handle = await store.open(doc(user));
114
+ const root = handle.root as Partial<RolesDocument>;
115
+ atomic(() => {
116
+ if (root.names === undefined) root.names = createArray<string>() as string[];
117
+ change(root.names);
118
+ root.modifiedAt = Date.now();
119
+ });
120
+ await store.settled(handle);
121
+ await store.close(handle);
122
+ };
123
+
124
+ const grant = async (user: string, ...names: string[]): Promise<void> => {
125
+ const wanted = checked(user, names);
126
+ if (wanted.length === 0) return;
127
+ await write(user, (held) => {
128
+ for (const name of wanted) if (!held.includes(name)) held.push(name);
129
+ });
130
+ };
131
+
132
+ const firstOf = async (user: string): Promise<boolean> => {
133
+ checked(user, []);
134
+ if (await store.head(FIRST) !== 0) return false;
135
+ if (!(await writeFirst(user))) return false;
136
+ await grant(user, ...first);
137
+ return true;
138
+ };
139
+
140
+ return {
141
+ // A name built from a client's input can come out as no name at all, and the answer to
142
+ // "does anyone hold that" is no, not a fault.
143
+ may: async (user, name) => { checked(user, []); return isName(name) && holds(await granted(user), implies, name); },
144
+ grant,
145
+ revoke: async (user, ...names) => {
146
+ const unwanted = checked(user, names);
147
+ if (unwanted.length === 0 || await store.head(doc(user)) === 0) return;
148
+ await write(user, (held) => {
149
+ for (let at = held.length - 1; at >= 0; at -= 1) if (unwanted.includes(held[at]!)) held.splice(at, 1);
150
+ });
151
+ },
152
+ names: async (user) => { checked(user, []); return granted(user); },
153
+ first: firstOf,
154
+ call: () => ({ implies }),
155
+ connection: async ({ link, context }) => {
156
+ const user = userOf(context);
157
+ if (user === null) throw notGated();
158
+ const handle = await store.open(doc(user));
159
+ link.share('roles', handle.root, READ_ONLY);
160
+ return async () => { await store.close(handle); };
161
+ },
162
+ };
163
+ };