Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Townhall ${{ github.ref_name }}"
releaseBody: "Download the Townhall desktop app for your platform."
releaseName: "Lor ${{ github.ref_name }}"
releaseBody: "Download the Lor desktop app for your platform."
releaseDraft: true
prerelease: false
projectPath: apps/desktop
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pnpm run check-types # TypeScript type checking across all packages

## Architecture

This is a pnpm + Turborepo monorepo for **Townhall**, an open-source Discord alternative.
This is a pnpm + Turborepo monorepo for **Lor** — the AI multiplayer workspace for teams.

### Workspaces

Expand Down
5 changes: 3 additions & 2 deletions PIVOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,10 @@ Three buckets. Execute in order: deletes first on a branch, get to a minimal cha
**Channel types we don't need:**
- `announcement` (Decrees) — B2B teams don't broadcast like communities
- `forum` — threads live inside text channels
- `category` — categories are sidebar UI grouping, not a channel-type row
- Remove these values from the channel-type enum in `packages/db/src/schemas/channels.ts` and any UI branches that render them

**`category` channel-type stays.** Earlier draft said "categories are sidebar UI grouping, not a channel-type row." Revisited 2026-05-28: that was an aesthetic preference, not a load-bearing requirement. Discord uses the same channel-as-category-row pattern at scale. Migrating to a separate `channel_category` table would touch ~25-30 files + a real DB migration for marginal cleanup. Skipped. Revisit only if it causes concrete pain.

**Private channels:**
- Do not implement a private channel primitive. Channels are public to the workspace, full stop. If a `private`/`visibility` field gets added later, it must come with a maintainer decision — not as a quiet build.

