Restrict platform-list edits to admins

Anyone authenticated can still read the shared platform list, but only an
admin may add, rename or delete from it.

- users.is_admin boolean, defaulting to false. The very first account ever
  registered becomes an admin automatically (CreateUser's INSERT uses
  NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at
  least one without manual setup; promoting anyone beyond that is a manual
  UPDATE for now (documented in the migration's comment on the column).
- GetSessionAndUser, GetUserByEmail and GetUserByID now also select
  is_admin, and it flows through to model.User (and so to
  /api/auth/{register,login,me}'s response) and to the auth middleware's
  authedUser.
- New requireAdminMiddleware + addAdminAuth, layered on top of the existing
  auth middleware. Wired onto create/update/delete-platform; list/get stay
  on plain addAuth, since reading the list only requires being logged in.

Verified end to end: the first registered user comes back is_admin:true,
the second false; the non-admin gets 403 on create/update/delete-platform
but 200 on list; the admin can do all of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 11:39:21 +02:00
1 parent 7fbd403d30
commit 384c4a7217
12 files changed
+110 -35

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated {"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
+7
View File
@@ -14,6 +14,13 @@ CREATE TABLE users (
password_hash text NOT NULL, password_hash text NOT NULL,
name text NOT NULL, name text NOT NULL,
bio text, bio text,
-- Only admins may add/rename/delete from the shared platform list (see
-- the platform table below); everyone can read it. The first account
-- ever registered becomes an admin automatically (see CreateUser in
-- db/query.sql) so there's always at least one without manual setup;
-- promoting anyone beyond that is a manual
-- `UPDATE users SET is_admin = true WHERE email = ...` for now.
is_admin boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
+9 -6
View File
@@ -1,18 +1,21 @@
-- name: CreateUser :one -- name: CreateUser :one
-- The first account ever registered becomes an admin automatically; see
-- the is_admin column's comment in db/migrations for why and the race this
-- accepts. Everyone after that registers as a non-admin.
INSERT INTO users ( INSERT INTO users (
email, password_hash, name, bio email, password_hash, name, bio, is_admin
) VALUES ( ) VALUES (
$1, $2, $3, $4 $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
) )
RETURNING id, email, name, bio, created_at; RETURNING id, email, name, bio, is_admin, created_at;
-- name: GetUserByEmail :one -- name: GetUserByEmail :one
SELECT id, email, password_hash, name, bio, created_at SELECT id, email, password_hash, name, bio, is_admin, created_at
FROM users FROM users
WHERE email = $1; WHERE email = $1;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT id, email, name, bio, created_at SELECT id, email, name, bio, is_admin, created_at
FROM users FROM users
WHERE id = $1; WHERE id = $1;
@@ -24,7 +27,7 @@ INSERT INTO sessions (
); );
-- name: GetSessionAndUser :one -- name: GetSessionAndUser :one
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
FROM sessions FROM sessions
JOIN users ON users.id = sessions.user_id JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = $1 AND sessions.expires_at > now(); WHERE sessions.token_hash = $1 AND sessions.expires_at > now();
+2 -2
View File
@@ -93,7 +93,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
SetCookie: sessionCookie(token), SetCookie: sessionCookie(token),
Body: model.AuthResponse{ Body: model.AuthResponse{
Token: token, Token: token,
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
}, },
}, nil }, nil
}) })
@@ -126,7 +126,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) {
SetCookie: sessionCookie(token), SetCookie: sessionCookie(token),
Body: model.AuthResponse{ Body: model.AuthResponse{
Token: token, Token: token,
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
}, },
}, nil }, nil
}) })
+6 -5
View File
@@ -11,10 +11,11 @@ import (
// authedUser is what AuthMiddleware stores in the request context after // authedUser is what AuthMiddleware stores in the request context after
// validating a session token. Handlers read it with userFromContext. // validating a session token. Handlers read it with userFromContext.
type authedUser struct { type authedUser struct {
ID uuid.UUID ID uuid.UUID
Email string Email string
Name string Name string
Bio string Bio string
IsAdmin bool
} }
type ctxKey string type ctxKey string
@@ -22,7 +23,7 @@ type ctxKey string
const userCtxKey ctxKey = "completed.user" const userCtxKey ctxKey = "completed.user"
func (u authedUser) toModel() model.User { func (u authedUser) toModel() model.User {
return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio} return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio, IsAdmin: u.IsAdmin}
} }
// userFromContext returns the authenticated user set by AuthMiddleware. It // userFromContext returns the authenticated user set by AuthMiddleware. It
+25 -1
View File
@@ -64,7 +64,7 @@ func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func
// Best-effort; a failure to record activity shouldn't fail the request. // Best-effort; a failure to record activity shouldn't fail the request.
_ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token))
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin}
newCtx := huma.WithValue(ctx, userCtxKey, user) newCtx := huma.WithValue(ctx, userCtxKey, user)
newCtx = huma.WithValue(newCtx, tokenCtxKey, token) newCtx = huma.WithValue(newCtx, tokenCtxKey, token)
next(newCtx) next(newCtx)
@@ -77,6 +77,30 @@ func addAuth(op *huma.Operation, api huma.API, queries db.Querier) {
op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries))
} }
// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and
// rejects the request unless the authenticated user is an admin. It reads
// the context value that middleware set directly, rather than calling
// userFromContext, so a wiring mistake (this middleware attached without
// the auth one ahead of it) fails the request with 403 instead of panicking.
func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
u, ok := ctx.Context().Value(userCtxKey).(authedUser)
if !ok || !u.IsAdmin {
huma.WriteErr(api, ctx, 403, "admin access required")
return
}
next(ctx)
}
}
// addAdminAuth attaches both the auth and the admin-only middleware to op,
// for routes that mutate data every user can see (the shared platform
// list) but only an admin should be able to change.
func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) {
addAuth(op, api, queries)
op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api))
}
// tokenCtxKey holds the raw bearer/cookie token for the current request, so // tokenCtxKey holds the raw bearer/cookie token for the current request, so
// the logout handler can hash and delete the exact session that was used // the logout handler can hash and delete the exact session that was used
// without re-parsing the request. // without re-parsing the request.
+9 -7
View File
@@ -42,10 +42,12 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
// --- Platform Routes --- // --- Platform Routes ---
// //
// Platforms are a shared list: every authenticated user reads and writes // Platforms are a shared list: every authenticated user reads the same
// the same rows, unlike games, which are private per user. Routes still // rows, unlike games, which are private per user. None of the queries
// require auth (so the list isn't public), but none of the queries below // below filter by user_id, because platform has no such column. Reading
// filter by user_id, because platform has no such column. // the list only requires being logged in (addAuth); adding, renaming or
// deleting a platform requires being an admin (addAdminAuth), since that
// changes what everyone else sees.
func registerPlatformRoutes(api huma.API, queries db.Querier) { func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{ listOp := huma.Operation{
@@ -90,7 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
Path: "/api/platforms", Path: "/api/platforms",
Summary: "Create Platform", Summary: "Create Platform",
} }
addAuth(&createOp, api, queries) addAdminAuth(&createOp, api, queries)
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
platform, err := queries.CreatePlatform(ctx, input.Body.Name) platform, err := queries.CreatePlatform(ctx, input.Body.Name)
if err != nil { if err != nil {
@@ -105,7 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
Path: "/api/platforms/{id}", Path: "/api/platforms/{id}",
Summary: "Update Platform", Summary: "Update Platform",
} }
addAuth(&updateOp, api, queries) addAdminAuth(&updateOp, api, queries)
huma.Register(api, updateOp, func(ctx context.Context, input *struct { huma.Register(api, updateOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"` ID uuid.UUID `path:"id"`
Body model.PlatformInput Body model.PlatformInput
@@ -126,7 +128,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
Path: "/api/platforms/{id}", Path: "/api/platforms/{id}",
Summary: "Delete Platform", Summary: "Delete Platform",
} }
addAuth(&deleteOp, api, queries) addAdminAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct { huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"` ID uuid.UUID `path:"id"`
}) (*struct{}, error) { }) (*struct{}, error) {
+25 -3
View File
@@ -43,6 +43,16 @@ func authedMock() *MockQuerier {
} }
} }
// adminMock is authedMock for a session belonging to an admin, for the
// platform-mutation routes that require one.
func adminMock() *MockQuerier {
m := authedMock()
m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil
}
return m
}
func authedRequest(method, path, body string) *http.Request { func authedRequest(method, path, body string) *http.Request {
var req *http.Request var req *http.Request
if body == "" { if body == "" {
@@ -118,7 +128,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
// db/query.sql's DeletePlatform comment), so the handler has to notice // db/query.sql's DeletePlatform comment), so the handler has to notice
// the 0-rows result itself and turn it into 409, not just pass through // the 0-rows result itself and turn it into 409, not just pass through
// whatever error (if any) the query returns. // whatever error (if any) the query returns.
mockDB := authedMock() mockDB := adminMock()
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{ID: id, Name: "PC"}, nil return db.GetPlatformRow{ID: id, Name: "PC"}, nil
} }
@@ -134,7 +144,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
} }
func TestDeletePlatformNotFound(t *testing.T) { func TestDeletePlatformNotFound(t *testing.T) {
mockDB := authedMock() mockDB := adminMock()
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{}, pgx.ErrNoRows return db.GetPlatformRow{}, pgx.ErrNoRows
} }
@@ -160,7 +170,7 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
} }
func TestCreatePlatform(t *testing.T) { func TestCreatePlatform(t *testing.T) {
mockDB := authedMock() mockDB := adminMock()
mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) { mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) {
return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil
} }
@@ -173,6 +183,18 @@ func TestCreatePlatform(t *testing.T) {
assert.Contains(t, rec.Body.String(), "Sega Genesis") assert.Contains(t, rec.Body.String(), "Sega Genesis")
} }
func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) {
mockDB := authedMock()
// No CreatePlatformFunc set: the admin check must reject the request
// before the handler ever calls the database, or this test would panic.
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
assert.Equal(t, http.StatusForbidden, rec.Code)
}
func TestListPlatformsIsSharedAcrossUsers(t *testing.T) { func TestListPlatformsIsSharedAcrossUsers(t *testing.T) {
// Unlike games, the platform list has no user_id to scope by: every // Unlike games, the platform list has no user_id to scope by: every
// authenticated user sees the same rows. ListPlatformsFunc taking no // authenticated user sees the same rows. ListPlatformsFunc taking no
+1
View File
@@ -44,6 +44,7 @@ type User struct {
PasswordHash string PasswordHash string
Name string Name string
Bio pgtype.Text Bio pgtype.Text
IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
+3
View File
@@ -14,6 +14,9 @@ type Querier interface {
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error)
CreateSession(ctx context.Context, arg CreateSessionParams) error CreateSession(ctx context.Context, arg CreateSessionParams) error
// The first account ever registered becomes an admin automatically; see
// the is_admin column's comment in db/migrations for why and the race this
// accepts. Everyone after that registers as a non-admin.
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
// Soft delete, so the FK on game.platform_id is never touched and never // Soft delete, so the FK on game.platform_id is never touched and never
+17 -6
View File
@@ -110,11 +110,11 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users ( INSERT INTO users (
email, password_hash, name, bio email, password_hash, name, bio, is_admin
) VALUES ( ) VALUES (
$1, $2, $3, $4 $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
) )
RETURNING id, email, name, bio, created_at RETURNING id, email, name, bio, is_admin, created_at
` `
type CreateUserParams struct { type CreateUserParams struct {
@@ -129,9 +129,13 @@ type CreateUserRow struct {
Email string Email string
Name string Name string
Bio pgtype.Text Bio pgtype.Text
IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
} }
// The first account ever registered becomes an admin automatically; see
// the is_admin column's comment in db/migrations for why and the race this
// accepts. Everyone after that registers as a non-admin.
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, createUser, row := q.db.QueryRow(ctx, createUser,
arg.Email, arg.Email,
@@ -145,6 +149,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
&i.Email, &i.Email,
&i.Name, &i.Name,
&i.Bio, &i.Bio,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -259,7 +264,7 @@ func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow
} }
const getSessionAndUser = `-- name: GetSessionAndUser :one const getSessionAndUser = `-- name: GetSessionAndUser :one
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
FROM sessions FROM sessions
JOIN users ON users.id = sessions.user_id JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = $1 AND sessions.expires_at > now() WHERE sessions.token_hash = $1 AND sessions.expires_at > now()
@@ -270,6 +275,7 @@ type GetSessionAndUserRow struct {
Email string Email string
Name string Name string
Bio pgtype.Text Bio pgtype.Text
IsAdmin bool
ExpiresAt pgtype.Timestamptz ExpiresAt pgtype.Timestamptz
} }
@@ -281,13 +287,14 @@ func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetS
&i.Email, &i.Email,
&i.Name, &i.Name,
&i.Bio, &i.Bio,
&i.IsAdmin,
&i.ExpiresAt, &i.ExpiresAt,
) )
return i, err return i, err
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, bio, created_at SELECT id, email, password_hash, name, bio, is_admin, created_at
FROM users FROM users
WHERE email = $1 WHERE email = $1
` `
@@ -298,6 +305,7 @@ type GetUserByEmailRow struct {
PasswordHash string PasswordHash string
Name string Name string
Bio pgtype.Text Bio pgtype.Text
IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
} }
@@ -310,13 +318,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
&i.PasswordHash, &i.PasswordHash,
&i.Name, &i.Name,
&i.Bio, &i.Bio,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, email, name, bio, created_at SELECT id, email, name, bio, is_admin, created_at
FROM users FROM users
WHERE id = $1 WHERE id = $1
` `
@@ -326,6 +335,7 @@ type GetUserByIDRow struct {
Email string Email string
Name string Name string
Bio pgtype.Text Bio pgtype.Text
IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
} }
@@ -337,6 +347,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow
&i.Email, &i.Email,
&i.Name, &i.Name,
&i.Bio, &i.Bio,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
+5 -4
View File
@@ -5,10 +5,11 @@ package model
// User is the public view of an account; it never carries the password hash. // User is the public view of an account; it never carries the password hash.
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Bio string `json:"bio,omitempty"` Bio string `json:"bio,omitempty"`
IsAdmin bool `json:"is_admin"`
} }
type RegisterInput struct { type RegisterInput struct {