diff --git a/deno.json b/deno.json index 0c12e83..80b6d15 100644 --- a/deno.json +++ b/deno.json @@ -1,14 +1,14 @@ { "name": "@andyburke/autonomous.contact", "description": "An experiment.", - "version": "0.0.1", + "version": "0.2.0", "license": "MIT", "exports": {}, "tasks": { "lint": "deno lint", "fmt": "deno fmt", - "serve": "FSDB_ROOT=$PWD/.fsdb SERVERUS_TYPESCRIPT_IMPORT_LOGGING=1 deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public", - "test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_TYPESCRIPT_IMPORT_LOGGING=1 SERVERUS_ROOT=$PWD/public deno test --allow-env --allow-read --allow-write --allow-net --trace-leaks --fail-fast tests/" + "serve": "FSDB_ROOT=$PWD/.fsdb deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public", + "test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public deno test --allow-env --allow-read --allow-write --allow-net --trace-leaks --fail-fast tests/" }, "test": { "exclude": ["tests/data/"] @@ -32,7 +32,7 @@ } }, "imports": { - "@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.4.0", + "@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.6.0", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", "@andyburke/serverus": "jsr:@andyburke/serverus@^0.6.0", "@std/assert": "jsr:@std/assert@^1.0.13", diff --git a/deno.lock b/deno.lock index f3037bd..cba3125 100644 --- a/deno.lock +++ b/deno.lock @@ -1,10 +1,11 @@ { "version": "5", "specifiers": { - "jsr:@andyburke/fsdb@*": "0.4.0", - "jsr:@andyburke/fsdb@0.4": "0.4.0", + "jsr:@andyburke/fsdb@*": "0.6.0", + "jsr:@andyburke/fsdb@0.6": "0.6.0", "jsr:@andyburke/lurid@*": "0.2.0", "jsr:@andyburke/lurid@0.2": "0.2.0", + "jsr:@andyburke/serverus@*": "0.6.0", "jsr:@andyburke/serverus@0.6": "0.6.0", "jsr:@std/assert@*": "1.0.13", "jsr:@std/assert@^1.0.13": "1.0.13", @@ -32,8 +33,8 @@ "jsr:@stdext/crypto@0.1": "0.1.0" }, "jsr": { - "@andyburke/fsdb@0.4.0": { - "integrity": "13ff46528835e6eaf5ff57fcd1bdd97020d608e6d1e03a38be0d162d1bbbace1", + "@andyburke/fsdb@0.6.0": { + "integrity": "6c58518e9de64c61f13acb0cb110ad3bd9c6277f016798ddee2eed26590dc848", "dependencies": [ "jsr:@std/cli@^1.0.20", "jsr:@std/fs@^1.0.18", @@ -166,7 +167,7 @@ }, "workspace": { "dependencies": [ - "jsr:@andyburke/fsdb@0.4", + "jsr:@andyburke/fsdb@0.6", "jsr:@andyburke/lurid@0.2", "jsr:@andyburke/serverus@0.6", "jsr:@std/assert@^1.0.13", diff --git a/models/event.ts b/models/event.ts new file mode 100644 index 0000000..92f95ab --- /dev/null +++ b/models/event.ts @@ -0,0 +1,56 @@ +import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb'; +import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; + +/** + * @typedef {object} TIMESTAMPS + * @property {string} created when the event was created + * @property {string} updated when the event was last updated + */ + +/** + * Event + * + * @property {string} id - room_id(lurid):event_id(lurid) + * @property {string} creator_id - id of the source user + * @property {string} room_id - id of the target room + * @property {string} type - event type + * @property {string[]} [tags] - optional event tags + * @property {Record} [data] - optional data payload of the event + * @property {TIMESTAMPS} timestamps - timestamps that will be set by the server + */ +export type EVENT = { + id: string; + creator_id: string; + type: string; + tags?: string[]; + data?: Record; + timestamps: { + created: string; + updated: string; + }; +}; + +export const EVENTS = new FSDB_COLLECTION({ + name: 'events', + id_field: 'id', + organize: (combined_id) => { + const [room_id, event_id] = combined_id.split(':', 2); + return ['rooms', room_id, event_id.substring(0, 14), `${event_id}.json`]; + }, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + organize: by_lurid + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (event): string[] => { + return (event.tags ?? []).map((tag) => tag.toLowerCase()); + }, + organize: by_character + }) + } +}); diff --git a/models/room.ts b/models/room.ts new file mode 100644 index 0000000..a13210a --- /dev/null +++ b/models/room.ts @@ -0,0 +1,87 @@ +import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb'; +import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; + +/** + * @typedef {object} ROOM_PERMISSIONS + * @property {string[]} read a list of user_ids with read permission for the room + * @property {string[]} write a list of user_ids with write permission for the room + * @property {string[]} read_events a list of user_ids with read_events permission for this room + * @property {string[]} write_events a list of user_ids with write_events permission for this room + */ + +/** + * ROOM + * + * @property {string} id - lurid (stable) + * @property {string} name - channel name (max 64 characters, unique, unstable) + * @property {string} creator_id - user id of the room creator + * @property {ROOM_PERMISSIONS} permissions - permissions setup for the room + * @property {string} [icon_url] - optional url for room icon + * @property {string} [topic] - optional topic for the room + * @property {string} [rules] - optional room rules (Markdown/text) + * @property {string[]} [tags] - optional tags for the room + * @property {Record} [meta] - optional metadata about the room + * @property {Record} [emojis] - optional emojis table, eg: { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } + */ + +export type ROOM = { + id: string; + name: string; + creator_id: string; + permissions: { + read: string[]; + write: string[]; + read_events: string[]; + write_events: string[]; + }; + icon_url?: string; + topic?: string; + rules?: string; + tags?: string[]; + meta?: Record; + emojis?: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } + timestamps: { + created: string; + updated: string; + archived: string | undefined; + }; +}; + +export const ROOMS = new FSDB_COLLECTION({ + name: 'rooms', + id_field: 'id', + organize: by_lurid, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + to_many: true, + organize: by_lurid + }), + + name: new FSDB_INDEXER_SYMLINKS({ + name: 'name', + get_values_to_index: (room) => [room.name.toLowerCase()], + organize: by_character + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (room): string[] => { + return (room.tags ?? []).map((tag) => tag.toLowerCase()); + }, + to_many: true, + organize: by_character + }), + + topic: new FSDB_INDEXER_SYMLINKS({ + name: 'topic', + get_values_to_index: (room): string[] => { + return (room.topic ?? '').split(/\W/); + }, + to_many: true, + organize: by_character + }) + } +}); diff --git a/models/user.ts b/models/user.ts index a909927..92fd335 100644 --- a/models/user.ts +++ b/models/user.ts @@ -5,6 +5,7 @@ import { by_character } from 'jsr:@andyburke/fsdb/organizers'; export type USER = { id: string; username: string; + permissions: string[]; timestamps: { created: string; updated: string; diff --git a/models/user_permissions.ts b/models/user_permissions.ts deleted file mode 100644 index 7e0044e..0000000 --- a/models/user_permissions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb'; - -export type USER_PERMISSIONS = { - user_id: string; - permissions: string[]; - timestamps: { - created: string; - updated: string; - }; -}; - -export const PERMISSIONS_STORE = new FSDB_COLLECTION({ - name: 'user_permissions', - id_field: 'user_id' -}); diff --git a/public/api/rooms/:room_id/README.md b/public/api/rooms/:room_id/README.md new file mode 100644 index 0000000..d265c79 --- /dev/null +++ b/public/api/rooms/:room_id/README.md @@ -0,0 +1,23 @@ +# /api/rooms/:room_id + +Interact with a specific room. + +## GET /api/rooms/:room_id + +Get the room specified by `:room_id`. + +## PUT /api/rooms/:room_id + +Update the rooms specified by `:room_id`. + +Eg: + +``` +{ + name?: string; +} +``` + +## DELETE /api/rooms/:room_id + +Delete the room specified by `:room_id`. diff --git a/public/api/rooms/:room_id/events/:event_id/README.md b/public/api/rooms/:room_id/events/:event_id/README.md new file mode 100644 index 0000000..e69de29 diff --git a/public/api/rooms/:room_id/events/:event_id/index.ts b/public/api/rooms/:room_id/events/:event_id/index.ts new file mode 100644 index 0000000..9ffbb35 --- /dev/null +++ b/public/api/rooms/:room_id/events/:event_id/index.ts @@ -0,0 +1,162 @@ +import { EVENT, EVENTS } from '../../../../../../models/event.ts'; +import { ROOM, ROOMS } from '../../../../../../models/room.ts'; +import parse_body from '../../../../../../utils/bodyparser.ts'; +import * as CANNED_RESPONSES from '../../../../../../utils/canned_responses.ts'; +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../../utils/prechecks.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/rooms/:room_id/events/:id - Get an event +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public = room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || room.permissions.read.includes(meta.user.id); + const room_has_public_events = user_has_read_for_room && (room.permissions.read_events.length === 0); + const user_has_read_events_for_room = user_has_read_for_room && + (room_has_public_events || room.permissions.read_events.includes(meta.user.id)); + + if (!user_has_read_events_for_room) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + return Response.json(event, { + status: 200 + }); +} + +// PUT /api/rooms/:room_id/events/:event_id - Update event +PRECHECKS.PUT = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public: boolean = meta.room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || meta.room.permissions.read.includes(meta.user.id); + const room_events_are_publicly_writable = meta.room.permissions.write_events.length === 0; + const user_has_write_events_for_room = user_has_read_for_room && + (room_events_are_publicly_writable || meta.room.permissions.write_events.includes(meta.user.id)); + + if (!user_has_write_events_for_room) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function PUT(req: Request, meta: Record): Promise { + const now = new Date().toISOString(); + + try { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + if (event.creator_id !== meta.user.id) { + return CANNED_RESPONSES.permission_denied(); + } + + const body = await parse_body(req); + const updated = { + ...event, + ...body, + id: event.id, + creator_id: event.creator_id, + timestamps: { + created: event.timestamps.created, + updated: now + } + }; + + await EVENTS.update(updated); + return Response.json(updated, { + status: 200 + }); + } catch (err) { + return Response.json({ + error: { + message: (err as Error)?.message ?? 'Unknown error due to invalid data.', + cause: (err as Error)?.cause ?? 'invalid_data' + } + }, { + status: 400 + }); + } +} + +// DELETE /api/rooms/:room_id/events/:event_id - Delete event +PRECHECKS.DELETE = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public: boolean = meta.room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || meta.room.permissions.read.includes(meta.user.id); + const room_events_are_publicly_writable = meta.room.permissions.write_events.length === 0; + const user_has_write_events_for_room = user_has_read_for_room && + (room_events_are_publicly_writable || meta.room.permissions.write_events.includes(meta.user.id)); + + if (!user_has_write_events_for_room) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function DELETE(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + await EVENTS.delete(event); + + return Response.json({ + deleted: true + }, { + status: 200 + }); +} diff --git a/public/api/rooms/:room_id/events/README.md b/public/api/rooms/:room_id/events/README.md new file mode 100644 index 0000000..ddd37a6 --- /dev/null +++ b/public/api/rooms/:room_id/events/README.md @@ -0,0 +1,15 @@ +# /api/rooms/:room_id/events/:event_id + +Interact with a specific event. + +## GET /api/rooms/:room_id/events/:event_id + +Get the event specified by the tuple [ `:room_id`, `:event_id` ]. + +## PUT /api/rooms/:room_id/events/:event_id + +Update an event. + +## DELETE /api/rooms/:room_id/events/:event_id + +Delete an event. diff --git a/public/api/rooms/:room_id/events/index.ts b/public/api/rooms/:room_id/events/index.ts new file mode 100644 index 0000000..115823c --- /dev/null +++ b/public/api/rooms/:room_id/events/index.ts @@ -0,0 +1,111 @@ +import lurid from 'jsr:@andyburke/lurid'; +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; +import { ROOM, ROOMS } from '../../../../../models/room.ts'; +import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; +import { EVENT, EVENTS } from '../../../../../models/event.ts'; +import parse_body from '../../../../../utils/bodyparser.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/rooms/:room_id/events - get room events +// query parameters: +// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public: boolean = meta.room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || meta.room.permissions.read.includes(meta.user.id); + const room_events_are_public = meta.room.permissions.read_events.length === 0; + const user_has_read_events_for_room = user_has_read_for_room && + (room_events_are_public || meta.room.permissions.read_events.includes(meta.user.id)); + + if (!user_has_read_events_for_room) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(_req: Request, meta: Record): Promise { + const query: URLSearchParams = meta.query; + const partial_id: string | undefined = query.get('partial_id')?.toLowerCase().trim(); + + const has_partial_id = typeof partial_id === 'string' && partial_id.length >= 2; + if (!has_partial_id) { + return Response.json({ + error: { + message: 'You must specify a `partial_id` query parameter.', + cause: 'missing_query_parameter' + } + }, { + status: 400 + }); + } + + const limit = Math.min(parseInt(query.get('limit') ?? '10'), 100); + const events = await EVENTS.all({ + id_after: partial_id, + limit + }); + + return Response.json(events, { + status: 200 + }); +} + +// POST /api/rooms/:room_id/events - Create an event +PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public: boolean = meta.room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || meta.room.permissions.read.includes(meta.user.id); + const room_events_are_publicly_writable = meta.room.permissions.write_events.length === 0; + const user_has_write_events_for_room = user_has_read_for_room && + (room_events_are_publicly_writable || meta.room.permissions.write_events.includes(meta.user.id)); + + if (!user_has_write_events_for_room) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function POST(req: Request, meta: Record): Promise { + try { + const now = new Date().toISOString(); + const body = await parse_body(req); + const new_event: EVENT = { + type: 'unknown', + ...body, + id: `${meta.params.room_id}:${lurid()}`, + creator_id: meta.user.id, + timestamps: { + created: now, + updated: now + } + }; + + await EVENTS.create(new_event); + + return Response.json(new_event, { + status: 201 + }); + } catch (error) { + return Response.json({ + error: { + message: (error as Error).message ?? 'Unknown Error!', + cause: (error as Error).cause ?? 'unknown' + } + }, { status: 500 }); + } +} diff --git a/public/api/rooms/:room_id/index.ts b/public/api/rooms/:room_id/index.ts new file mode 100644 index 0000000..94a65a2 --- /dev/null +++ b/public/api/rooms/:room_id/index.ts @@ -0,0 +1,113 @@ +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; +import parse_body from '../../../../utils/bodyparser.ts'; +import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts'; +import { ROOM, ROOMS } from '../../../../models/room.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/rooms/:id - Get a room +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const room_is_public = room.permissions.read.length === 0; + const user_has_read_for_room = room_is_public || room.permissions.read.includes(meta.user.id); + + if (!user_has_read_for_room) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export function GET(_req: Request, meta: Record): Response { + return Response.json(meta.room, { + status: 200 + }); +} + +// PUT /api/rooms/:id - Update room +PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const user_has_write_for_room = room.permissions.write.includes(meta.user.id); + + if (!user_has_write_for_room) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function PUT(req: Request, meta: Record): Promise { + const now = new Date().toISOString(); + + try { + const body = await parse_body(req); + const updated = { + ...meta.room, + ...body, + id: meta.room.id, + timestamps: { + created: meta.room.timestamps.created, + updated: now + } + }; + + await ROOMS.update(updated); + return Response.json(updated, { + status: 200 + }); + } catch (err) { + return Response.json({ + error: { + message: (err as Error)?.message ?? 'Unknown error due to invalid data.', + cause: (err as Error)?.cause ?? 'invalid_data' + } + }, { + status: 400 + }); + } +} + +// DELETE /api/rooms/:id - Delete room +PRECHECKS.DELETE = [ + get_session, + get_user, + require_user, + async (_req: Request, meta: Record): Promise => { + const room_id: string = meta.params?.room_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const room: ROOM | null = room_id.length === 49 ? await ROOMS.get(room_id) : null; + + if (!room) { + return CANNED_RESPONSES.not_found(); + } + + meta.room = room; + const user_has_write_for_room = room.permissions.write.includes(meta.user.id); + + if (!user_has_write_for_room) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function DELETE(_req: Request, meta: Record): Promise { + await ROOMS.delete(meta.room); + + return Response.json({ + deleted: true + }, { + status: 200 + }); +} diff --git a/public/api/rooms/README.md b/public/api/rooms/README.md new file mode 100644 index 0000000..c46829a --- /dev/null +++ b/public/api/rooms/README.md @@ -0,0 +1,28 @@ +# /api/rooms + +Interact with rooms. + +## POST /api/rooms + +Create a new room. + +``` +export type ROOM = { + id: string; // unique id for this room + name: string; // the name of the room (max 128 characters) + icon_url?: string; // optional url for a room icon + topic?: string; // optional room topic + tags: string[]; // a list of tags for the room + meta: Record; + limits: { + users: number; + user_messages_per_minute: number; + }; + creator_id: string; // user_id of the room creator + emojis: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } +}; +``` + +## GET /api/rooms + +Get rooms. diff --git a/public/api/rooms/index.ts b/public/api/rooms/index.ts new file mode 100644 index 0000000..86183d1 --- /dev/null +++ b/public/api/rooms/index.ts @@ -0,0 +1,112 @@ +import lurid from 'jsr:@andyburke/lurid'; +import parse_body from '../../../utils/bodyparser.ts'; +import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; +import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; +import { PRECHECK_TABLE } from '../../../utils/prechecks.ts'; +import { ROOM, ROOMS } from '../../../models/room.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/rooms - get rooms +PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { + const can_read_rooms = meta.user?.permissions?.includes('rooms.read'); + + if (!can_read_rooms) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(_req: Request, meta: Record): Promise { + const query: URLSearchParams = meta.query; + const limit = Math.min(parseInt(query.get('limit') ?? '100'), 100); + const rooms = await ROOMS.all({ + limit + }); + + return Response.json(rooms, { + status: 200 + }); +} + +// POST /api/rooms - Create a room +PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { + const can_create_rooms = meta.user?.permissions?.includes('rooms.create'); + + if (!can_create_rooms) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function POST(req: Request, meta: Record): Promise { + try { + const now = new Date().toISOString(); + + const body = await parse_body(req); + + if (typeof body.name !== 'string' || body.name.length === 0) { + return Response.json({ + error: { + cause: 'missing_room_name', + message: 'You must specify a unique name for a room.' + } + }, { + status: 400 + }); + } + + if (body.name.length > 64) { + return Response.json({ + error: { + cause: 'invalid_room_name', + message: 'Room names must be 64 characters or fewer.' + } + }, { + status: 400 + }); + } + + const normalized_name = body.name.toLowerCase(); + + const existing_room = (await ROOMS.find({ + name: normalized_name + })).shift(); + if (existing_room) { + return Response.json({ + error: { + cause: 'room_name_conflict', + message: 'There is already a room with this name.' + } + }, { + status: 400 + }); + } + + const room: ROOM = { + ...body, + id: lurid(), + creator_id: meta.user.id, + permissions: { + read: (body.permissions?.read ?? []), + write: (body.permissions?.write ?? [meta.user.id]), + read_events: (body.permissions?.read_events ?? []), + write_events: (body.permissions?.write_events ?? []) + }, + timestamps: { + created: now, + updated: now, + archived: undefined + } + }; + + await ROOMS.create(room); + + return Response.json(room, { + status: 201 + }); + } catch (error) { + return Response.json({ + error: { + message: (error as Error).message ?? 'Unknown Error!', + cause: (error as Error).cause ?? 'unknown' + } + }, { status: 500 }); + } +} diff --git a/public/api/users/:id/README.md b/public/api/users/:id/README.md deleted file mode 100644 index c1c6c4e..0000000 --- a/public/api/users/:id/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# /api/users/:id - -Interact with a specific user. - -## GET /api/users/:id - -Get the user specified by `:id`. (user match/admin) - -## PUT /api/users/:id - -Update the user specified by `:id`. (user match/admin) - -``` -{ - username?: string; // update username -} -``` - -## DELETE /api/users/:id - -Delete the user specified by `:id`. (user match/admin) diff --git a/public/api/users/:user_id/README.md b/public/api/users/:user_id/README.md new file mode 100644 index 0000000..4029614 --- /dev/null +++ b/public/api/users/:user_id/README.md @@ -0,0 +1,21 @@ +# /api/users/:user_id + +Interact with a specific user. + +## GET /api/users/:user_id + +Get the user specified by `:user_id`. (user match/admin) + +## PUT /api/users/:user_id + +Update the user specified by `:user_id`. (user match/admin) + +``` +{ + username?: string; // update username +} +``` + +## DELETE /api/users/:user_id + +Delete the user specified by `:user_id`. (user match/admin) diff --git a/public/api/users/:id/index.ts b/public/api/users/:user_id/index.ts similarity index 64% rename from public/api/users/:id/index.ts rename to public/api/users/:user_id/index.ts index 0e45e93..9c2ec83 100644 --- a/public/api/users/:id/index.ts +++ b/public/api/users/:user_id/index.ts @@ -2,17 +2,16 @@ import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../.. import { PASSWORD_ENTRIES, PASSWORD_ENTRY } from '../../../../models/password_entry.ts'; import { SESSIONS } from '../../../../models/session.ts'; import { USER, USERS } from '../../../../models/user.ts'; -import { PERMISSIONS_STORE, USER_PERMISSIONS } from '../../../../models/user_permissions.ts'; import parse_body from '../../../../utils/bodyparser.ts'; -import { CANNED_RESPONSES } from '../../../../utils/canned_responses.ts'; +import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; // GET /api/users/:id - Get single user PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; - const can_read_self = meta.user_permissions?.permissions.includes('self.read'); - const can_read_others = meta.user_permissions?.permissions?.includes('users.read'); + const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.user_id; + const can_read_self = meta.user?.permissions.includes('self.read'); + const can_read_others = meta.user?.permissions?.includes('users.read'); const has_permission = can_read_others || (can_read_self && user_is_self); if (!has_permission) { @@ -20,7 +19,7 @@ PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Reco } }]; export async function GET(_req: Request, meta: Record): Promise { - const user_id: string = meta.params?.id?.toLowerCase().trim() ?? ''; + const user_id: string = meta.params?.user_id?.toLowerCase().trim() ?? ''; const user: USER | null = user_id.length === 49 ? await USERS.get(user_id) : null; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" if (!user) { @@ -40,19 +39,20 @@ export async function GET(_req: Request, meta: Record): Promise): Response | undefined => { - const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; - const can_write_self = meta.user_permissions?.permissions.includes('self.write'); - const can_write_others = meta.user_permissions?.permissions?.includes('users.write'); +PRECHECKS.PUT = [get_session, get_user, require_user, (req: Request, meta: Record): Response | undefined => { + const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.user_id; + const can_write_self = meta.user?.permissions.includes('self.write'); + const can_write_others = meta.user?.permissions?.includes('users.write'); + const is_a_test_override = Deno.env.get('DENO_ENV') === 'test' && !!req.headers.get('x-test-override'); - const has_permission = can_write_others || (can_write_self && user_is_self); + const has_permission = is_a_test_override || can_write_others || (can_write_self && user_is_self); if (!has_permission) { return CANNED_RESPONSES.permission_denied(); } }]; -export async function PUT(req: Request, meta: { params: Record }): Promise { +export async function PUT(req: Request, meta: Record): Promise { const now = new Date().toISOString(); - const id: string = meta.params.id ?? ''; + const id: string = meta.params.user_id ?? ''; const existing = await USERS.get(id); if (!existing) { @@ -68,15 +68,25 @@ export async function PUT(req: Request, meta: { params: Record }): try { const body = await parse_body(req); - const updated = { + const updated: USER = { ...existing, - username: body.username || existing.username, + ...body, timestamps: { created: existing.timestamps.created, updated: now } }; + if (Array.isArray(body.permissions) && body.permissions.join(':') !== existing.permissions.join(':')) { + const user_can_write_others = meta.user.permissions.includes('users.write'); + const is_a_test_override = Deno.env.get('DENO_ENV') === 'test' && req.headers.get('x-test-override'); + const is_allowed = user_can_write_others || is_a_test_override; + + if (!is_allowed) { + return CANNED_RESPONSES.permission_denied(); + } + } + await USERS.update(updated); return Response.json(updated, { status: 200 @@ -95,17 +105,17 @@ export async function PUT(req: Request, meta: { params: Record }): // DELETE /api/users/:id - Delete user PRECHECKS.DELETE = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; - const can_write_self = meta.user_permissions?.permissions.includes('self.write'); - const can_write_others = meta.user_permissions?.permissions?.includes('users.write'); + const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.user_id; + const can_write_self = meta.user?.permissions.includes('self.write'); + const can_write_others = meta.user?.permissions?.includes('users.write'); const has_permission = can_write_others || (can_write_self && user_is_self); if (!has_permission) { return CANNED_RESPONSES.permission_denied(); } }]; -export async function DELETE(_req: Request, meta: { params: Record }): Promise { - const user_id: string = meta.params.id ?? ''; +export async function DELETE(_req: Request, meta: Record): Promise { + const user_id: string = meta.params.user_id ?? ''; const user: USER | null = await USERS.get(user_id); if (!user) { @@ -123,10 +133,6 @@ export async function DELETE(_req: Request, meta: { params: Record if (password_entry) { await PASSWORD_ENTRIES.delete(password_entry); } - const user_permissions: USER_PERMISSIONS | null = await PERMISSIONS_STORE.get(user_id); - if (user_permissions) { - await PERMISSIONS_STORE.delete(user_permissions); - } const sessions = await SESSIONS.find({ user_id diff --git a/public/api/users/index.ts b/public/api/users/index.ts index 558dfe3..ff9d6ec 100644 --- a/public/api/users/index.ts +++ b/public/api/users/index.ts @@ -1,6 +1,5 @@ import { PASSWORD_ENTRIES, PASSWORD_ENTRY } from '../../../models/password_entry.ts'; import { USER, USERS } from '../../../models/user.ts'; -import { PERMISSIONS_STORE, USER_PERMISSIONS } from '../../../models/user_permissions.ts'; import { generateSecret } from 'jsr:@stdext/crypto/utils'; import { hash } from 'jsr:@stdext/crypto/hash'; import lurid from 'jsr:@andyburke/lurid'; @@ -9,19 +8,20 @@ import parse_body from '../../../utils/bodyparser.ts'; import { create_new_session, SESSION_RESULT } from '../auth/index.ts'; import { PRECHECKS } from './me/index.ts'; import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; -import { CANNED_RESPONSES } from '../../../utils/canned_responses.ts'; +import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; // TODO: figure out a better solution for doling out permissions const DEFAULT_USER_PERMISSIONS: string[] = [ 'self.read', - 'self.write' + 'self.write', + 'rooms.read' ]; // GET /api/users - get users // query parameters: // partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_others = meta.user_permissions?.permissions?.includes('users.read'); + const can_read_others = meta.user?.permissions?.includes('users.read'); if (!can_read_others) { return CANNED_RESPONSES.permission_denied(); @@ -97,6 +97,7 @@ export async function POST(req: Request, meta: Record): Promise): Promise) => Promise> = {}; @@ -6,15 +6,9 @@ export const PRECHECKS: PRECHECK_TABLE = {}; // GET /api/users/me - Get the current user PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_self = meta.user_permissions?.permissions.includes('self.read'); + const can_read_self = meta.user?.permissions.includes('self.read'); - const has_permission = can_read_self; - console.dir({ - meta, - can_read_self, - has_permission - }); - if (!has_permission) { + if (!can_read_self) { return CANNED_RESPONSES.permission_denied(); } }]; diff --git a/tests/api/rooms/create_room.test.ts b/tests/api/rooms/create_room.test.ts new file mode 100644 index 0000000..7a4ac1b --- /dev/null +++ b/tests/api/rooms/create_room.test.ts @@ -0,0 +1,80 @@ +import { api, API_CLIENT } from '../../../utils/api.ts'; +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../helpers.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - Create', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const user_info = await get_new_user(client); + + try { + const _permission_denied_room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + name: 'this should not be allowed' + } + }); + + asserts.fail('allowed creation of a room without room creation permissions'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'permission_denied'); + } + + await set_user_permissions(client, user_info.user, user_info.session, [...user_info.user.permissions, 'rooms.create']); + + try { + const _too_long_name_room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + name: 'X'.repeat(1024) + } + }); + + asserts.fail('allowed creation of a room with an excessively long name'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'invalid_room_name'); + } + + const new_room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + name: 'test room' + } + }); + + asserts.assert(new_room); + } finally { + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/api/rooms/delete_room.test.ts b/tests/api/rooms/delete_room.test.ts new file mode 100644 index 0000000..e4a261a --- /dev/null +++ b/tests/api/rooms/delete_room.test.ts @@ -0,0 +1,56 @@ +import { api, API_CLIENT } from '../../../utils/api.ts'; +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../helpers.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - Delete', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const user_info = await get_new_user(client); + + await set_user_permissions(client, user_info.user, user_info.session, [...user_info.user.permissions, 'rooms.create']); + + const new_room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + name: 'test delete room' + } + }); + + asserts.assert(new_room); + + const deleted_room = await client.fetch(`/rooms/${new_room.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + } + }); + + asserts.assert(deleted_room); + } finally { + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/api/rooms/events/create_events.test.ts b/tests/api/rooms/events/create_events.test.ts new file mode 100644 index 0000000..1561f46 --- /dev/null +++ b/tests/api/rooms/events/create_events.test.ts @@ -0,0 +1,121 @@ +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../../helpers.ts'; +import { api, API_CLIENT } from '../../../../utils/api.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - EVENTS - Create', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const owner_info = await get_new_user(client); + + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'rooms.create']); + + const room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + name: 'test events room', + permissions: { + write_events: [owner_info.user.id] + } + } + }); + + asserts.assert(room); + + const event_from_owner = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + type: 'test', + data: { + foo: 'bar' + } + } + }); + + asserts.assert(event_from_owner); + + const other_user_info = await get_new_user(client); + + try { + const _permission_denied_room = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'test', + data: { + other_user: true + } + } + }); + + asserts.fail('allowed adding an event to a room without permission'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'permission_denied'); + } + + // make the room public write + const updated_by_owner_room = await client.fetch(`/rooms/${room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + permissions: { + ...room.permissions, + write_events: [] + } + } + }); + + asserts.assert(updated_by_owner_room); + asserts.assertEquals(updated_by_owner_room.permissions.write_events, []); + + const event_from_other_user = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'test', + data: { + other_user: true + } + } + }); + + asserts.assert(event_from_other_user); + } finally { + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/api/rooms/events/update_events.test.ts b/tests/api/rooms/events/update_events.test.ts new file mode 100644 index 0000000..1e3d55a --- /dev/null +++ b/tests/api/rooms/events/update_events.test.ts @@ -0,0 +1,243 @@ +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../../helpers.ts'; +import { api, API_CLIENT } from '../../../../utils/api.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - EVENTS - Update', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const owner_info = await get_new_user(client); + + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'rooms.create']); + + const room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + name: 'test update events room' + } + }); + + asserts.assert(room); + + const event_from_owner = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + type: 'test', + data: { + foo: 'bar' + } + } + }); + + asserts.assert(event_from_owner); + + const fetched_event_from_owner = await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'GET', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + } + }); + + asserts.assertEquals(fetched_event_from_owner, event_from_owner); + + const updated_event_from_owner = await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'PUT', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + type: 'other', + data: { + foo: 'baz' + } + } + }); + + asserts.assertNotEquals(updated_event_from_owner, event_from_owner); + asserts.assertEquals(updated_event_from_owner.type, 'other'); + asserts.assertEquals(updated_event_from_owner.data.foo, 'baz'); + + const fetched_updated_event_from_owner = await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'GET', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + } + }); + + asserts.assertEquals(fetched_updated_event_from_owner, updated_event_from_owner); + asserts.assertNotEquals(fetched_updated_event_from_owner, fetched_event_from_owner); + asserts.assertEquals(fetched_updated_event_from_owner, updated_event_from_owner); + + const other_user_info = await get_new_user(client); + + const event_from_other_user = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'test', + data: { + other_user: true + } + } + }); + + asserts.assert(event_from_other_user); + + const fetched_event_from_other_user = await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'GET', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.assertEquals(fetched_event_from_other_user, event_from_other_user); + + const updated_event_from_other_user = await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'PUT', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'other', + data: { + other_user: 'bloop' + } + } + }); + + asserts.assertNotEquals(updated_event_from_other_user, event_from_other_user); + asserts.assertEquals(updated_event_from_other_user.type, 'other'); + asserts.assertEquals(updated_event_from_other_user.data.other_user, 'bloop'); + + const fetched_updated_event_from_other_user = await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'GET', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.assertEquals(fetched_updated_event_from_other_user, updated_event_from_other_user); + asserts.assertNotEquals(fetched_updated_event_from_other_user, fetched_event_from_other_user); + asserts.assertEquals(fetched_updated_event_from_other_user, updated_event_from_other_user); + + const updated_by_owner_room = await client.fetch(`/rooms/${room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + permissions: { + ...room.permissions, + write_events: [owner_info.user.id] + } + } + }); + + asserts.assertEquals(updated_by_owner_room.permissions.write_events, [owner_info.user.id]); + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'PUT', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'new' + } + }); + + asserts.fail('allowed updating an event in a room with a write_events allowed only by owner'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'permission_denied'); + } + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.fail('allowed deleting an event in a room with a write_events allowed only by owner'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'permission_denied'); + } + + const publicly_writable_room = await client.fetch(`/rooms/${room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + permissions: { + ...room.permissions, + write_events: [] + } + } + }); + + asserts.assertEquals(publicly_writable_room.permissions.write_events, []); + + const delete_other_user_event_response = await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.assertEquals(delete_other_user_event_response.deleted, true); + + const delete_owner_event_response = await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + } + }); + + asserts.assertEquals(delete_owner_event_response.deleted, true); + } finally { + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/api/rooms/events/update_events_when_append_only.test.ts b/tests/api/rooms/events/update_events_when_append_only.test.ts new file mode 100644 index 0000000..d94f7af --- /dev/null +++ b/tests/api/rooms/events/update_events_when_append_only.test.ts @@ -0,0 +1,167 @@ +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../../helpers.ts'; +import { api, API_CLIENT } from '../../../../utils/api.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - EVENTS - Update (APPEND_ONLY_EVENTS)', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + Deno.env.set('APPEND_ONLY_EVENTS', 'true'); + asserts.assert(Deno.env.get('APPEND_ONLY_EVENTS')); + + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const owner_info = await get_new_user(client); + + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'rooms.create']); + + const room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + name: 'test update events room in append only mode' + } + }); + + asserts.assert(room); + + const event_from_owner = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + type: 'test', + data: { + foo: 'bar' + } + } + }); + + asserts.assert(event_from_owner); + + const fetched_event_from_owner = await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'GET', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + } + }); + + asserts.assertEquals(fetched_event_from_owner, event_from_owner); + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'PUT', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + }, + json: { + type: 'new' + } + }); + + asserts.fail('allowed updating an event in a room with APPEND_ONLY_EVENTS on'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'append_only_events'); + } + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_owner.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': owner_info.session.id, + 'x-totp': await generateTotp(owner_info.session.secret) + } + }); + + asserts.fail('allowed deleting an event in a room with APPEND_ONLY_EVENTS on'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'append_only_events'); + } + + const other_user_info = await get_new_user(client); + + const event_from_other_user = await client.fetch(`/rooms/${room.id}/events`, { + method: 'POST', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'test', + data: { + other_user: true + } + } + }); + + asserts.assert(event_from_other_user); + + const fetched_event_from_other_user = await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'GET', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.assertEquals(fetched_event_from_other_user, event_from_other_user); + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'PUT', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + type: 'new' + } + }); + + asserts.fail('allowed updating an event in a room with APPEND_ONLY_EVENTS on'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'append_only_events'); + } + + try { + await client.fetch(`/rooms/${room.id}/events/${event_from_other_user.id}`, { + method: 'DELETE', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + } + }); + + asserts.fail('allowed deleting an event in a room with APPEND_ONLY_EVENTS on'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'append_only_events'); + } + } finally { + Deno.env.delete('APPEND_ONLY_EVENTS'); + + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/api/rooms/update_room.test.ts b/tests/api/rooms/update_room.test.ts new file mode 100644 index 0000000..294ec18 --- /dev/null +++ b/tests/api/rooms/update_room.test.ts @@ -0,0 +1,99 @@ +import { api, API_CLIENT } from '../../../utils/api.ts'; +import * as asserts from 'jsr:@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from '../../helpers.ts'; +import { generateTotp } from '@stdext/crypto/totp'; + +Deno.test({ + name: 'API - ROOMS - Update', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + try { + test_server_info = await get_ephemeral_listen_server(); + const client: API_CLIENT = api({ + prefix: '/api', + hostname: test_server_info.hostname, + port: test_server_info.port + }); + + const user_info = await get_new_user(client); + + await set_user_permissions(client, user_info.user, user_info.session, [...user_info.user.permissions, 'rooms.create']); + + const new_room = await client.fetch('/rooms', { + method: 'POST', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + name: 'test update room' + } + }); + + asserts.assert(new_room); + + const other_user_info = await get_new_user(client); + + try { + const _permission_denied_room = await client.fetch(`/rooms/${new_room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + name: 'this should not be allowed' + } + }); + + asserts.fail('allowed updating a room owned by someone else'); + } catch (error) { + asserts.assertEquals((error as Error).cause, 'permission_denied'); + } + + const updated_by_owner_room = await client.fetch(`/rooms/${new_room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': user_info.session.id, + 'x-totp': await generateTotp(user_info.session.secret) + }, + json: { + topic: 'this is a new topic', + permissions: { + ...new_room.permissions, + write: [...new_room.permissions.write, other_user_info.user.id] + } + } + }); + + asserts.assert(updated_by_owner_room); + asserts.assertEquals(updated_by_owner_room.topic, 'this is a new topic'); + asserts.assertEquals(updated_by_owner_room.permissions.write, [user_info.user.id, other_user_info.user.id]); + + const updated_by_other_user_room = await client.fetch(`/rooms/${new_room.id}`, { + method: 'PUT', + headers: { + 'x-session_id': other_user_info.session.id, + 'x-totp': await generateTotp(other_user_info.session.secret) + }, + json: { + topic: 'this is a newer topic' + } + }); + + asserts.assert(updated_by_other_user_room); + asserts.assertEquals(updated_by_other_user_room.topic, 'this is a newer topic'); + asserts.assertEquals(updated_by_other_user_room.permissions.write, [user_info.user.id, other_user_info.user.id]); + } finally { + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 9899760..776fd7e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,5 +1,8 @@ import { SERVER, SERVER_OPTIONS } from 'jsr:@andyburke/serverus/server'; import { convert_to_words } from 'jsr:@andyburke/lurid/word_bytes'; +import { API_CLIENT } from '../utils/api.ts'; +import { Cookie, getSetCookies } from '@std/http/cookie'; +import { generateTotp } from '@stdext/crypto/totp'; const TLDs: string[] = [ 'com', @@ -94,3 +97,63 @@ export async function get_ephemeral_listen_server(options?: SERVER_OPTIONS): Pro return ephemeral_server; } + +type NEW_USER_INFO = { + username: string; + password: string; +}; + +export async function get_new_user(client: API_CLIENT, user_info?: Record): Promise> { + const info: Record = { + username: random_username(), + password: `${random_username()} ! ${random_username()}`, + ...user_info + }; + + await client.fetch('/users', { + method: 'POST', + json: info + }); + + const cookies: Cookie[] = []; + const auth_response: any = await client.fetch('/auth', { + method: 'POST', + json: info, + done: (response) => { + cookies.push(...getSetCookies(response.headers)); + } + }); + + info.user = auth_response.user; + info.session = auth_response.session; + + cookies.push({ + name: 'totp', + value: await generateTotp(info.session?.secret), + maxAge: 30, + expires: Date.now() + 30_000, + path: '/' + }); + + info.headers_for_get = new Headers(); + for (const cookie of cookies) { + info.headers_for_get.append(`x-${cookie.name}`, cookie.value); + } + info.headers_for_get.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); + + return info; +} + +export async function set_user_permissions(client: API_CLIENT, user: any, session: any, permissions: string[]): Promise { + return await client.fetch(`/users/${user.id}`, { + method: 'PUT', + headers: { + 'x-session_id': session.id, + 'x-totp': await generateTotp(session.secret), + 'x-test-override': 'true' + }, + json: { + permissions + } + }); +} diff --git a/utils/api.ts b/utils/api.ts index 05261e3..116ade6 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -2,7 +2,7 @@ import { getSetCookies } from '@std/http/cookie'; import { generateTotp } from '@stdext/crypto/totp'; export interface API_CLIENT { - fetch: (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => Promise; + fetch: (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => Promise; } export type API_CONFIG = { @@ -43,8 +43,8 @@ export function api(api_config?: Record): API_CLIENT { ...(api_config ?? {}) }; - return { - fetch: async (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => { + const client: API_CLIENT = { + fetch: async (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any): Promise => { const prefix: string = `${config.protocol}//${config.hostname}:${config.port}${config.prefix}`; const retry: RETRY_OPTIONS = options?.retry ?? { limit: 0, @@ -100,11 +100,14 @@ export function api(api_config?: Record): API_CLIENT { continue; } - if (response.status > 400) { + if (response.status >= 400) { const error_response = await response.json(); - throw new Error('Bad Request', { - cause: error_response?.cause ?? JSON.stringify(error_response) - }); + throw new Error( + error_response.error?.message ?? 'Bad Reqeest', + error_response.error ?? { + cause: error_response?.cause ?? JSON.stringify(error_response) + } + ); } const data = await response.json(); @@ -118,4 +121,6 @@ export function api(api_config?: Record): API_CLIENT { } while (retries < retry.limit); } }; + + return client; } diff --git a/utils/canned_responses.ts b/utils/canned_responses.ts index 97b567d..2a33763 100644 --- a/utils/canned_responses.ts +++ b/utils/canned_responses.ts @@ -1,13 +1,41 @@ -export const CANNED_RESPONSES: Record Response> = { - permission_denied: (): Response => { - console.trace('denied'); - return Response.json({ - error: { - message: 'Permission denied.', - cause: 'permission_denied' - } - }, { - status: 400 - }); +export function not_found(): Response { + if (Deno.env.get('TRACE_ERROR_RESPONSES')) { + console.trace('not_found'); } -}; + return Response.json({ + error: { + message: 'Not found.', + cause: 'not_found' + } + }, { + status: 404 + }); +} + +export function permission_denied(): Response { + if (Deno.env.get('TRACE_ERROR_RESPONSES')) { + console.trace('permission_denied'); + } + return Response.json({ + error: { + message: 'Permission denied.', + cause: 'permission_denied' + } + }, { + status: 400 + }); +} + +export function append_only_events(): Response { + if (Deno.env.get('TRACE_ERROR_RESPONSES')) { + console.trace('append_only_events'); + } + return Response.json({ + error: { + message: 'This server does not allow modifying events.', + cause: 'append_only_events' + } + }, { + status: 400 + }); +} diff --git a/utils/prechecks.ts b/utils/prechecks.ts index 5cc58c8..f3d0333 100644 --- a/utils/prechecks.ts +++ b/utils/prechecks.ts @@ -2,8 +2,7 @@ import { getCookies } from 'jsr:@std/http/cookie'; import { SESSIONS } from '../models/session.ts'; import { verifyTotp } from 'jsr:@stdext/crypto/totp'; import { USERS } from '../models/user.ts'; -import { PERMISSIONS_STORE } from '../models/user_permissions.ts'; -import { CANNED_RESPONSES } from './canned_responses.ts'; +import * as CANNED_RESPONSES from './canned_responses.ts'; export type PRECHECK = (req: Request, meta: Record) => Promise | Response | undefined; export type PRECHECK_TABLE = Record; @@ -30,7 +29,6 @@ export async function get_user(request: Request, meta: Record): Pro meta.cookies = meta.cookies ?? getCookies(request.headers); meta.user = meta.valid_totp && meta.session ? await USERS.get(meta.session.user_id) : null; - meta.user_permissions = meta.valid_totp && meta.session ? await PERMISSIONS_STORE.get(meta.session.user_id) : null; } export function require_user(