@extantape/payload-plugin-signup 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EXTANT APE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # payload-plugin-signup
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sign-up for Payload: open, approval, invite-only or closed, switched in the admin. Requests are verified by email and wait as disabled accounts for an approve or decline, with no account enumeration.
4
+
5
+ **Status:** 0.1.0 is on npm. 0.1.1 (the confirm page's details, checks again at confirm, the request itself handed to checks, and cookies from checks) is on main, not released yet. 55 tests pass against a real MongoDB (in memory, single-node replica set) on Payload 3.92: every mode, confirming, approving and declining, that nobody can set up an account under someone else's address, the same answer for every address, refusals that fail closed (at asking and at confirming), rate limits (including through the real endpoints), what staff and members can and can't see or change, rollback, and retention. An independent security read found two problems before release, both fixed and tested (see [docs/decisions.md](docs/decisions.md)). Nothing has run on a real site yet, and no real email has been sent: the tests capture email. Integration into the Captive Origins platform is next.
6
+
7
+ ## What it does
8
+
9
+ - **Four modes**, chosen in **Sign-up settings** in the admin and in force at once, with no deploy:
10
+ - **Open:** anyone who confirms their email gets an account.
11
+ - **Approval:** a confirmed request waits as a disabled account until staff approve or decline it.
12
+ - **Invite only:** the form refuses, with your message. Accounts come from your own invitations.
13
+ - **Closed:** the form refuses, with your message.
14
+ `SIGNUP_MODE` (or the `forceMode` option) locks the mode from configuration. Anything unreadable or unknown counts as closed.
15
+ - **A request** has an email and your own fields (plain Payload fields, so Payload validates them and the admin shows them). No account and no password yet. Sign-up emails a link. Opening it shows a short form: the person chooses their password and presses "Confirm my email". Opening the link alone changes nothing, because mail scanners open links. Only then is the account made, so only the person with the inbox can set one up under that address. In approval mode it can't sign in until it's approved.
16
+ - **The same answer every time:** "Thanks. Check your email to continue." That goes for a new address, one that already has an account (its owner gets an email saying so), and one that's already waiting. The form never tells anyone whether an address has an account, and each case does the same lookups, so the timing gives little away (not measured, so not promised). "Waiting for approval" is only shown at sign-in, after the password matched.
17
+ - **Staff decide in the admin.** Open a request and set Status to Approved or Declined, or select several and use Edit. A waiting request can only be approved or declined; nothing else on it can be changed. Approving activates the account, runs your `onApprove` (roles, memberships), and emails "You're in". Declining deletes the account, which never signed in, and sends a polite email with your optional note. If `onApprove` fails, nothing changes and no email goes out.
18
+ - **Your checks** run before anything is stored, for example an age gate. A check can refuse with a message, flag the request for staff ("minor"), or add values to the new account. Answers meant only for checks (`answers`, such as a birth month and year) are never stored. A check that breaks refuses the request. See [Checks](#checks).
19
+ - **Checks again at confirm** (`confirmChecks`, optional): run when the person confirms, before the account is made, with the stored details and flag but never the check-only answers. A refusal declines the request; a check that breaks refuses for now and the link still works later.
20
+ - **Rate limits** per network address, per email and site-wide. Past the email limit, nothing happens, with the same answer. Use your own limiter, or the built-in one, which stores only hashed keys. A limiter that breaks refuses.
21
+ - Which address counts: by default, `x-real-ip` when the request has it (Vercel sets it), otherwise the last `x-forwarded-for` entry, the one added by the proxy in front of the site. That's only right behind one trusted proxy that sets one of these. Behind several proxies, a CDN with its own header, or no proxy at all (where a visitor can send any of these headers), pass `clientAddress` with the header your host vouches for. Requests with no address share one "unknown" count.
22
+ - The site-wide limit (200 an hour) is a cheap way for anyone to block sign-up for an hour. On a public site, use `verifyHuman` (Turnstile or similar) as well.
23
+ - **No back door:** once a site has users, an account can't be created by a signed-out API call. Accounts come through sign-up, from staff, or from your own code.
24
+ - **Retention:** requests decided, abandoned or never confirmed are deleted after 90 days (`retentionDays`), with any account that was never confirmed. Waiting requests stay until someone decides them. Run `pruneSignupRequests(payload)` daily.
25
+ - **A human check** (Turnstile or similar) through `verifyHuman`. Nothing is wired by default; on a public site, wire one.
26
+ - **Who manages it is yours to say, and required.** `access.manage` decides who sees and decides requests and changes the mode, and only an exact `true` counts: see [Access](#access). The plugin won't start without it, so a site can't leave the queue open to every signed-in member. `signupStatus` is shown only to the account itself and to managers. What checks set for the account (a minor flag, say) is kept out of the queue's view.
27
+
28
+ ## On its own, and with others
29
+
30
+ **On its own:** it needs a users auth collection, Payload's email, and your `access.manage`. Checks, roles on approval and a human check are functions you pass in. It imports no other Captive Origins plugin.
31
+
32
+ **Works with:**
33
+ - **Age Gate:** its `ageGateCheck` goes in `checks`. A too-young person is refused, a minor is flagged in the queue, and `minorUntil` is written to the new account. The birth month and year stay in `answers` and are never stored. Its "stay blocked" cookie comes back through `setCookies`, from Age Gate's next release. Age Gate 0.1.x set it on `req.responseHeaders`, which Sign-up doesn't pass on (see [Checks](#checks)), so with 0.1.x the cookie is not set.
34
+ - **A tenant plugin** (such as Payload's multi-tenant plugin): pass `scopeField` and `scopeFor`, and add `signup-requests` to its collections, so each site sees its own queue.
35
+
36
+ **Family:** It's part of the **Identity** family (a working draft): plugins designed to sit together for accounts and access. Age Gate is the member it's wired to today.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm install @extantape/payload-plugin-signup
42
+ ```
43
+
44
+ ```ts
45
+ import { signupPlugin } from '@extantape/payload-plugin-signup'
46
+
47
+ export default buildConfig({
48
+ plugins: [
49
+ signupPlugin({
50
+ fields: [
51
+ { name: 'firstName', type: 'text', required: true },
52
+ { name: 'lastName', type: 'text', required: true },
53
+ ],
54
+ toUser: (d) => ({ name: `${d.firstName} ${d.lastName}` }),
55
+ checks: [myAgeGate], // ({ data, answers, email, req, payload }) => { refuse?, flag?, userData?, setCookies? }
56
+ onApprove: async ({ user, req, payload }) => {
57
+ /* give roles */
58
+ },
59
+ access: { manage: ({ req }) => req.user?.role === 'admin' }, // required
60
+ emails: { verify: myBrandedVerifyEmail }, // any of verify, pending, approved, declined, alreadyHasAccount
61
+ urls: { signIn: 'https://example.com/login', resetPassword: 'https://example.com/forgot-password' },
62
+ }),
63
+ ],
64
+ })
65
+ ```
66
+
67
+ Build the collections' indexes before first use (`signup-requests`, `signup-attempts`, and the new `signupStatus` on your users). If you build your own confirm page, show what was asked for with `readSignupRequest`, ask for the password there and post `{ token, password }`; never confirm when the page loads. With a tenant plugin, pass `scopeField` and `scopeFor`, and add `signup-requests` to your tenant plugin's collections, so each site sees its own queue. The mode setting is one for the whole install.
68
+
69
+ ## Checks
70
+
71
+ A check is a function in `checks`, run in order when a request comes in:
72
+
73
+ ```ts
74
+ type Check = (args: { data; answers; email; req: PayloadRequest; payload }) =>
75
+ { refuse?: string; flag?: string; userData?: Record<string, unknown>; setCookies?: string[] } | void
76
+ ```
77
+
78
+ - `req` is the request itself, headers and cookies included. A check written to take less (`Partial<PayloadRequest>`) still fits. Calling `submitSignup` from your own code with a partial request, or none, is fine: Payload's `createLocalReq` completes it, with empty headers.
79
+ - `setCookies` are whole `Set-Cookie` values. `POST /api/signup/request` sets them on its response, whether the request is taken or refused. `submitSignup` hands them back as `setCookies` on its result, or on the `SignupError` it throws, so your own form can set them. Each must be printable ASCII (space to `~`), as cookie syntax allows; anything else counts as a broken check and refuses (503 `checkFailed`) before anything is stored or emailed.
80
+ - Confirm checks (`confirmChecks`) work the same way: they get the request itself, and their `setCookies` go on the `POST /api/signup/verify` response, as JSON or the page, confirmed or refused. `verifySignup` hands them back on its result or its `SignupError`.
81
+ - Nothing else about the response is a check's to change. Each check runs with an empty `req.responseHeaders` of its own, and Sign-up throws that list away afterwards, so headers a check adds there never reach the response. The request's own list (an auth strategy's, say) is put back untouched. Sign-up and its checks don't import each other: the check returns plain values.
82
+
83
+ ## Endpoints
84
+
85
+ | Method and path | Body | Answer |
86
+ | --- | --- | --- |
87
+ | `GET /api/signup/mode` | | `{ mode, accepting, message }` |
88
+ | `POST /api/signup/request` | `{ email, details, answers?, humanToken? }` | 202 `{ ok, message }`, or an error `{ error: { code, message } }`, either with any cookies the checks set |
89
+ | `GET /api/signup/verify?token=` | | A small page: what was asked for (email and details), then choose a password and confirm |
90
+ | `POST /api/signup/verify` | `{ token, password }` as JSON, or the page's form (`token`, `password`, `confirm`) | JSON `{ status: 'pending' or 'active' }`, or a page |
91
+
92
+ Error codes: `closed`, `inviteOnly`, `email`, `password`, `passwordMismatch` (the page only), `refused`, `checkFailed`, `human`, `busy`, `badLink`, `hasAccount` (an account was made for the address since they asked). The same functions are exported for your own pages: `submitSignup`, `verifySignup`, `currentMode`, and `readSignupRequest` to show what was asked for on your confirm page (it only reads; the link still works). The confirm page shows only the email and the fields you list in `confirmShows` (default none), because a mistyped address sends the link to a stranger. Hidden fields and fields with their own read access never show.
93
+
94
+ ## Access
95
+
96
+ `access.manage` decides who manages sign-up, and only an answer of exactly `true`, for a signed-in
97
+ user, counts. Anything else is a no: `false`, `1`, `'yes'`, an object, a query (`Where`), or an error
98
+ thrown or rejected. An error doesn't escape as a 500: REST answers 403, as for anyone else who isn't a
99
+ manager. It's typed as Payload's `Access` so existing rules fit, but what it returns is never passed to
100
+ Payload, so an object that looks like a query can't widen the queue to every request.
101
+
102
+ | Where | A manager | Anyone else, or signed out |
103
+ |---|---|---|
104
+ | Sign-up requests (applicants' emails and details) | Reads, decides, deletes | Nothing |
105
+ | Sign-up settings (the mode and messages) | Reads and changes | Nothing |
106
+ | An account's `signupStatus` | Reads | Only the account itself reads its own |
107
+
108
+ Nobody creates requests or changes `signupStatus` directly: the plugin does. Each site seeing only its
109
+ own queue comes from a tenant plugin with `scopeField` (above), not from a query in `access.manage`.
110
+
111
+ ## Commands
112
+
113
+ ```sh
114
+ npm test # integration tests, in-memory MongoDB
115
+ npm run typecheck
116
+ npm run build
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { ResolvedSignup } from './types.js';
3
+ /** Only an answer of exactly `true` passes; anything else, a thrown error included, is no. */
4
+ export declare function exactlyTrue(check: () => unknown): Promise<boolean>;
5
+ /**
6
+ * A signed-in manager: `access.manage` answered exactly `true`. A truthy value, a query (`Where`) or an error is a no,
7
+ * and the site's answer is never handed to Payload, so an object can't widen access to every request.
8
+ */
9
+ export declare const isManager: (o: Pick<ResolvedSignup, "manage">, args: {
10
+ req: PayloadRequest;
11
+ }) => Promise<boolean>;
package/dist/access.js ADDED
@@ -0,0 +1,14 @@
1
+ /** Only an answer of exactly `true` passes; anything else, a thrown error included, is no. */
2
+ export async function exactlyTrue(check) {
3
+ try {
4
+ return (await check()) === true;
5
+ }
6
+ catch {
7
+ return false;
8
+ }
9
+ }
10
+ /**
11
+ * A signed-in manager: `access.manage` answered exactly `true`. A truthy value, a query (`Where`) or an error is a no,
12
+ * and the site's answer is never handed to Payload, so an object can't widen access to every request.
13
+ */
14
+ export const isManager = (o, args) => args.req.user ? exactlyTrue(() => o.manage(args)) : Promise.resolve(false);
@@ -0,0 +1,10 @@
1
+ import type { CollectionConfig, Field, GlobalConfig } from 'payload';
2
+ import { type ResolvedSignup } from './types.js';
3
+ /** Sign-up requests: the queue staff work through. Made only by the plugin; staff change only the decision. */
4
+ export declare function requestsCollection(o: ResolvedSignup): CollectionConfig;
5
+ /** The mode, and what the form says while sign-up is shut. */
6
+ export declare function settingsGlobal(o: ResolvedSignup): GlobalConfig;
7
+ /** Rate-limit counts for the built-in limiter: hashed keys and times, nothing else. */
8
+ export declare function attemptsCollection(o: ResolvedSignup): CollectionConfig;
9
+ /** Added to the auth collection: where an account is in sign-up. Accounts made any other way are active. */
10
+ export declare function signupStatusField(o: ResolvedSignup): Field;
@@ -0,0 +1,147 @@
1
+ import { isManager } from './access.js';
2
+ import { decide } from './flow.js';
3
+ import { MODES } from './types.js';
4
+ import { SignupError } from './util.js';
5
+ const nobody = () => false;
6
+ /** Sign-up requests: the queue staff work through. Made only by the plugin; staff change only the decision. */
7
+ export function requestsCollection(o) {
8
+ // Only an exact `true` from `access.manage` makes a manager; a truthy value, a query or a thrown error is a no.
9
+ const manager = (args) => isManager(o, args);
10
+ const internal = { hidden: true };
11
+ return {
12
+ slug: o.requests,
13
+ labels: { singular: 'Sign-up request', plural: 'Sign-up requests' },
14
+ admin: {
15
+ group: o.adminGroup,
16
+ useAsTitle: 'email',
17
+ defaultColumns: ['email', 'status', 'flag', 'createdAt'],
18
+ description: 'People asking for an account. Open one and set Status to Approved or Declined, or select several and use Edit. Unverified ones haven\'t confirmed their email yet, and have no account. Decided, abandoned and unverified requests are deleted after ' +
19
+ `${o.retentionDays} days.`,
20
+ listSearchableFields: ['email'],
21
+ },
22
+ access: { read: manager, update: manager, create: nobody, delete: manager },
23
+ fields: [
24
+ { name: 'email', type: 'email', required: true, index: true, admin: { readOnly: true } },
25
+ {
26
+ name: 'status',
27
+ type: 'select',
28
+ required: true,
29
+ defaultValue: 'unverified',
30
+ index: true,
31
+ options: [
32
+ { label: 'Unverified', value: 'unverified' },
33
+ { label: 'Waiting for a decision', value: 'pending' },
34
+ { label: 'Approved', value: 'approved' },
35
+ { label: 'Declined', value: 'declined' },
36
+ ],
37
+ admin: { position: 'sidebar', description: 'Change a waiting request to Approved or Declined. Nothing else can be changed here.' },
38
+ },
39
+ { name: 'flag', type: 'text', admin: { readOnly: true, position: 'sidebar', description: 'Something the site\'s checks want you to see, for example that the person is a minor.' } },
40
+ {
41
+ name: 'note',
42
+ type: 'textarea',
43
+ maxLength: 500,
44
+ admin: { description: 'Optional. Sent to them with a decline, word for word. Leave it empty for the standard polite email.' },
45
+ },
46
+ { name: 'details', type: 'group', fields: o.fields.length ? o.fields : [{ name: 'none', type: 'text', admin: { hidden: true } }], admin: { readOnly: true } },
47
+ { name: 'user', type: 'relationship', relationTo: o.users, admin: { readOnly: true, position: 'sidebar' } },
48
+ { name: 'verifiedAt', type: 'date', admin: { readOnly: true, position: 'sidebar', date: { pickerAppearance: 'dayAndTime' } } },
49
+ { name: 'decidedAt', type: 'date', index: true, admin: { readOnly: true, position: 'sidebar', date: { pickerAppearance: 'dayAndTime' } } },
50
+ { name: 'decidedBy', type: 'relationship', relationTo: o.users, admin: { readOnly: true, position: 'sidebar', description: 'Empty when it was approved automatically (open sign-up).' } },
51
+ // What the site's checks set for the account (a minor flag, say): applied on confirming, read by nobody.
52
+ { name: 'userData', type: 'json', admin: internal, access: { read: nobody } },
53
+ { name: 'tokenHash', type: 'text', index: true, admin: internal, access: { read: nobody } },
54
+ { name: 'tokenExpires', type: 'date', admin: internal },
55
+ ],
56
+ hooks: {
57
+ beforeChange: [
58
+ async ({ data, originalDoc, operation, req, context }) => {
59
+ if (operation !== 'update' || context?.signupInternal)
60
+ return data;
61
+ // Staff change only the decision, and only on a waiting request. Everything else stays as the plugin wrote it.
62
+ const from = originalDoc?.status;
63
+ const to = data.status ?? from;
64
+ for (const key of ['email', 'details', 'user', 'flag', 'verifiedAt', 'decidedAt', 'decidedBy', 'userData', 'tokenHash', 'tokenExpires'])
65
+ data[key] = originalDoc?.[key];
66
+ if (to === from)
67
+ return data;
68
+ if (from !== 'pending')
69
+ throw new SignupError('notWaiting', from === 'unverified' ? 'This request isn\'t verified yet, so it can\'t be decided.' : 'This request has already been decided.');
70
+ if (to !== 'approved' && to !== 'declined')
71
+ throw new SignupError('badDecision', 'A waiting request can only be approved or declined.');
72
+ return decide(req.payload, { ...originalDoc, ...data }, to, req);
73
+ },
74
+ ],
75
+ },
76
+ timestamps: true,
77
+ };
78
+ }
79
+ /** The mode, and what the form says while sign-up is shut. */
80
+ export function settingsGlobal(o) {
81
+ const manager = (args) => isManager(o, args);
82
+ return {
83
+ slug: o.settings,
84
+ label: 'Sign-up settings',
85
+ admin: { group: o.adminGroup },
86
+ access: { read: manager, update: manager },
87
+ fields: [
88
+ {
89
+ name: 'mode',
90
+ type: 'select',
91
+ required: true,
92
+ defaultValue: 'closed',
93
+ options: [
94
+ { label: 'Open: anyone who confirms their email gets an account', value: MODES[0] },
95
+ { label: 'Approval: requests wait for a person to approve them', value: MODES[1] },
96
+ { label: 'Invite only: the form is shut; accounts come from invitations', value: MODES[2] },
97
+ { label: 'Closed: nobody can ask', value: MODES[3] },
98
+ ],
99
+ admin: {
100
+ description: o.forceMode ? `Locked to "${o.forceMode}" by the site's configuration; this setting is ignored until that's removed.` : 'Takes effect at once, with no deploy.',
101
+ },
102
+ },
103
+ { name: 'inviteOnlyMessage', type: 'textarea', maxLength: 500, admin: { description: 'Shown on the form in invite-only mode. Empty: "Sign-up is by invitation for now."' } },
104
+ { name: 'closedMessage', type: 'textarea', maxLength: 500, admin: { description: 'Shown on the form when closed. Empty: "Sign-up is closed for now."' } },
105
+ ],
106
+ };
107
+ }
108
+ /** Rate-limit counts for the built-in limiter: hashed keys and times, nothing else. */
109
+ export function attemptsCollection(o) {
110
+ return {
111
+ slug: o.attempts,
112
+ admin: { hidden: true },
113
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
114
+ fields: [
115
+ { name: 'kind', type: 'text', required: true, index: true },
116
+ { name: 'keyHash', type: 'text', required: true, index: true },
117
+ { name: 'at', type: 'date', required: true, index: true },
118
+ ],
119
+ };
120
+ }
121
+ /** Added to the auth collection: where an account is in sign-up. Accounts made any other way are active. */
122
+ export function signupStatusField(o) {
123
+ return {
124
+ name: 'signupStatus',
125
+ type: 'select',
126
+ defaultValue: 'active',
127
+ index: true,
128
+ options: [
129
+ { label: 'Active', value: 'active' },
130
+ { label: 'Waiting for approval', value: 'pending' },
131
+ ],
132
+ // Read by the account itself and by sign-up's managers; set only by the plugin.
133
+ access: {
134
+ create: nobody,
135
+ update: nobody,
136
+ read: async (args) => {
137
+ const { req, doc } = args;
138
+ if (!req.user)
139
+ return false;
140
+ if (doc?.id !== undefined && String(req.user.id) === String(doc.id))
141
+ return true;
142
+ return isManager(o, args);
143
+ },
144
+ },
145
+ admin: { position: 'sidebar', readOnly: true, description: 'Set by sign-up. Only active accounts can sign in.' },
146
+ };
147
+ }
@@ -0,0 +1,3 @@
1
+ import type { Emails } from './types.js';
2
+ /** Plain defaults. Sites pass their own, branded versions with `emails`. */
3
+ export declare const defaultEmails: Emails;
package/dist/emails.js ADDED
@@ -0,0 +1,25 @@
1
+ import { plainEmail } from './util.js';
2
+ /** Plain defaults. Sites pass their own, branded versions with `emails`. */
3
+ export const defaultEmails = {
4
+ verify: ({ url, hours }) => plainEmail('Confirm your email to finish signing up', [
5
+ 'Someone asked for an account with this email address. If that was you, confirm it here:',
6
+ url,
7
+ `The link works for ${hours} hours. If you didn't ask, you can ignore this email and nothing will happen.`,
8
+ ]),
9
+ pending: () => plainEmail('Thanks, your request is in', [
10
+ 'Your email is confirmed and your request is waiting for a person to look at it.',
11
+ 'We\'ll email you as soon as it\'s decided. You don\'t need to do anything else.',
12
+ ]),
13
+ approved: ({ signInURL }) => plainEmail('You\'re in', ['Your account is ready. Sign in with the email and password you chose:', signInURL]),
14
+ declined: ({ note }) => plainEmail('About your account request', [
15
+ 'Thank you for asking. We can\'t open an account for you at the moment.',
16
+ ...(note ? [note] : []),
17
+ 'Your request and the details you gave us will be deleted.',
18
+ ]),
19
+ alreadyHasAccount: ({ signInURL, resetURL }) => plainEmail('You already have an account', [
20
+ 'Someone asked for a new account with this email address, but you already have one.',
21
+ `Sign in here: ${signInURL}`,
22
+ `If you've forgotten your password, you can set a new one here: ${resetURL}`,
23
+ 'If you didn\'t ask, you can ignore this email.',
24
+ ]),
25
+ };
@@ -0,0 +1,2 @@
1
+ import type { Endpoint } from 'payload';
2
+ export declare const signupEndpoints: Endpoint[];
@@ -0,0 +1,137 @@
1
+ import { currentMode, readSignupRequest, submitSignup, verifySignup } from './flow.js';
2
+ import { SignupError } from './util.js';
3
+ /** A JSON answer. `setCookies` are the cookies the site's checks asked for, set on this response. */
4
+ const json = (body, status = 200, setCookies = []) => {
5
+ const headers = new Headers({ 'Cache-Control': 'no-store' });
6
+ for (const cookie of setCookies)
7
+ headers.append('Set-Cookie', cookie);
8
+ return Response.json(body, { status, headers });
9
+ };
10
+ const failure = (error, req) => {
11
+ if (error instanceof SignupError)
12
+ return json({ error: { code: error.code, message: error.message } }, error.status, error.setCookies);
13
+ req.payload.logger.error({ err: error, msg: 'signup: request failed' });
14
+ return json({ error: { code: 'error', message: 'Something went wrong. Please try again later.' } }, 500);
15
+ };
16
+ const body = async (req) => {
17
+ try {
18
+ const type = req.headers.get('content-type') ?? '';
19
+ if (type.includes('application/x-www-form-urlencoded') || type.includes('multipart/form-data'))
20
+ return Object.fromEntries((await req.formData()).entries());
21
+ return ((await req.json?.()) ?? {});
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ };
27
+ const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
28
+ /** A bare page for sites without their own: readable, keyboard-friendly, no scripts. */
29
+ const page = (title, inner, status = 200, setCookies = []) => withSetCookies(new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${escape(title)}</title>` +
30
+ '<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#1a1a1a;background:#fff}button{font:inherit;padding:.6rem 1.2rem;border-radius:.4rem;border:0;background:#1a4d2e;color:#fff;cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #f2a900;outline-offset:2px}dl{display:grid;grid-template-columns:max-content 1fr;gap:.25rem 1rem}dt{font-weight:600}dd{margin:0}input{font:inherit;padding:.5rem;width:100%;max-width:20rem;box-sizing:border-box}.error{color:#b00020;font-weight:600}@media (prefers-color-scheme:dark){.error{color:#ff8a80}}@media (prefers-color-scheme:dark){body{background:#121212;color:#eee}}</style>' +
31
+ `</head><body><main><h1>${escape(title)}</h1>${inner}</main></body></html>`, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } }), setCookies);
32
+ /** Set the cookies the site's checks asked for on this response. */
33
+ function withSetCookies(res, setCookies = []) {
34
+ for (const cookie of setCookies)
35
+ res.headers.append('Set-Cookie', cookie);
36
+ return res;
37
+ }
38
+ /** What they asked for, so they can see it's their request before confirming. */
39
+ const summary = (shown) => shown
40
+ ? `<p>You asked for an account as <strong>${escape(shown.email)}</strong>.</p>` +
41
+ (shown.details.length ? `<dl>${shown.details.map((d) => `<dt>${escape(d.label)}</dt><dd>${escape(d.value)}</dd>`).join('')}</dl>` : '')
42
+ : '';
43
+ /** Choose a password, twice, then confirm. Labels and autocomplete hints for password managers and screen readers. */
44
+ const confirmForm = (req, token, shown = null) => {
45
+ const min = req.payload.config.custom?.signup?.minPasswordLength ?? 10;
46
+ return (summary(shown) +
47
+ `<p>Choose the password for your account, then press the button to confirm this is your email address.</p>` +
48
+ `<form method="post" action="${escape(req.payload.config.routes.api)}/signup/verify">` +
49
+ `<input type="hidden" name="token" value="${escape(token)}">` +
50
+ `<p><label for="password">Password (at least ${min} characters)</label><br><input id="password" name="password" type="password" autocomplete="new-password" minlength="${min}" maxlength="256" required></p>` +
51
+ `<p><label for="confirm">The same password again</label><br><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="${min}" maxlength="256" required></p>` +
52
+ `<button type="submit">Confirm my email</button></form>` +
53
+ `<p>If you didn't ask for an account, close this page. Nothing happens unless you press the button.</p>`);
54
+ };
55
+ export const signupEndpoints = [
56
+ {
57
+ // What the form should show: open to requests or not, and the message when it isn't.
58
+ path: '/signup/mode',
59
+ method: 'get',
60
+ handler: async (req) => {
61
+ const { mode, message } = await currentMode(req.payload);
62
+ return json({ mode, accepting: mode === 'open' || mode === 'approval', message });
63
+ },
64
+ },
65
+ {
66
+ path: '/signup/request',
67
+ method: 'post',
68
+ handler: async (req) => {
69
+ try {
70
+ const b = await body(req);
71
+ const { setCookies, ...out } = await submitSignup(req.payload, { email: b.email, details: b.details, answers: b.answers, humanToken: typeof b.humanToken === 'string' ? b.humanToken : undefined }, req);
72
+ return json(out, 202, setCookies);
73
+ }
74
+ catch (error) {
75
+ return failure(error, req);
76
+ }
77
+ },
78
+ },
79
+ {
80
+ // The link in the email. Opening it never verifies by itself (mail scanners open links): the person chooses
81
+ // their password here and presses the button, so the account is made by whoever owns the inbox.
82
+ path: '/signup/verify',
83
+ method: 'get',
84
+ handler: async (req) => {
85
+ const token = new URL(req.url ?? 'http://x').searchParams.get('token') ?? '';
86
+ // Show the request so they know what they're confirming. Reading changes nothing, so a mail scanner opening
87
+ // the link is harmless. A dead link says so now, rather than after they've typed a password twice.
88
+ try {
89
+ return page('Confirm your email', confirmForm(req, token, await readSignupRequest(req.payload, { token }, req)));
90
+ }
91
+ catch (error) {
92
+ if (error instanceof SignupError && error.code === 'badLink')
93
+ return page('That didn\'t work', `<p>${escape(error.message)}</p>`, 400);
94
+ if (!(error instanceof SignupError))
95
+ req.payload.logger.error({ err: error, msg: 'signup: request not readable for the confirm page' });
96
+ // Too many tries or a lookup that broke: the form still works, without the summary.
97
+ return page('Confirm your email', confirmForm(req, token));
98
+ }
99
+ },
100
+ },
101
+ {
102
+ path: '/signup/verify',
103
+ method: 'post',
104
+ handler: async (req) => {
105
+ const b = await body(req);
106
+ const html = !(req.headers.get('content-type') ?? '').includes('json');
107
+ try {
108
+ // The page asks twice; the JSON API leaves that to the site's own form.
109
+ if (html && b.password !== b.confirm)
110
+ throw new SignupError('passwordMismatch', 'The two passwords don\'t match. Please type the same password twice.');
111
+ const { setCookies, ...out } = await verifySignup(req.payload, { token: b.token, password: b.password }, req);
112
+ if (!html)
113
+ return json(out, 200, setCookies);
114
+ return out.status === 'active'
115
+ ? page('You\'re in', '<p>Your email is confirmed and your account is ready. We\'ve sent you an email with the sign-in link.</p>', 200, setCookies)
116
+ : page('Thanks, you\'re on the list', '<p>Your email is confirmed. A person will look at your request, and we\'ll email you when it\'s decided.</p>', 200, setCookies);
117
+ }
118
+ catch (error) {
119
+ if (!html)
120
+ return failure(error, req);
121
+ const message = error instanceof SignupError ? error.message : 'Something went wrong. Please try again later.';
122
+ // A password problem keeps the form, so they can try again with the same link.
123
+ if (error instanceof SignupError && (error.code === 'password' || error.code === 'passwordMismatch')) {
124
+ // A short password came back from verifySignup, which already counted this try. A mismatch never reached
125
+ // it, so that read counts, and the page can't be used to read past the limit.
126
+ const shown = await readSignupRequest(req.payload, { token: b.token }, req, { countAttempt: error.code === 'passwordMismatch' }).catch((e) => {
127
+ if (!(e instanceof SignupError))
128
+ req.payload.logger.error({ err: e, msg: 'signup: request not readable for the confirm page' });
129
+ return null;
130
+ });
131
+ return page('Confirm your email', `<p role="alert" class="error">${escape(message)}</p>${confirmForm(req, String(b.token ?? ''), shown)}`, 400);
132
+ }
133
+ return page('That didn\'t work', `<p>${escape(message)}</p>`, error instanceof SignupError ? error.status : 500, error instanceof SignupError ? error.setCookies : []);
134
+ }
135
+ },
136
+ },
137
+ ];
package/dist/flow.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Payload } from 'payload';
2
+ import type { Req, SignupMode } from './types.js';
3
+ type Doc = Record<string, any>;
4
+ /** What the form says for every request it takes, whatever happened, so it never tells who has an account. */
5
+ export declare const RECEIVED = "Thanks. Check your email to continue.";
6
+ /**
7
+ * The mode now. The site's lock (`forceMode`, or `SIGNUP_MODE`) wins, then the admin setting. Anything that
8
+ * can't be read or isn't a known mode is `closed`: sign-up fails shut.
9
+ */
10
+ export declare function currentMode(payload: Payload): Promise<{
11
+ mode: SignupMode;
12
+ message: string | null;
13
+ }>;
14
+ /** What a taken request answers. `setCookies` is there only when a check asked for cookies. */
15
+ export type SubmitResult = {
16
+ ok: true;
17
+ message: string;
18
+ setCookies?: string[];
19
+ };
20
+ export type SignupInput = {
21
+ email?: unknown;
22
+ /** The request's details, checked against the site's `fields` and kept with the request. */
23
+ details?: unknown;
24
+ /** Answers only the site's checks see, never stored (for example a birth month and year for an age gate). */
25
+ answers?: unknown;
26
+ humanToken?: string;
27
+ };
28
+ /**
29
+ * Take a sign-up request. No account and no password yet: the account is made when the address's owner confirms
30
+ * and chooses a password (`verifySignup`), so nobody can set up an account under someone else's address.
31
+ *
32
+ * Refusals the person can act on (mode, a bad email, a site check, too many tries) throw a `SignupError`.
33
+ * Everything else ends in the same `{ ok: true }` and an email, whether the address is new, already has an
34
+ * account, or is already waiting: the form never tells. Each case does the same lookups.
35
+ *
36
+ * Cookies the checks ask for come back as `setCookies`, on the result or on the refusal (`SignupError`), for the
37
+ * response. Sign-up's own endpoint sets them; a site's own form should too.
38
+ */
39
+ export declare function submitSignup(payload: Payload, input: SignupInput, req?: Req): Promise<SubmitResult>;
40
+ export type RequestDetail = {
41
+ name: string;
42
+ label: string;
43
+ value: string;
44
+ };
45
+ /**
46
+ * What the person asked for, from the link, so the confirm page can show it before they press the button: the
47
+ * email, and the fields the site listed in `confirmShows`, as words. Only those: a mistyped address sends the link
48
+ * to a stranger. Hidden fields and fields with their own read access are never shown, even when listed. Answers
49
+ * meant only for checks were never stored, and what checks set for the account isn't shown. It only reads: the
50
+ * link still works afterwards. An unknown, used or expired link gets the same `badLink` refusal as confirming would.
51
+ * `countAttempt: false` is for a page that just counted one through `verifySignup`.
52
+ */
53
+ export declare function readSignupRequest(payload: Payload, input: {
54
+ token?: unknown;
55
+ }, req?: Req, { countAttempt }?: {
56
+ countAttempt?: boolean;
57
+ }): Promise<{
58
+ email: string;
59
+ details: RequestDetail[];
60
+ }>;
61
+ export type VerifyInput = {
62
+ token?: unknown;
63
+ password?: unknown;
64
+ };
65
+ /** What a confirm answers. `setCookies` is there only when a confirm check asked for cookies. */
66
+ export type VerifyResult = {
67
+ status: 'pending' | 'active';
68
+ setCookies?: string[];
69
+ };
70
+ /**
71
+ * Confirm the email from the link, choosing the account's password: only the person with the inbox can do this,
72
+ * so the account belongs to the address's owner. The account is made now: waiting for approval, or (open mode)
73
+ * active at once. If sign-up was shut since they asked, the request still waits for staff.
74
+ */
75
+ export declare function verifySignup(payload: Payload, input: VerifyInput, req?: Req): Promise<VerifyResult>;
76
+ /**
77
+ * Approve or decline a waiting request, inside the caller's transaction: approving activates the account and runs
78
+ * the site's `onApprove`; declining deletes the account (it never signed in). Returns the request's new data. The
79
+ * email goes after the transaction commits; a failed email is logged and doesn't undo the decision.
80
+ */
81
+ export declare function decide(payload: Payload, request: Doc, to: 'approved' | 'declined', req: Req): Promise<Doc>;
82
+ /** Send what `decide` queued on this request. */
83
+ export declare function flushAfterCommit(req: Req): Promise<void>;
84
+ /**
85
+ * Delete what's past its time: requests decided, abandoned or never confirmed more than `retentionDays` ago, and
86
+ * rate-limit counts older than a day. Waiting requests are kept until
87
+ * someone decides them. Run it daily, from the site's scheduled job.
88
+ */
89
+ export declare function pruneSignupRequests(payload: Payload, now?: Date): Promise<{
90
+ requests: number;
91
+ attempts: number;
92
+ }>;
93
+ export {};