tenseleyflow/shithub / 562bd24

Browse files

Add migrations 0002-0008: citext + users, user_emails, password_resets, email_verifications, auth_throttle, username_redirects

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
562bd24786cc27839604fb7c13c7924c554a7fb9
Parents
ea569c9
Tree
eac024e

7 changed files

StatusFile+-
A internal/migrationsfs/migrations/0002_citext.sql 13 0
A internal/migrationsfs/migrations/0003_users.sql 39 0
A internal/migrationsfs/migrations/0004_user_emails.sql 38 0
A internal/migrationsfs/migrations/0005_password_resets.sql 21 0
A internal/migrationsfs/migrations/0006_email_verifications.sql 21 0
A internal/migrationsfs/migrations/0007_auth_throttle.sql 27 0
A internal/migrationsfs/migrations/0008_username_redirects.sql 21 0
internal/migrationsfs/migrations/0002_citext.sqladded
@@ -0,0 +1,13 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Enable the citext extension. Used by users.username and user_emails.email
4
+-- so case-insensitive uniqueness is enforced at the type level (no
5
+-- lower(col) functional indexes, no application-side normalization).
6
+
7
+-- +goose Up
8
+CREATE EXTENSION IF NOT EXISTS citext;
9
+
10
+-- +goose Down
11
+-- Intentionally left empty: dropping citext would cascade across columns
12
+-- created in later migrations. If a full down-migration is required, drop
13
+-- the dependent tables first.
internal/migrationsfs/migrations/0003_users.sqladded
@@ -0,0 +1,39 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Core users table. username is citext for case-insensitive uniqueness;
4
+-- display casing is preserved for UI rendering. password_hash stores a
5
+-- PHC-encoded argon2id string; password_algo identifies the encoding so
6
+-- we can roll forward later without DB-side migration.
7
+--
8
+-- primary_email_id is a forward-reference to user_emails.id (FK added
9
+-- after that table exists in 0004 to avoid circular DDL).
10
+
11
+-- +goose Up
12
+CREATE TABLE users (
13
+    id                  bigserial   PRIMARY KEY,
14
+    username            citext      NOT NULL UNIQUE,
15
+    display_name        text        NOT NULL DEFAULT '',
16
+    primary_email_id    bigint,
17
+    password_hash       text        NOT NULL,
18
+    password_algo       text        NOT NULL DEFAULT 'argon2id-v1',
19
+    password_updated_at timestamptz NOT NULL DEFAULT now(),
20
+    email_verified      boolean     NOT NULL DEFAULT false,
21
+    last_login_at       timestamptz,
22
+    suspended_at        timestamptz,
23
+    suspended_reason    text,
24
+    deleted_at          timestamptz,
25
+    created_at          timestamptz NOT NULL DEFAULT now(),
26
+    updated_at          timestamptz NOT NULL DEFAULT now(),
27
+
28
+    CONSTRAINT users_username_length CHECK (char_length(username::text) BETWEEN 1 AND 39),
29
+    CONSTRAINT users_password_algo CHECK (password_algo IN ('argon2id-v1'))
30
+);
31
+
32
+CREATE INDEX users_deleted_at_idx ON users (deleted_at) WHERE deleted_at IS NOT NULL;
33
+CREATE INDEX users_suspended_at_idx ON users (suspended_at) WHERE suspended_at IS NOT NULL;
34
+
35
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
36
+    FOR EACH ROW EXECUTE FUNCTION tg_set_updated_at();
37
+
38
+-- +goose Down
39
+DROP TABLE IF EXISTS users;
internal/migrationsfs/migrations/0004_user_emails.sqladded
@@ -0,0 +1,38 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- User email addresses. Users can have multiple emails; exactly one is
4
+-- the primary at any time (enforced by a partial unique index).
5
+-- email is citext + globally unique across all users (an email address
6
+-- can belong to at most one shithub account).
7
+
8
+-- +goose Up
9
+CREATE TABLE user_emails (
10
+    id                       bigserial   PRIMARY KEY,
11
+    user_id                  bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12
+    email                    citext      NOT NULL UNIQUE,
13
+    is_primary               boolean     NOT NULL DEFAULT false,
14
+    verified                 boolean     NOT NULL DEFAULT false,
15
+    verification_token_hash  bytea,
16
+    verification_sent_at     timestamptz,
17
+    verified_at              timestamptz,
18
+    created_at               timestamptz NOT NULL DEFAULT now(),
19
+
20
+    CONSTRAINT user_emails_email_length CHECK (char_length(email::text) BETWEEN 3 AND 254),
21
+    CONSTRAINT user_emails_email_shape  CHECK (email::text LIKE '%@%')
22
+);
23
+
24
+-- At most one primary email per user.
25
+CREATE UNIQUE INDEX user_emails_one_primary_per_user
26
+    ON user_emails (user_id) WHERE is_primary;
27
+
28
+CREATE INDEX user_emails_user_id_idx ON user_emails (user_id);
29
+
30
+-- Now that user_emails exists, attach the FK from users.primary_email_id.
31
+ALTER TABLE users
32
+    ADD CONSTRAINT users_primary_email_fk
33
+        FOREIGN KEY (primary_email_id) REFERENCES user_emails(id)
34
+        ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
35
+
36
+-- +goose Down
37
+ALTER TABLE users DROP CONSTRAINT IF EXISTS users_primary_email_fk;
38
+DROP TABLE IF EXISTS user_emails;
internal/migrationsfs/migrations/0005_password_resets.sqladded
@@ -0,0 +1,21 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Password reset tokens. The token itself is high-entropy random; only its
4
+-- sha256 hash is stored. used_at marks single-use consumption (replay
5
+-- protection). expires_at is set to created_at + 1 hour at insert time.
6
+
7
+-- +goose Up
8
+CREATE TABLE password_resets (
9
+    id          bigserial   PRIMARY KEY,
10
+    user_id     bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11
+    token_hash  bytea       NOT NULL UNIQUE,
12
+    expires_at  timestamptz NOT NULL,
13
+    used_at     timestamptz,
14
+    created_at  timestamptz NOT NULL DEFAULT now()
15
+);
16
+
17
+CREATE INDEX password_resets_user_id_idx ON password_resets (user_id);
18
+CREATE INDEX password_resets_expires_at_idx ON password_resets (expires_at);
19
+
20
+-- +goose Down
21
+DROP TABLE IF EXISTS password_resets;
internal/migrationsfs/migrations/0006_email_verifications.sqladded
@@ -0,0 +1,21 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Email verification tokens. Same pattern as password_resets but keyed
4
+-- to a specific user_email row (a user with multiple emails verifies
5
+-- each one separately). TTL 24h. Single-use via used_at.
6
+
7
+-- +goose Up
8
+CREATE TABLE email_verifications (
9
+    id            bigserial   PRIMARY KEY,
10
+    user_email_id bigint      NOT NULL REFERENCES user_emails(id) ON DELETE CASCADE,
11
+    token_hash    bytea       NOT NULL UNIQUE,
12
+    expires_at    timestamptz NOT NULL,
13
+    used_at       timestamptz,
14
+    created_at    timestamptz NOT NULL DEFAULT now()
15
+);
16
+
17
+CREATE INDEX email_verifications_email_id_idx ON email_verifications (user_email_id);
18
+CREATE INDEX email_verifications_expires_at_idx ON email_verifications (expires_at);
19
+
20
+-- +goose Down
21
+DROP TABLE IF EXISTS email_verifications;
internal/migrationsfs/migrations/0007_auth_throttle.sqladded
@@ -0,0 +1,27 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Generic counter table for auth-related rate limits. The (scope, identifier)
4
+-- pair is unique. window_started_at is the start of the current window;
5
+-- callers reset hits to 1 when the window has elapsed.
6
+--
7
+-- Examples of (scope, identifier):
8
+--   ('login',  '1.2.3.4|alice')
9
+--   ('signup', 'ip:1.2.3.4')
10
+--   ('signup', 'email:foo@bar')
11
+--   ('reset',  'email:foo@bar')
12
+
13
+-- +goose Up
14
+CREATE TABLE auth_throttle (
15
+    id                 bigserial   PRIMARY KEY,
16
+    scope              text        NOT NULL,
17
+    identifier         text        NOT NULL,
18
+    hits               integer     NOT NULL DEFAULT 0,
19
+    window_started_at  timestamptz NOT NULL DEFAULT now(),
20
+
21
+    UNIQUE (scope, identifier)
22
+);
23
+
24
+CREATE INDEX auth_throttle_window_started_idx ON auth_throttle (window_started_at);
25
+
26
+-- +goose Down
27
+DROP TABLE IF EXISTS auth_throttle;
internal/migrationsfs/migrations/0008_username_redirects.sqladded
@@ -0,0 +1,21 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Username change history. When a user changes their username (S10), the
4
+-- old name is recorded here so:
5
+--   1. URLs to /<old-username>/* can 301 to the new username.
6
+--   2. The old name is reserved for a 30-day cooldown — the redirect
7
+--      record itself acts as the reservation; the signup flow checks
8
+--      this table in addition to users.username when validating new names.
9
+
10
+-- +goose Up
11
+CREATE TABLE username_redirects (
12
+    old_username text        PRIMARY KEY,
13
+    user_id      bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14
+    changed_at   timestamptz NOT NULL DEFAULT now()
15
+);
16
+
17
+CREATE INDEX username_redirects_user_id_idx ON username_redirects (user_id);
18
+CREATE INDEX username_redirects_changed_at_idx ON username_redirects (changed_at);
19
+
20
+-- +goose Down
21
+DROP TABLE IF EXISTS username_redirects;