Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ it from a clone inside a MediaWiki installation's `extensions/` directory — se

## Schema changes

The schema has never shipped, so change a table definition in place rather than through a patch file.
`addExtensionTable` leaves installs that already have the table untouched, so after regenerating the SQL:
The schema is running on installs, so every table change needs both parts:

* drop the extension's tables and re-run `update.php` on every wiki that has them;
* bump the MediaWiki cache key in `.github/workflows/ci.yml`, which otherwise restores an install
carrying the old table.
* Fresh installs: the regenerated table definition in `sql/`.
* Existing installs: an abstract schema change registered in `SchemaChangesHandler`, since
`addExtensionTable` skips them.

Both are described under [Development](README.md#development). Prove the second part by running
`update.php` on an install that already has the table.
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ admitted by an allowlist of addresses and domains organized into named groups.
* Nothing gives the member list away: a member's account is named after nobody, code and
password-reset requests answer the same for every address, and account listings and the logs that
record members are restricted.
* An admitted address can be mailed an invitation naming the login page and how to log in.
* Groups, allowlist entries and the member roster are managed over a REST API.
* It does not make the wiki private: restricting who may read stays a wiki configuration decision.

Expand Down Expand Up @@ -58,6 +59,16 @@ named after nobody, placed in the reader group, and the address is recorded as c
allowlist is consulted again at that point, so removing an entry ends access at the next login. A
code never opens an account that was created some other way.

### Invitations

Adding an address to the allowlist grants access but sends no mail. An invitation is asked for
separately: it mails that address the login page, the address to log in with, and that a code arrives
by mail instead of a password. It names no username and no inviter.

Only an address entry can be invited, not a domain rule, and only while the
[code route](#login-routes) is offered, since the mail tells the recipient to log in with a code.
Inviting again sends another mail; the entry records when the last one was sent.

### Usernames

A member's account is named `Member` and six characters drawn at random, `Member A7K2M4` for
Expand Down Expand Up @@ -224,7 +235,7 @@ Platform requirements:
* [PHP] 8.3 or later
* [MediaWiki] 1.43 or later
* MySQL, MariaDB or SQLite. No PostgreSQL schema is shipped
* Working outgoing email while the code route is offered, since login codes are sent by mail
* Working outgoing email while the code route is offered, since login codes and invitations are sent by mail
* [OpenIDConnect] 8.3 or later while single sign-on is held to the allowlist, with
`$wgOpenIDConnect_UseRandomUsernames` left off and its preferred username processor left to this
extension rather than set per provider. Every other way of naming a single sign-on account is one
Expand All @@ -250,15 +261,16 @@ $wgMemberAccessCodeLogin = 'allowlisted';
Loading alone admits nobody: the second line turns on the code login route, held to the allowlist.
See [Login routes](#login-routes) for what each route setting admits.

To send the login code as rich email rather than plain text, set:
To send the login code and the invitation as rich email rather than plain text, set:

```php
$wgAllowHTMLEmail = true;
```

Run `php maintenance/run.php update --quick` to create the extension's tables. Until it has run, a
warning on the `MemberAccess` log channel says so, and anything that reads them fails with a database
error.
Run `php maintenance/run.php update --quick` to create the extension's tables, and again after every
upgrade, since one may add a column to a table the wiki already has. Until it has run, anything that
reads those tables fails with a database error; a wiki missing them altogether also says so, with a
warning on the `MemberAccess` log channel.

The same command gives an opaque name to every member the extension did not name, which is what
earlier versions left them under. It is recorded in the rename log, which is why that log is
Expand All @@ -282,6 +294,7 @@ wiki's CSRF token in an `X-CSRF-TOKEN` header, unless the session provider is in
| `GET /groups/{id}/entries` | The group's allowlist entries |
| `POST /groups/{id}/entries` | Adds entries, at most 500 per request. Body: `values`, a list of email addresses and `@domain`s |
| `DELETE /entries/{id}` | Removes an allowlist entry |
| `POST /entries/{id}/invitation` | Mails an invitation to the entry's address and records the time as the entry's `invited`. Refused for a domain rule, or while the code route is off |
| `GET /members` | The roster: each member's address, group, creation, last login and active flag, plus the totals overall and per group |
| `POST /members/{userId}/deactivate` | Ends a member's access. Also requires the `block` right, and refuses your own account |
| `POST /members/{userId}/reactivate` | Restores a member's access. Also requires the `block` right. The response's `blocked` says whether a block placed for another reason is still on the account |
Expand All @@ -299,7 +312,7 @@ already admits the value.
{
"value": "jane@example.com",
"added": true,
"entry": { "id": 7, "value": "jane@example.com", "kind": "email", "created": "2026-05-04T09:12:33Z" }
"entry": { "id": 7, "value": "jane@example.com", "kind": "email", "created": "2026-05-04T09:12:33Z", "invited": null }
},
{
"value": "john@example.net",
Expand All @@ -320,8 +333,9 @@ group (`group_not_found`), a body without a `values` list or with a value that i
A failed request answers with the HTTP status and a body carrying a stable `errorCode` next to a
human-readable `error`: `not_logged_in`, `permission_denied`, `invalid_csrf_token`,
`invalid_request_body`, `invalid_group_name`, `group_name_too_long`, `duplicate_group_name`,
`group_not_found`, `group_not_empty`, `group_has_members`, `too_many_entry_values`, `entry_not_found`,
`not_a_member`, `cannot_deactivate_self`, `block_right_required`, `block_failed`, `unblock_failed`,
`group_not_found`, `group_not_empty`, `group_has_members`, `too_many_entry_values`,
`entry_not_found`, `not_an_address`, `code_login_off`, `invitation_not_sent`, `not_a_member`,
`cannot_deactivate_self`, `block_right_required`, `block_failed`, `unblock_failed`,
`cannot_remove_self`. A request the REST framework refuses first — an id that is not a number, a
body it cannot read — carries MediaWiki's error shape rather than this one.

Expand All @@ -338,7 +352,7 @@ body it cannot read — carries MediaWiki's error shape rather than this one.
| `$wgMemberAccessEmailDailyLimit` | int | `10` | Maximum code requests per email address within 24 hours |
| `$wgMemberAccessIpBurstLimit` | int | `10` | Maximum code requests per client IP within 15 minutes |
| `$wgMemberAccessIpDailyLimit` | int | `50` | Maximum code requests per client IP within 24 hours |
| `$wgMemberAccessSenderAddress` | ?string | `null` | Address login codes are sent from. Falls back to `$wgPasswordSender` |
| `$wgMemberAccessSenderAddress` | ?string | `null` | Address that login codes and invitations are sent from. Falls back to `$wgPasswordSender` |
| `$wgMemberAccessSessionDurationSeconds` | int | `2592000` | How long a remembered login lasts, wiki-wide. Thirty days, against core's 180 days. `0` leaves `$wgExtendedLoginCookieExpiration` alone |
| `$wgMemberAccessBlockedApiModules` | string[] | `[ 'allusers', 'users', 'blocks' ]` | Action API query submodules the reader group may not use |
| `$wgMemberAccessBlockedSpecialPages` | string[] | `[ 'Listusers', 'Activeusers', 'BlockList', 'Redirect', 'Userrights' ]` | Special pages the reader group may not open. Canonical names; an alias does not match. Setting it adds to the shipped list rather than replacing it, so those pages cannot be dropped |
Expand Down Expand Up @@ -375,6 +389,19 @@ php maintenance/run.php generateSchemaSql --json extensions/MemberAccess/sql/<ta
--sql extensions/MemberAccess/sql/sqlite/<table>.sql --type sqlite
```

Changing a table that installs already run also needs a patch, so that they get the change. Add an
abstract schema change under `sql/abstractSchemaChanges/`, generate its SQL for both database types,
and register it in `SchemaChangesHandler` after the `addExtensionTable` calls:

```shell
php maintenance/run.php generateSchemaChangeSql \
--json extensions/MemberAccess/sql/abstractSchemaChanges/<patch>.json \
--sql extensions/MemberAccess/sql/mysql/<patch>.sql --type mysql
php maintenance/run.php generateSchemaChangeSql \
--json extensions/MemberAccess/sql/abstractSchemaChanges/<patch>.json \
--sql extensions/MemberAccess/sql/sqlite/<patch>.sql --type sqlite
```

## Release notes

### Version 0.1.0 (unreleased)
Expand Down Expand Up @@ -404,6 +431,8 @@ Initial version for MediaWiki 1.43+ with these features:
user, block and rename logs, so the member list is not given away
* Every code issue, login success, failure and rate-limit hit logged through the `MemberAccess` log
channel, with the email address hashed
* An invitation mailed to an admitted address on request, and again as often as needed, naming the
login page and the address to log in with
* A REST API under `/rest.php/member-access/v0/` for managing groups, allowlist entries and the roster

[MediaWiki]: https://www.mediawiki.org
Expand Down
7 changes: 6 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
},
"MemberAccessSenderAddress": {
"value": null,
"description": "Email address login codes are sent from. Falls back to $wgPasswordSender when null."
"description": "Email address login codes and invitations are sent from. Falls back to $wgPasswordSender when null."
},
"MemberAccessSessionDurationSeconds": {
"value": 2592000,
Expand Down Expand Up @@ -191,6 +191,11 @@
"method": [ "DELETE" ],
"factory": "ProfessionalWiki\\MemberAccess\\MemberAccessExtension::newRemoveEntryApi"
},
{
"path": "/member-access/v0/entries/{id}/invitation",
"method": [ "POST" ],
"factory": "ProfessionalWiki\\MemberAccess\\MemberAccessExtension::newSendInvitationApi"
},
{
"path": "/member-access/v0/members",
"method": [ "GET" ],
Expand Down
5 changes: 5 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"memberaccess-code-email-validity": "It works once, and stops working in $1 {{PLURAL:$1|minute|minutes}}.",
"memberaccess-code-email-disclaimer": "If you did not ask to log in to {{SITENAME}}, nothing has happened and you can ignore this message.",
"memberaccess-code-email-body": "Here is your login code for {{SITENAME}}:\n\n $1\n\nIt works once, and stops working in $2 {{PLURAL:$2|minute|minutes}}.\n\nIf you did not ask to log in to {{SITENAME}}, nothing has happened and you can ignore this message.",
"memberaccess-invitation-email-subject": "You have been invited to {{SITENAME}}",
"memberaccess-invitation-email-intro": "You can now log in here:",
"memberaccess-invitation-email-instructions": "Log in with $1. A code is mailed to you each time, and there is no password.",
"memberaccess-invitation-email-disclaimer": "If you were not expecting this, you can ignore this message.",
"memberaccess-invitation-email-body": "You can now log in to {{SITENAME}} here:\n\n $1\n\nLog in with $2. A code is mailed to you each time, and there is no password.\n\nIf you were not expecting this, you can ignore this message.",
"memberaccess-auth-button-label": "Continue with email",
"memberaccess-auth-button-help": "We send a one-time code to your email address. Nothing beyond your email address is requested.",
"memberaccess-auth-resend-label": "Send a new code",
Expand Down
5 changes: 5 additions & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"memberaccess-code-email-validity": "Line below the login code saying how long it lasts. Parameters:\n* $1 - the number of minutes the code stays valid",
"memberaccess-code-email-disclaimer": "Closing line of the email carrying a login code, for someone who did not ask for one.",
"memberaccess-code-email-body": "Plain text alternative of the email carrying a one-time login code, shown by mail clients that do not display the formatted part. Parameters:\n* $1 - the login code, in groups of four digits\n* $2 - the number of minutes the code stays valid",
"memberaccess-invitation-email-subject": "Subject line of the email inviting someone to log in to the wiki.",
"memberaccess-invitation-email-intro": "First line of the invitation email, directly above the login page URL. The heading above it names the wiki, so this line does not.",
"memberaccess-invitation-email-instructions": "Line below the login page URL, saying which address to log in with and that logging in uses a code sent by email, not a password. Parameters:\n* $1 - the email address to log in with",
"memberaccess-invitation-email-disclaimer": "Closing line of the invitation email, for someone who was not expecting it.",
"memberaccess-invitation-email-body": "Plain text alternative of the whole invitation email, shown by mail clients that do not display the formatted part. Says the same as {{msg-mw|Memberaccess-invitation-email-intro}}, {{msg-mw|Memberaccess-invitation-email-instructions}} and {{msg-mw|Memberaccess-invitation-email-disclaimer}} together. Keep the blank lines and the indentation before $1. Parameters:\n* $1 - the URL of the login page\n* $2 - the email address to log in with",
"memberaccess-auth-button-label": "Label of the button on the login form that asks for a login code to be mailed to the address above it.",
"memberaccess-auth-button-help": "Help text below the button that asks for a login code to be mailed. \"Nothing beyond your email address is requested\" is a commitment: keep the meaning \"asked for\", and do not turn it into \"stored\".",
"memberaccess-auth-resend-label": "Label of the button on the code entry screen that sends another login code to the same address.",
Expand Down
173 changes: 173 additions & 0 deletions sql/abstractSchemaChanges/patch-memberaccess_entry-mae_invited.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
{
"comment": "Record when an invitation was last sent to an admitted address",
"before": {
"name": "memberaccess_entry",
"comment": "Allowlist entries: one email address, or one whole domain written as @example.com",
"columns": [
{
"name": "mae_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "mae_group_id",
"comment": "The memberaccess_group this entry belongs to",
"type": "integer",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "mae_value",
"comment": "Lowercased email address, or @ followed by a lowercased domain",
"type": "binary",
"options": {
"length": 255,
"notnull": true
}
},
{
"name": "mae_kind",
"comment": "Either email or domain",
"type": "binary",
"options": {
"length": 8,
"notnull": true
}
},
{
"name": "mae_actor",
"comment": "Actor that added the entry",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true,
"default": 0
}
},
{
"name": "mae_timestamp",
"comment": "When the entry was added",
"type": "mwtimestamp",
"options": {
"notnull": true
}
}
],
"indexes": [
{
"name": "mae_value",
"comment": "An address or domain belongs to exactly one group",
"columns": [
"mae_value"
],
"unique": true
},
{
"name": "mae_group_id_value",
"columns": [
"mae_group_id",
"mae_value"
],
"unique": false
}
],
"pk": [
"mae_id"
]
},
"after": {
"name": "memberaccess_entry",
"comment": "Allowlist entries: one email address, or one whole domain written as @example.com",
"columns": [
{
"name": "mae_id",
"type": "integer",
"options": {
"autoincrement": true,
"unsigned": true,
"notnull": true
}
},
{
"name": "mae_group_id",
"comment": "The memberaccess_group this entry belongs to",
"type": "integer",
"options": {
"unsigned": true,
"notnull": true
}
},
{
"name": "mae_value",
"comment": "Lowercased email address, or @ followed by a lowercased domain",
"type": "binary",
"options": {
"length": 255,
"notnull": true
}
},
{
"name": "mae_kind",
"comment": "Either email or domain",
"type": "binary",
"options": {
"length": 8,
"notnull": true
}
},
{
"name": "mae_actor",
"comment": "Actor that added the entry",
"type": "bigint",
"options": {
"unsigned": true,
"notnull": true,
"default": 0
}
},
{
"name": "mae_timestamp",
"comment": "When the entry was added",
"type": "mwtimestamp",
"options": {
"notnull": true
}
},
{
"name": "mae_invited",
"comment": "When an invitation was last sent to the address; null when none was, and always for a domain rule",
"type": "mwtimestamp",
"options": {
"notnull": false
}
}
],
"indexes": [
{
"name": "mae_value",
"comment": "An address or domain belongs to exactly one group",
"columns": [
"mae_value"
],
"unique": true
},
{
"name": "mae_group_id_value",
"columns": [
"mae_group_id",
"mae_value"
],
"unique": false
}
],
"pk": [
"mae_id"
]
}
}
8 changes: 8 additions & 0 deletions sql/memberaccess_entry.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@
"options": {
"notnull": true
}
},
{
"name": "mae_invited",
"comment": "When an invitation was last sent to the address; null when none was, and always for a domain rule",
"type": "mwtimestamp",
"options": {
"notnull": false
}
}
],
"indexes": [
Expand Down
1 change: 1 addition & 0 deletions sql/mysql/memberaccess_entry.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CREATE TABLE /*_*/memberaccess_entry (
mae_kind VARBINARY(8) NOT NULL,
mae_actor BIGINT UNSIGNED DEFAULT 0 NOT NULL,
mae_timestamp BINARY(14) NOT NULL,
mae_invited BINARY(14) DEFAULT NULL,
UNIQUE INDEX mae_value (mae_value),
INDEX mae_group_id_value (mae_group_id, mae_value),
PRIMARY KEY(mae_id)
Expand Down
6 changes: 6 additions & 0 deletions sql/mysql/patch-memberaccess_entry-mae_invited.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- This file is automatically generated using maintenance/generateSchemaChangeSql.php.
-- Source: extensions/MemberAccess/sql/abstractSchemaChanges/patch-memberaccess_entry-mae_invited.json
-- Do not modify this file directly.
-- See https://www.mediawiki.org/wiki/Manual:Schema_changes
ALTER TABLE /*_*/memberaccess_entry
ADD mae_invited BINARY(14) DEFAULT NULL;
2 changes: 1 addition & 1 deletion sql/sqlite/memberaccess_entry.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CREATE TABLE /*_*/memberaccess_entry (
mae_group_id INTEGER UNSIGNED NOT NULL,
mae_value BLOB NOT NULL, mae_kind BLOB NOT NULL,
mae_actor BIGINT UNSIGNED DEFAULT 0 NOT NULL,
mae_timestamp BLOB NOT NULL
mae_timestamp BLOB NOT NULL, mae_invited BLOB DEFAULT NULL
);

CREATE UNIQUE INDEX mae_value ON /*_*/memberaccess_entry (mae_value);
Expand Down
Loading
Loading