Repository navigation
feat(endpoint-microsub): PR 1 - Core Microsub server with channels and timeline #944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+3,209
−79
Merged
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
e6b8f79
feat(endpoint-microsub): add core Microsub server with channels and t…
rmdes cdd714a
ci: add microsub endpoint to development config
paulrobertlloyd 7d2c6a6
feat(endpoint-microsub): add plug-in icon
paulrobertlloyd 0e41045
fix(endpoint-microsub): use same mongodb version as indiekit
rmdes 818707a
style(endpoint-microsub): fix eslint errors
rmdes eb32e2f
test(endpoint-microsub): add unit and integration tests
rmdes 96036f8
refactor(endpoint-microsub): use getObjectId from @indiekit/util
rmdes 182ec81
chore: update lockfile for endpoint-microsub dependency change
rmdes 76c927d
refactor(endpoint-microsub): use utility methods for uid and logging
rmdes 3a2af4f
test(endpoint-microsub): drop the constant parameter eslint rejects
rmdes 4bd122e
chore(endpoint-microsub): satisfy strictNullChecks and the new lint r…
rmdes 7992fbe
refactor(endpoint-microsub): use mongodb's ObjectId now that util no …
rmdes a022176
feat(endpoint-microsub): page the timeline with the shared cursor
rmdes 615154a
refactor(endpoint-microsub): reference channels by uid, not by Mongo id
rmdes 8332296
refactor(endpoint-microsub): address review
rmdes 1270f88
refactor(endpoint-microsub): type the localiser, drop the uid retry, …
rmdes 66802a0
chore(endpoint-microsub): no init logging, README and docs page, engi…
rmdes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import express from "express"; | ||
|
|
||
| import { microsubController } from "./lib/controllers/microsub.js"; | ||
| import { createIndexes } from "./lib/storage/items.js"; | ||
|
|
||
| const defaults = { | ||
| mountPath: "/microsub", | ||
| }; | ||
| const router = express.Router(); | ||
|
|
||
| export default class MicrosubEndpoint { | ||
| name = "Microsub endpoint"; | ||
|
|
||
| /** | ||
| * @param {object} options - Plugin options | ||
| * @param {string} [options.mountPath] - Path to mount Microsub endpoint | ||
| */ | ||
| constructor(options = {}) { | ||
| this.options = { ...defaults, ...options }; | ||
| this.mountPath = this.options.mountPath; | ||
| } | ||
|
|
||
| /** | ||
| * Microsub API routes (authenticated) | ||
| * @returns {import("express").Router} Express router | ||
| */ | ||
| get routes() { | ||
| // Main Microsub endpoint - dispatches based on action parameter | ||
| router.get("/", microsubController.get); | ||
| router.post("/", microsubController.post); | ||
|
|
||
| return router; | ||
| } | ||
|
|
||
| /** | ||
| * Initialize plugin | ||
| * @param {object} indiekit - Indiekit instance | ||
| */ | ||
| async init(indiekit) { | ||
| console.info("[Microsub] Initializing endpoint-microsub plugin"); | ||
|
|
||
| // Register MongoDB collections | ||
| indiekit.addCollection("microsub_channels"); | ||
| indiekit.addCollection("microsub_items"); | ||
|
|
||
| console.info("[Microsub] Registered MongoDB collections"); | ||
|
paulrobertlloyd marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Register endpoint | ||
| indiekit.addEndpoint(this); | ||
|
|
||
| // Set microsub endpoint URL in config | ||
| if (!indiekit.config.application.microsubEndpoint) { | ||
| indiekit.config.application.microsubEndpoint = this.mountPath; | ||
| } | ||
|
|
||
| // Create indexes for optimal performance | ||
| if (indiekit.database) { | ||
| try { | ||
| await createIndexes(indiekit); | ||
| } catch (error) { | ||
| console.warn( | ||
| "[Microsub] Index creation failed:", | ||
| error instanceof Error ? error.message : String(error), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| /** | ||
| * Channel management controller | ||
| * @module controllers/channels | ||
| */ | ||
|
|
||
| import { IndiekitError } from "@indiekit/error"; | ||
|
|
||
| import { | ||
| getChannels, | ||
| createChannel, | ||
| updateChannel, | ||
| deleteChannel, | ||
| reorderChannels, | ||
| } from "../storage/channels.js"; | ||
| import { | ||
| validateChannel, | ||
| validateChannelName, | ||
| parseArrayParameter, | ||
| } from "../utils/validation.js"; | ||
|
|
||
| /** | ||
| * List all channels | ||
| * GET ?action=channels | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| */ | ||
| export async function list(request, response) { | ||
| const { application, publication } = request.app.locals; | ||
|
|
||
| const channels = await getChannels(application, publication.me); | ||
|
|
||
| response.json({ channels }); | ||
| } | ||
|
|
||
| /** | ||
| * Handle channel actions (create, update, delete, order) | ||
| * POST ?action=channels | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function action(request, response) { | ||
| const { application, publication } = request.app.locals; | ||
| const { __ } = response.locals; | ||
| const userId = publication.me; | ||
| const { method, name, uid } = request.body; | ||
|
|
||
| // Delete channel | ||
| if (method === "delete") { | ||
| validateChannel(__, uid); | ||
|
|
||
| const deleted = await deleteChannel(application, uid, userId); | ||
| if (!deleted) { | ||
| throw IndiekitError.notFound(__("microsub.error.channelNotFound")); | ||
| } | ||
|
|
||
| return response.json({ deleted: uid }); | ||
| } | ||
|
|
||
| // Reorder channels | ||
| if (method === "order") { | ||
| const channelUids = parseArrayParameter(request.body, "channels"); | ||
| if (channelUids.length === 0) { | ||
| throw IndiekitError.badRequest( | ||
| __("BadRequestError.missingParameter", "channels"), | ||
| ); | ||
| } | ||
|
|
||
| await reorderChannels(application, channelUids, userId); | ||
|
|
||
| const channels = await getChannels(application, userId); | ||
| return response.json({ channels }); | ||
| } | ||
|
|
||
| // Update existing channel | ||
| if (uid) { | ||
| validateChannel(__, uid); | ||
|
|
||
| if (name) { | ||
| validateChannelName(__, name); | ||
| } | ||
|
|
||
| const channel = await updateChannel(application, uid, { name }, userId); | ||
| if (!channel) { | ||
| throw IndiekitError.notFound(__("microsub.error.channelNotFound")); | ||
| } | ||
|
|
||
| return response.json({ | ||
| uid: channel.uid, | ||
| name: channel.name, | ||
| }); | ||
| } | ||
|
|
||
| // Create new channel | ||
| validateChannelName(__, name); | ||
|
|
||
| const channel = await createChannel(application, { name, userId }); | ||
|
|
||
| response.status(201).json({ | ||
| uid: channel.uid, | ||
| name: channel.name, | ||
| }); | ||
| } | ||
|
|
||
| export const channelsController = { list, action }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /** | ||
| * Main Microsub action router | ||
| * @module controllers/microsub | ||
| */ | ||
|
|
||
| import { IndiekitError } from "@indiekit/error"; | ||
|
|
||
| import { validateAction } from "../utils/validation.js"; | ||
|
|
||
| import { list as listChannels, action as channelAction } from "./channels.js"; | ||
| import { get as getTimeline, action as timelineAction } from "./timeline.js"; | ||
|
|
||
| /** | ||
| * Route GET requests to appropriate action handler | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| * @param {import("express").NextFunction} next - Express next function | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function get(request, response, next) { | ||
| try { | ||
| const { action } = request.query; | ||
|
|
||
| if (!action) { | ||
| // Return basic endpoint info | ||
| return response.json({ | ||
| type: "microsub", | ||
| actions: ["channels", "timeline"], | ||
| }); | ||
| } | ||
|
|
||
| validateAction(response.locals.__, action); | ||
|
|
||
| switch (action) { | ||
| case "channels": { | ||
| return listChannels(request, response); | ||
| } | ||
|
|
||
| case "timeline": { | ||
| return getTimeline(request, response); | ||
| } | ||
|
|
||
| default: { | ||
| throw IndiekitError.badRequest( | ||
| response.locals.__("BadRequestError.invalidValue", "action"), | ||
| ); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Route POST requests to appropriate action handler | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| * @param {import("express").NextFunction} next - Express next function | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function post(request, response, next) { | ||
| try { | ||
| const action = request.body.action || request.query.action; | ||
| validateAction(response.locals.__, action); | ||
|
|
||
| switch (action) { | ||
| case "channels": { | ||
| return channelAction(request, response); | ||
| } | ||
|
|
||
| case "timeline": { | ||
| return timelineAction(request, response); | ||
| } | ||
|
|
||
| default: { | ||
| throw IndiekitError.badRequest( | ||
| response.locals.__("BadRequestError.invalidValue", "action"), | ||
| ); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| } | ||
|
|
||
| export const microsubController = { get, post }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| /** | ||
| * Timeline controller | ||
| * @module controllers/timeline | ||
| */ | ||
|
|
||
| import { IndiekitError } from "@indiekit/error"; | ||
|
|
||
| import { getChannel } from "../storage/channels.js"; | ||
| import { | ||
| getTimelineItems, | ||
| markItemsRead, | ||
| markItemsUnread, | ||
| removeItems, | ||
| } from "../storage/items.js"; | ||
| import { | ||
| validateChannel, | ||
| validateEntries, | ||
| parseArrayParameter, | ||
| } from "../utils/validation.js"; | ||
|
|
||
| /** | ||
| * Get timeline items for a channel | ||
| * GET ?action=timeline&channel=<uid> | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| */ | ||
| export async function get(request, response) { | ||
| const { application, publication } = request.app.locals; | ||
| const { __ } = response.locals; | ||
| const userId = publication.me; | ||
| const { channel, before, after, limit } = request.query; | ||
|
|
||
| validateChannel(__, channel); | ||
|
|
||
| // Verify channel exists | ||
| const channelDocument = await getChannel(application, channel, userId); | ||
| if (!channelDocument) { | ||
| throw IndiekitError.notFound(__("microsub.error.channelNotFound")); | ||
| } | ||
|
|
||
| const timeline = await getTimelineItems(application, channelDocument.uid, { | ||
| before, | ||
| after, | ||
| limit, | ||
| userId, | ||
| }); | ||
|
|
||
| response.json(timeline); | ||
| } | ||
|
|
||
| /** | ||
| * Handle timeline actions (mark_read, mark_unread, remove) | ||
| * POST ?action=timeline | ||
| * @param {object} request - Express request | ||
| * @param {object} response - Express response | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function action(request, response) { | ||
| const { application, publication } = request.app.locals; | ||
| const { __ } = response.locals; | ||
| const userId = publication.me; | ||
| const { method, channel } = request.body; | ||
|
|
||
| validateChannel(__, channel); | ||
|
|
||
| // Verify channel exists | ||
| const channelDocument = await getChannel(application, channel, userId); | ||
| if (!channelDocument) { | ||
| throw IndiekitError.notFound(__("microsub.error.channelNotFound")); | ||
| } | ||
|
|
||
| // Get entry IDs from request | ||
| const entries = parseArrayParameter(request.body, "entry"); | ||
|
|
||
| switch (method) { | ||
| case "mark_read": { | ||
| validateEntries(__, entries); | ||
| const count = await markItemsRead( | ||
| application, | ||
| channelDocument.uid, | ||
| entries, | ||
| userId, | ||
| ); | ||
| return response.json({ result: "ok", updated: count }); | ||
| } | ||
|
|
||
| case "mark_unread": { | ||
| validateEntries(__, entries); | ||
| const count = await markItemsUnread( | ||
| application, | ||
| channelDocument.uid, | ||
| entries, | ||
| userId, | ||
| ); | ||
| return response.json({ result: "ok", updated: count }); | ||
| } | ||
|
|
||
| case "remove": { | ||
| validateEntries(__, entries); | ||
| const count = await removeItems( | ||
| application, | ||
| channelDocument.uid, | ||
| entries, | ||
| ); | ||
| return response.json({ result: "ok", removed: count }); | ||
| } | ||
|
|
||
| default: { | ||
| throw IndiekitError.badRequest( | ||
| __("BadRequestError.invalidValue", "method"), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const timelineController = { get, action }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.