Expand Down Expand Up @@ -317,7 +318,7 @@ Recommended v1 scope: **one connector, done exceptionally well** (GitHub is the
**Foundation first — rework the existing chat app before any Merlin work begins.** Per maintainer call (2026-05-28): the order below front-loads the delete pass + workspace rename so we have a clean base, then layers Merlin on top.

1. **Branch the delete pass.** Strip the old surfaces listed in [Delete aggressively](#delete-aggressively-do-not-rename-do-not-migrate-just-rm) — social/friendship layer, per-guild roles/bans/timeouts, `announcement`/`forum`/`category` channel types, Townhall lexicon, Ravn references. Voice channels stay.
2. **Resolve the workspace primitive name** (see Open decisions) and execute the `guilds → workspaces|organizations|keeps` rename across schema, API routes, web routes, and components.
2. ~~**Resolve the workspace primitive name** (see Open decisions) and execute the `guilds → workspaces|organizations|keeps` rename across schema, API routes, web routes, and components.~~ **Done.** `guild*` → `workspace*` rename executed across schemas (`packages/db/src/schemas/workspace*.ts`), API (`apps/api/src/routes/v1/workspaces/`), web routes (`apps/web/src/routes/_authenticated/$workspaceSlug/`), components, realtime room/event names, and permission helpers. Better-auth's `organization` plugin surface is unchanged at the API boundary.
3. **Collapse private-Hall scaffolding** if any landed — channels are public-to-workspace by design.
4. **Rebuild the sidebar IA** — tabbed sidebar (Channels / DMs / Merlin) + minimal top bar per [Navigation & sidebar IA](#navigation--sidebar-ia). Includes Discord-style collapsible categories. **The workspace switcher itself is deferred** — top-left can stay as a static workspace badge for now (the multi-workspace switcher dropdown is post-foundation work).
5. Rebuild marketing site copy on `apps/www` against the new Lor / institutional-memory positioning.
Expand Down
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
# Townhall
# Lor

A free, open source chat app built for communities of any size.
The AI multiplayer workspace for teams.

Townhall aims to be a simple, privacy-respecting alternative to platforms like Discord. No ads, no AI training on your data, no forced identity verification. Just chat.
Lor is a chat-first workspace for software teams. Chat is the interface; **Merlin** — the resident AI agent — is the product. It indexes everything that happens in your workspace (messages, threads, voice transcripts, integrations) and answers questions about your team's history, decisions, and ongoing work. Think "Glean for small teams," open-source and self-hostable.

## Why Townhall?
## Why Lor?

- **Free and open source** — the code is public, forkable, and self-hostable
- **No tracking** — no analytics, no algorithms, no data harvesting
- **No identity verification** — no face scans, no ID uploads, no phone number required
- **Self-host or use hosted** — run it on your own server or use the hosted version
- **Open source & self-hostable** — AGPL-licensed; run it on your own infra or use the hosted version
- **Chat-native institutional memory** — Merlin lives where the work happens, not behind a separate search bar
- **For software teams** — not a community-chat platform; designed around how engineering orgs actually communicate

## Project Structure

Expand Down Expand Up @@ -61,4 +60,4 @@ pnpm dev

## License

MIT
AGPL-3.0
18 changes: 4 additions & 14 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Roadmap

> Note: roadmap pending a full rewrite for Lor positioning. This is a cleanup pass — old/dead items removed but not yet refreshed with new Lor-specific direction.

## Completed

- [x] Add `apps/realtime` Socket.IO gateway and wire it to authenticated sessions.
Expand Down Expand Up @@ -31,18 +33,9 @@
## Phase 2 — Permissions & Moderation

- [ ] Granular permission system (beyond owner/admin/member)
- [x] Member management UI (kick, banish, silence, role assignment)
- [ ] Rate limiting enforcement (API-level + per-channel)
- [ ] Audit logs

## Phase 3 — Social Features

- [x] Shareable invite links (not just email invites) — schema, API, and UI implemented
- [x] Ally (friend) system with requests — schema, API, allies page, user profile popover with ally actions
- [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog
- [x] User blocking — schema, API (block/unblock/list), realtime DM enforcement, blocked tab on allies page, block/unblock in profile popover, message collapse with click-to-reveal, typing/DM filtering
- [x] Privacy settings — user_privacy_settings table, API (get/update), DM/ally request/presence enforcement, Privacy & Safety settings UI, profile popover DM button

## Phase 4 — Tests & CI/CD

- [ ] API endpoint tests
Expand All @@ -61,7 +54,7 @@
- [x] Notification preferences — user_notification_settings table, API (get/update), settings UI (desktop/DM notification levels, permission request)
- [x] Unread indicators (Discord-style) — channel/DM text highlights, mention badges, left-side unread pill
- [x] Reaction tooltips (who reacted with each emoji)
- [x] User profile popover (bio, status, online indicator, ally actions)
- [x] User profile popover (bio, status, online indicator)
- [x] Remember last visited channel per guild (localStorage)
- [ ] Error handling & loading state improvements
- [x] Username editing in account settings (with availability check)
Expand All @@ -77,11 +70,8 @@

## Phase 7 — v2 Features

- [ ] Voice/video (Voice Chambers)
- [ ] Voice/video (voice channels)
- [ ] Bots & webhooks (including inbound channel webhooks for integrations like GitHub PR notifications with @mentions)
- [ ] Custom emojis (Sigils & Crests)
- [ ] Server discovery
- [ ] Forum channel posts

---

Expand Down
23 changes: 11 additions & 12 deletions apps/api/scripts/seed-channels.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
/**
* Seed a guild with sample channels.
* Seed a workspace with sample channels.
*
* Usage:
* pnpm --filter @repo/api exec tsx scripts/seed-channels.ts <guild-id>
* pnpm --filter @repo/api exec tsx scripts/seed-channels.ts <workspace-id>
*/

import { db } from "@repo/db"
import { channel } from "@repo/db/schema"
import { eq } from "drizzle-orm"

const guildId = process.argv[2]
if (!guildId) {
const workspaceId = process.argv[2]
if (!workspaceId) {
console.error(
"Usage: pnpm --filter @repo/api exec tsx scripts/seed-channels.ts <guild-id>"
"Usage: pnpm --filter @repo/api exec tsx scripts/seed-channels.ts <workspace-id>"
)
process.exit(1)
}
Expand All @@ -38,7 +38,6 @@ const categories = [
{
name: "Community",
channels: [
{ name: "announcements", type: "announcement" as const },
{ name: "feedback", type: "text" as const },
{ name: "showcase", type: "text" as const },
],
Expand All @@ -54,9 +53,9 @@ const categories = [
]

async function seed() {
// Clear existing channels for this guild
await db.delete(channel).where(eq(channel.guildId, guildId))
console.log(`Cleared existing channels for guild ${guildId}`)
// Clear existing channels for this workspace
await db.delete(channel).where(eq(channel.workspaceId, workspaceId))
console.log(`Cleared existing channels for workspace ${workspaceId}`)

// Uncategorized channels at the top
const uncategorized = [
Expand All @@ -68,7 +67,7 @@ async function seed() {
await db.insert(channel).values({
name: uncategorized[i].name,
type: uncategorized[i].type,
guildId,
workspaceId,
position: i,
})
console.log(` # ${uncategorized[i].name}`)
Expand All @@ -82,7 +81,7 @@ async function seed() {
.values({
name: cat.name,
type: "category",
guildId,
workspaceId,
position: catIdx,
})
.returning()
Expand All @@ -94,7 +93,7 @@ async function seed() {
await db.insert(channel).values({
name: ch.name,
type: ch.type,
guildId,
workspaceId,
parentId: categoryRow.id,
position: chIdx,
})
Expand Down
4 changes: 2 additions & 2 deletions apps/api/scripts/seed-dms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async function seed() {
.insert(channel)
.values({
type: "dm",
guildId: null,
workspaceId: null,
position: 0,
})
.returning()
Expand Down Expand Up @@ -198,7 +198,7 @@ async function seed() {
.values({
type: "group_dm",
name: group.name,
guildId: null,
workspaceId: null,
ownerId: userId,
position: 0,
})
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { testClient } from "hono/testing"
import app, { type AppType } from "@/app"

/**
* Typed test client for the Townhall API.
* Typed test client for the Lor API.
* Provides autocomplete on routes, params, and response bodies.
*/
export const client = testClient<AppType>(app as unknown as AppType)
/**
* Creates request options with a session cookie for authenticated requests.
*
* Usage:
* const res = await client.v1.allies.$get(undefined, withSession(cookie))
* const res = await client.v1.someRoute.$get(undefined, withSession(cookie))
*/
export function withSession(sessionCookie: string): ClientRequestOptions {
return {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { globalRateLimit } from "@/middleware/rate-limit"
import index from "@/routes/index.route"
import channelsRouter from "@/routes/v1/channels/index"
import dmsRouter from "@/routes/v1/dms/index"
import guildsRouter from "@/routes/v1/guilds/index"
import invitesRouter from "@/routes/v1/invites/index"
import notificationSettingsRouter from "@/routes/v1/notification-settings/index"
import uploadsRouter from "@/routes/v1/uploads/index"
import usersRouter from "@/routes/v1/users/index"
import workspacesRouter from "@/routes/v1/workspaces/index"
import waitlistRouter from "@/routes/waitlist/index"

const app = createApp()
Expand Down Expand Up @@ -38,7 +38,7 @@ app.route("/", index)
const routes = app
.route("/", waitlistRouter)
.route("/v1", channelsRouter)
.route("/v1", guildsRouter)
.route("/v1", workspacesRouter)
.route("/v1", invitesRouter)
.route("/v1", notificationSettingsRouter)
.route("/v1", dmsRouter)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/lib/helpers/openapi/configure-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function configureOpenAPI(app: AppOpenAPI) {
openapi: "3.0.0",
info: {
version: "0.1.0",
title: "Townhall API",
title: "Lor API",
},
servers: [
{
Expand Down
50 changes: 25 additions & 25 deletions apps/api/src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { auth } from "@repo/auth"
import {
canManageGuildAuthority,
type GuildAuthority,
guildAuthorityHasPermissions,
isGuildRole,
canManageWorkspaceAuthority,
isWorkspaceRole,
type PermissionRequest,
type StatementKey,
type WorkspaceAuthority,
workspaceAuthorityHasPermissions,
} from "@repo/auth/permissions"
import { HTTPException } from "hono/http-exception"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import type { Guild, GuildMember } from "@/lib/types/app-types"
import type { Workspace, WorkspaceMember } from "@/lib/types/app-types"

// ── Type-Safe Permission Types ──────────────────────────────────────

Expand All @@ -19,26 +19,26 @@ export type PermissionForStatement<T extends StatementKey> = NonNullable<
PermissionRequest[T]
>[number]

function toGuildAuthority(
member: Pick<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">
): GuildAuthority {
if (!isGuildRole(member.role)) {
function toWorkspaceAuthority(
member: Pick<WorkspaceMember, "role" | "userId">,
workspace: Pick<Workspace, "ownerId">
): WorkspaceAuthority {
if (!isWorkspaceRole(member.role)) {
throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: `Unknown guild role: ${member.role}`,
message: `Unknown workspace role: ${member.role}`,
})
}

return {
role: member.role,
isOwner: guild.ownerId === member.userId,
isOwner: workspace.ownerId === member.userId,
}
}

// ── Permission Check ──────────────────────────────────────

/**
* Checks if the current user has the specified permissions in their active guild.
* Checks if the current user has the specified permissions in their active workspace.
* Uses better-auth's hasPermission API and throws HTTPException(403) when the
* requested permission is missing.
*
Expand Down Expand Up @@ -70,14 +70,14 @@ export async function checkPermission<
return true
}

export function assertGuildPermission(
member: Pick<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">,
export function assertWorkspacePermission(
member: Pick<WorkspaceMember, "role" | "userId">,
workspace: Pick<Workspace, "ownerId">,
requestedPermissions: PermissionRequest
) {
const authority = toGuildAuthority(member, guild)
const authority = toWorkspaceAuthority(member, workspace)

if (!guildAuthorityHasPermissions(authority, requestedPermissions)) {
if (!workspaceAuthorityHasPermissions(authority, requestedPermissions)) {
throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: "You do not have permission to perform this action",
})
Expand All @@ -86,21 +86,21 @@ export function assertGuildPermission(
return authority
}

export function assertCanManageGuildMember(
actor: Pick<GuildMember, "role" | "userId">,
target: Pick<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">
export function assertCanManageWorkspaceMember(
actor: Pick<WorkspaceMember, "role" | "userId">,
target: Pick<WorkspaceMember, "role" | "userId">,
workspace: Pick<Workspace, "ownerId">
) {
if (actor.userId === target.userId) {
throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: "You cannot moderate yourself",
})
}

const actorAuthority = toGuildAuthority(actor, guild)
const targetAuthority = toGuildAuthority(target, guild)
const actorAuthority = toWorkspaceAuthority(actor, workspace)
const targetAuthority = toWorkspaceAuthority(target, workspace)

if (!canManageGuildAuthority(actorAuthority, targetAuthority)) {
if (!canManageWorkspaceAuthority(actorAuthority, targetAuthority)) {
throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: "You cannot moderate this member",
})
Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/lib/types/app-types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"
import type { Session } from "@repo/auth"
import type { guild, guildMember } from "@repo/db/schema"
import type { workspace, workspaceMember } from "@repo/db/schema"
import type { Schema } from "hono"
import type { PinoLogger } from "hono-pino"

export type Guild = typeof guild.$inferSelect
export type GuildMember = typeof guildMember.$inferSelect
export type Workspace = typeof workspace.$inferSelect
export type WorkspaceMember = typeof workspaceMember.$inferSelect

export interface AppBindings {
Variables: {
logger: PinoLogger
user: Session["user"]
session: Session["session"]
guild: Guild
member: GuildMember
workspace: Workspace
member: WorkspaceMember
}
}

Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/middleware/session-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { AppBindings } from "@/lib/types/app-types"

/**
* Lightweight auth middleware that only validates the session.
* Does NOT require or resolve an active guild.
* Does NOT require or resolve an active workspace.
*
* Use this for guild-independent routes like DMs.
* Use this for workspace-independent routes like DMs.
*
* Sets in context:
* - user: The authenticated user
Expand Down
Loading
Loading