Skip to content

Commit

Permalink
feat(db): implement database models
Browse files Browse the repository at this point in the history
  • Loading branch information
BastiDood committed Jul 1, 2024
1 parent 86d754a commit 9265622
Show file tree
Hide file tree
Showing 7 changed files with 658 additions and 23 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@sveltejs/kit": "^2.5.17",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "^20.14.9",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
"autoprefixer": "^10.4.19",
Expand All @@ -35,6 +36,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.41.0",
"pino": "^9.2.0",
"postgres": "^3.4.4",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.5",
Expand All @@ -45,7 +47,9 @@
"svelte-check": "^3.8.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.2",
"valibot": "^0.35.0",
"vite": "^5.3.1",
"vite-plugin-tailwind-purgecss": "^0.3.3"
"vite-plugin-tailwind-purgecss": "^0.3.3",
"vitest": "^1.6.0"
}
}
499 changes: 477 additions & 22 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/lib/models/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type InferOutput, bigint, maxLength, minLength, object, pipe, string, transform, unknown, url } from 'valibot';

export const User = object({
student_number: bigint(),
is_admin: pipe(unknown(), transform(Boolean)),
is_faculty: pipe(unknown(), transform(Boolean)),
email: string(),
user_id: pipe(string(), minLength(1), maxLength(255)),
given_name: string(),
family_name: string(),
avatar: pipe(string(), url()),
});

export type User = InferOutput<typeof User>;
65 changes: 65 additions & 0 deletions src/lib/server/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { type Loggable, timed } from '$lib/decorators';
import { Pending, Session } from '$lib/server/models/session';
import { parse, pick } from 'valibot';
import type { Logger } from 'pino';
import type { User } from '$lib/models/user';
import assert from 'node:assert/strict';
import { env } from '$env/dynamic/private';
import postgres from 'postgres';

const sql = postgres(env.POSTGRES_URL, { ssl: 'prefer' });
process.once('sveltekit:shutdown', () => sql.end());

const DeletedSession = pick(Session, ['user_id', 'expiration']);

export class Database implements Loggable {
#logger: Logger;

constructor(logger: Logger) {
this.#logger = logger;
}

get logger() {
return this.#logger;
}

// eslint-disable-next-line class-methods-use-this
@timed async generatePendingSession() {
const [first, ...rest] = await sql`INSERT INTO pendings DEFAULT VALUES RETURNING session_id`;
assert(rest.length === 0);
return parse(Pending, first);
}

// eslint-disable-next-line class-methods-use-this
@timed async upgradePendingSession(
sid: Pending['session_id'],
uid: Session['user_id'],
expiration: Session['expiration'],
) {
const [first, ...rest] =
await sql`INSERT INTO sessions (session_id, user_id, expiration) DELETE FROM pendings WHERE session_id = ${sid} RETURNING session_id, ${uid}, ${expiration}`;
assert(rest.length === 0);
return typeof first === 'undefined' ? null : parse(Pending, first);
}

// eslint-disable-next-line class-methods-use-this
@timed async upsertOpenIdUser(
uid: User['user_id'],
email: User['email'],
given: User['given_name'],
family: User['family_name'],
avatar: User['avatar'],
) {
const { count } =
await sql`INSERT INTO users (user_id, email, given_name, family_name, avatar) VALUES (${uid}, ${email}, ${given}, ${family}, ${avatar}) ON CONFLICT (user_id) DO UPDATE SET email = ${email}, given_name = ${given}, family_name = ${family}, avatar = ${avatar}`;
return count;
}

// eslint-disable-next-line class-methods-use-this
@timed async deleteSession(sid: Session['session_id']) {
const [first, ...rest] =
await sql`DELETE FROM sessions WHERE session_id = ${sid} RETURNING session_id, expiration`;
assert(rest.length === 0);
return typeof first === 'undefined' ? null : parse(DeletedSession, first);
}
}
69 changes: 69 additions & 0 deletions src/lib/server/models/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
boolean,
email,
everyItem,
literal,
maxLength,
minLength,
number,
object,
pipe,
safeInteger,
string,
transform,
url,
} from 'valibot';
import { User } from '$lib/models/user';

const OAUTH_SCOPES = [
'openid',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
];
export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' ');
export const OAUTH_TOKEN_TYPE = 'Bearer';

/** @see https://developers.google.com/identity/protocols/oauth2#size */
export const AuthorizationCode = pipe(string(), minLength(1), maxLength(256));

export const TokenResponseSchema = object({
// JSON Web Token token containing the user's ID token.
id_token: string(),
// Always set to `OAUTH_SCOPE` for now.
scope: pipe(
string(),
transform(str => str.split(' ')),
everyItem(item => OAUTH_SCOPES.includes(item)),
),
token_type: literal(OAUTH_TOKEN_TYPE),
// Remaining lifetime in seconds.
expires_in: pipe(number(), safeInteger()),
});

const UnixTimeSecs = pipe(
number(),
safeInteger(),
transform(secs => new Date(secs * 1000)),
);

export const IdTokenSchema = object({
// OpenID audience.
aud: string(),
// OpenID subject. Typically the globally unique Google user ID.
sub: User.entries.user_id,
// Creation time (in seconds).
iat: UnixTimeSecs,
// Expiration time (in seconds) on or after which the token is invalid.
exp: UnixTimeSecs,
// OpenID issuer.
iss: string(),
// OpenID authorized presenter.
azp: string(),
// Access token hash.
at_hash: string(),
email: pipe(string(), email()),
email_verified: boolean(),
name: string(),
nonce: string(),
picture: pipe(string(), url()),
});
12 changes: 12 additions & 0 deletions src/lib/server/models/openid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type InferOutput, object, pipe, string, url } from 'valibot';

const DiscoveryDocument = object({
issuer: pipe(string(), url()),
authorization_endpoint: pipe(string(), url()),
token_endpoint: pipe(string(), url()),
userinfo_endpoint: pipe(string(), url()),
revocation_endpoint: pipe(string(), url()),
jwks_uri: pipe(string(), url()),
});

export type DiscoveryDocument = InferOutput<typeof DiscoveryDocument>;
16 changes: 16 additions & 0 deletions src/lib/server/models/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { type InferOutput, instance, number, object, pipe, string, transform, union, uuid } from 'valibot';
import { User } from '$lib/models/user';

const CommonSchema = object({
session_id: pipe(string(), uuid()),
expiration: pipe(
union([number(), string()]),
transform(input => new Date(input)),
),
});

export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array) });
export type Pending = InferOutput<typeof Pending>;

export const Session = object({ ...CommonSchema.entries, user_id: User.entries.user_id });
export type Session = InferOutput<typeof Session>;

0 comments on commit 9265622

Please sign in to comment.