Add MineGeo gameplay systems and interface

This commit is contained in:
2026-10-10 10:27:47 +05:00
parent bf01d9801d
commit 9af71c85c2
47 changed files with 9954 additions and 93 deletions

View File

@@ -4,6 +4,19 @@ MINEGEO_URL=http://localhost:3000
MINEGEO_HOST=127.0.0.1
# Optional comma-separated exact public origins for a reverse proxy or aliases.
MINEGEO_ALLOWED_ORIGINS=
# Optional public community links. Leave either blank to hide its footer button.
# Discord: https://discord.gg/<invite> or https://discord.com/invite/<invite>
# Telegram: https://t.me/<channel-or-group> (private group invite links also work).
MINEGEO_DISCORD_URL=
MINEGEO_TELEGRAM_URL=
# Optional server-only Discord bot integration. All published parties use one channel.
# Create a bot at https://discord.com/developers/applications and invite it to your server.
# Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here.
DISCORD_BOT_TOKEN=
DISCORD_MAPS_CHANNEL_ID=
DISCORD_PARTIES_CHANNEL_ID=
# Optional bearer secret for scheduled GET /api/integrations/discord/dispatch retries.
CRON_SECRET=
# Development testing only; production rejects this bypass of CSRF protection.
MINEGEO_ALLOW_ANY_ORIGIN=false
NEXT_PUBLIC_SUPABASE_URL=

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ public/downloads/*.jar
# Generated by Next.js
next-env.d.ts
/.swc

View File

@@ -0,0 +1,29 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { dispatchDiscordAnnouncements } from "@/lib/discord-announcements";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET;
const actual = Buffer.from(request.headers.get("authorization") ?? "");
const expected = Buffer.from(`Bearer ${secret ?? ""}`);
if (
!secret ||
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
await dispatchDiscordAnnouncements();
return NextResponse.json(
{ ok: true },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ error: "Discord dispatch will retry" },
{ status: 503 },
);
}
}

View File

@@ -326,6 +326,9 @@ html[data-theme="light"] .success-message {
html[data-theme="light"] .username-moderator {
color: #246494;
}
html[data-theme="light"] .username-admin {
color: #8c491e;
}
html[data-theme="light"] .username-bug-finder {
color: #866318;
}

40
app/community.css Normal file
View File

@@ -0,0 +1,40 @@
.community-links {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.community-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
max-width: 100%;
padding: 10px 13px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--panel);
color: var(--text);
font:
600 12px/1.5 Arial,
Helvetica,
sans-serif;
}
.community-link:hover {
border-color: var(--green);
background: var(--panel2);
}
.community-discord > svg:first-child {
color: #bab3ff;
}
.community-telegram > svg:first-child {
color: #83d3ff;
}
html[data-theme="light"] .community-discord > svg:first-child {
color: #6250b4;
}
html[data-theme="light"] .community-telegram > svg:first-child {
color: #20709d;
}

View File

@@ -17,6 +17,7 @@ import "./mobile-game.css";
import "./multiplayer.css";
import "./support.css";
import "./appearance.css";
import "./community.css";
import { themeInitScript } from "@/shared/theme";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";

View File

@@ -178,6 +178,9 @@
.username-moderator {
color: #91d3ff;
}
.username-admin {
color: #ffc18a;
}
.username-bug-finder {
color: #ffe6a1;
}

View File

@@ -0,0 +1,65 @@
import "server-only";
import { MessageCircle, Send, ArrowUpRight } from "lucide-react";
import { getI18n } from "@/lib/i18n";
function communityUrl(value: string | undefined, hosts: readonly string[]) {
if (!value?.trim()) return null;
try {
const url = new URL(value.trim());
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.port ||
!hosts.includes(url.hostname) ||
url.pathname === "/"
)
return null;
return url.href;
} catch {
return null;
}
}
export async function CommunityLinks() {
const { t } = await getI18n();
const discord = communityUrl(process.env.MINEGEO_DISCORD_URL, [
"discord.gg",
"discord.com",
"www.discord.com",
"discordapp.com",
]);
const telegram = communityUrl(process.env.MINEGEO_TELEGRAM_URL, [
"t.me",
"telegram.me",
]);
if (!discord && !telegram) return null;
return (
<div className="community-links" role="group" aria-label={t("Community")}>
{discord && (
<a
className="community-link community-discord"
href={discord}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle size={18} aria-hidden="true" />
{t("Join us on Discord")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
{telegram && (
<a
className="community-link community-telegram"
href={telegram}
target="_blank"
rel="noopener noreferrer"
>
<Send size={18} aria-hidden="true" />
{t("Follow us on Telegram")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { MessageCircle } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type { DiscordStatus } from "@/shared/discord";
export function DiscordIntegration() {
const { t } = useI18n();
const [status, setStatus] = useState<DiscordStatus | null>(null),
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
async function load() {
try {
setStatus(await api<DiscordStatus>("moderation/discord"));
setError("");
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
void load();
}, []);
const configured = !!(
status?.botConfigured &&
status.publicUrlConfigured &&
(status.mapsConfigured || status.partiesConfigured)
);
async function run(action: "check" | "retry") {
setBusy(true);
setError("");
setMessage("");
try {
await api("moderation/discord", { action });
setMessage(
action === "check"
? "Discord bot and channel access checked. Send Messages and Embed Links are also required."
: "Saved Discord announcements will retry.",
);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card" aria-labelledby="discord-integration-title">
<div className="settings-heading">
<MessageCircle size={24} aria-hidden="true" />
<h2 id="discord-integration-title">{t("Discord integration")}</h2>
</div>
<p>
{t(
"New approved maps are announced automatically. Hosts can publish an open party to the shared Discord channel.",
)}
</p>
{status && (
<>
<p className="muted">
{configured
? t("Discord bot configured")
: t("Discord setup required in Vercel environment variables")}
</p>
<p>
{t("Map announcements: {state}", {
state: status.mapsConfigured
? t("Configured")
: t("Not configured"),
})}{" "}
·{" "}
{t("Party announcements: {state}", {
state: status.partiesConfigured
? t("Configured")
: t("Not configured"),
})}
</p>
<p>
{t("{pending} pending · {failed} need attention · {sent} sent", {
pending: status.pending,
failed: status.failed,
sent: status.sent,
})}
</p>
</>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{message && <p role="status">{t(message)}</p>}
<div className="inline-actions">
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("check")}
>
{t("Check Discord setup")}
</button>
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("retry")}
>
{t("Retry saved announcements")}
</button>
<button
className="text-link"
disabled={busy}
onClick={() => void load()}
>
{t("Refresh status")}
</button>
</div>
</section>
);
}

View File

@@ -10,6 +10,8 @@ import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
import { ModeratorSettings } from "./ModeratorSettings";
import { ModeratorPrefixes } from "./ModeratorPrefixes";
import { isAdministrator, type StaffRole } from "@/shared/permissions";
import { DiscordIntegration } from "./DiscordIntegration";
type ReviewMap = {
id: string;
name: string;
@@ -45,7 +47,8 @@ export function Moderation() {
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [role, setRole] = useState<StaffRole | null>(null);
const administrator = isAdministrator(role ?? undefined);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
@@ -53,17 +56,21 @@ export function Moderation() {
async function load() {
try {
const d = await api<{
role: StaffRole;
maps: ReviewMap[];
reports: typeof reports;
appeals: Appeal[];
}>("moderation");
setMaps(d.maps);
setAuthorized(true);
setRole(d.role);
setReports(d.reports);
setAppeals(d.appeals);
setError("");
} catch (e) {
setAuthorized(false);
setRole(null);
setMaps([]);
setReports([]);
setAppeals([]);
setError((e as Error).message);
} finally {
setLoading(false);
@@ -114,8 +121,16 @@ export function Moderation() {
}
return (
<>
{authorized && <ModeratorSettings />}
{authorized && <ModeratorPrefixes />}
{administrator && <ModeratorSettings />}
{administrator && <ModeratorPrefixes />}
{administrator && <DiscordIntegration />}
{role === "moderator" && (
<p className="muted">
{t(
"Moderators can inspect and review pending maps and updates. Other controls require Admin permission.",
)}
</p>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
@@ -185,7 +200,7 @@ export function Moderation() {
</button>
</>
) : null}
{m.state === "published" && (
{administrator && m.state === "published" && (
<button
className="button"
disabled={busy}
@@ -197,7 +212,7 @@ export function Moderation() {
</div>
</article>
))}
{reports.length > 0 && (
{administrator && reports.length > 0 && (
<section className="token-box">
<h2>{t("Community reports")}</h2>
<p className="muted">
@@ -284,7 +299,7 @@ export function Moderation() {
))}
</section>
)}
{appeals.length > 0 && (
{administrator && appeals.length > 0 && (
<section className="token-box">
<h2>{t("Map ban appeals")}</h2>
{appeals.map((a) => (

View File

@@ -43,7 +43,7 @@ export function ModeratorSettings() {
<h2 id="site-settings-heading">{t("Site settings")}</h2>
<p>
{t(
"Changes apply to every player. Only moderators can edit these settings.",
"Changes apply to every player. Only admins can edit these settings.",
)}
</p>
</div>

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Copy, Users, Crown, Trophy } from "lucide-react";
import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { RoomChat } from "./RoomChat";
@@ -22,6 +22,8 @@ export function MultiplayerRoom({ id }: { id: string }) {
const [name, setName] = useState("");
const [invite, setInvite] = useState("");
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [shareMessage, setShareMessage] = useState("");
const clock = useRef<ReturnType<typeof roundClock> | null>(null);
const version = useRef(0);
const acting = useRef(false);
@@ -105,6 +107,22 @@ export function MultiplayerRoom({ id }: { id: string }) {
setError("Copy the invite link from the field below");
}
}
async function publishParty() {
if (sharing) return;
setSharing(true);
setShareMessage("");
try {
await api(`rooms/${id}/discord`, {});
setShareMessage(
"Party announcement queued. Anyone in the Discord channel can open the invite and join.",
);
await load();
} catch (failure) {
setShareMessage((failure as Error).message);
} finally {
setSharing(false);
}
}
const activeGame =
!!room?.you && ["loading", "playing", "results"].includes(room.status);
if (activeGame && room)
@@ -235,6 +253,60 @@ export function MultiplayerRoom({ id }: { id: string }) {
<Copy size={16} /> {copied ? t("Copied!") : t("Copy link")}
</button>
</div>
{owner && (room.discord?.available || room.discord?.state) && (
<div className="discord-party-share">
<p className="muted">
{t(
"Publishing shares this invite with everyone in the community Discord channel.",
)}
</p>
{room.discord.state === null && (
<button
className="button"
disabled={
sharing ||
busy ||
room.playerCount >= room.playerLimit
}
onClick={() => void publishParty()}
>
<MessageCircle size={16} aria-hidden="true" />
{sharing
? t("Publishing…")
: t("Publish party to Discord")}
</button>
)}
{room.discord.state === "sent" && (
<p role="status">{t("Party posted to Discord.")}</p>
)}
{["pending", "sending"].includes(
room.discord.state ?? "",
) && (
<p role="status">
{t(
"Party announcement is waiting for Discord delivery.",
)}
</p>
)}
{room.discord.state === "failed" && (
<p role="status">
{t(
"Discord delivery needs an administrator to check the bot setup and retry.",
)}
</p>
)}
{room.discord.state === "skipped" && (
<p role="status">
{t(
"This party announcement expired or the lobby is no longer available.",
)}
</p>
)}
{shareMessage && !room.discord.state && (
<p role="status">{t(shareMessage)}</p>
)}
</div>
)}
<h2>
{t("{count} / {limit} players", {
count: room.playerCount,

View File

@@ -8,6 +8,7 @@ import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
import { ReportBugButton } from "./ReportBugButton";
import { ThemeToggle } from "./ThemeToggle";
import { CommunityLinks } from "./CommunityLinks";
export async function Header() {
const { t } = await getI18n();
const account = await user();
@@ -37,6 +38,7 @@ export async function Footer() {
<div>
<Brand />
<p>{t("A familiar world. A whole new perspective.")}</p>
<CommunityLinks />
</div>
<div>
<ThemeToggle />

View File

@@ -38,6 +38,12 @@ export function Username({
Star;
return (
<span className="username">
{appearance.role === "admin" && (
<span className="username-prefix username-admin" title={t("Admin")}>
<Crown size={13} aria-hidden="true" />
{t("Admin")}
</span>
)}
{appearance.role === "moderator" && (
<span
className="username-prefix username-moderator"

View File

@@ -40,7 +40,7 @@ The follow-up migration `20261008075322_profile_identity.sql` adds a permanent
**Bug finder** badge when a glow is granted. Revoking the glow preserves the badge.
Guest reports retain confirmed-bug recognition when linked after a glow revoke.
Players with an active glow can choose any hex color in **Settings → Username glow**.
Moderators display a shield badge from their account role and can find an account
Moderators display a shield badge, and Admins display a crown. Admins can find an account
in **Moderation → Player prefixes** to assign, change, or remove a custom text/icon
prefix. Account IDs disambiguate players with identical names. Prefixes do not
change permissions. These decorations appear in account views, leaderboards,

135
docs/discord-integration.md Normal file
View File

@@ -0,0 +1,135 @@
# Discord announcements
MineGeo uses your Discord bot to send messages through Discord's REST API.
It runs on Vercel and does not need a continuously running bot process, Gateway,
Message Content intent, slash commands, an interactions URL, or a Discord webhook.
The bot can appear offline while announcements work.
New maps are announced when a Moderator or Admin approves their first public
version. Drafts, imported ZIP copies, player contributions, subsequent map updates,
and re-publication do not create new-map announcements. Existing maps from before
this integration are not backfilled.
In an open multiplayer lobby, the host can select **Publish party to Discord**.
All published parties go to the single server-wide `DISCORD_PARTIES_CHANNEL_ID`.
Players cannot choose a destination. Discord readers can open the invite and join
without a MineGeo account while the lobby is open and has space. Messages show
the map, host, difficulty, player count, and link. Publishing is optional: ordinary
private invite links still work. Parties on private or imported maps cannot be
announced publicly. A room gets one announcement, including repeated clicks or
a transfer of lobby ownership. The counts are a snapshot when posted.
## Create and invite your bot
1. Open the [Discord Developer Portal](https://discord.com/developers/applications)
and select **New Application**. Name it **MineGeo**, or your preferred bot name.
2. Open **Bot**. Create the bot if prompted, then use **Reset Token** to obtain
its bot token. Keep it private; save it directly into Vercel as described below.
3. Under **Installation**, enable **Guild Install**. For the guild's install
settings, choose the **bot** scope and these permissions: **View Channels**,
**Send Messages**, **Embed Links**. You do not need Administrator permission.
Use the installation link to add the bot to your Discord server. Alternatively,
**OAuth2 → URL Generator** with the `bot` scope generates an invite link.
4. Create or choose a text channel for map announcements and another for parties.
You can use the same channel for both. Ensure the bot has the three permissions
above in each channel, including any category or channel permission overrides.
Ensure community members can view the party channel.
5. In Discord's **User Settings → Advanced**, enable **Developer Mode**. Right-click
each channel and choose **Copy Channel ID**. On mobile, long-press the channel
or open its channel menu, then choose **Copy Channel ID**. Copy the numeric
channel IDs; an invite link or server ID is not a channel ID.
Discord setup references: [application setup](https://docs.discord.com/developers/quick-start/getting-started),
[bot permissions](https://docs.discord.com/developers/topics/permissions).
## Add the Vercel environment variables
Open [MineGeo's environment settings](https://vercel.com/pavel-melnikovs-projects-74f979c2/minegeo/settings/environment-variables).
Add these for **Production**:
| Name | Value | Sensitive? |
| --- | --- | --- |
| `DISCORD_BOT_TOKEN` | Bot token from the Developer Portal, without `Bot ` in front | Yes |
| `DISCORD_MAPS_CHANNEL_ID` | Numeric channel ID for new approved maps | No |
| `DISCORD_PARTIES_CHANNEL_ID` | Numeric channel ID where **all published parties** appear | No |
| `MINEGEO_URL` | `https://minegeo.vercel.app` (already configured) | No |
Use **Sensitive** for the token, and never prefix these names with `NEXT_PUBLIC_`.
Leave a channel variable blank to disable that announcement type. A Discord
invite such as `MINEGEO_DISCORD_URL` controls the footer button only; it cannot
send announcements.
After saving, open **Deployments**, choose the newest Production deployment,
select **Redeploy**, and use the current environment variables. Vercel applies
environment changes to new deployments. See [Vercel's environment-variable guide](https://vercel.com/docs/environment-variables).
For local use, put the same variables in ignored `.env.local` (ordinary `.env`
also works) and restart the server. Use a publicly reachable HTTPS `MINEGEO_URL`
for links that Discord members can open. Do not paste bot tokens into chat,
source control, Linear issues, or public map metadata.
## Check it
Sign in as **PerfectPython500**, open **Moderation → Discord integration**, and
select **Check Discord setup**. This reads the bot and channels without sending
a test message. It confirms the bot token, channel visibility, and channel type;
the bot must also have **Send Messages** and **Embed Links**.
Approve a genuinely new map to test a map announcement. Create a multiplayer room
on a published map, choose **Publish party to Discord**, and open the resulting
link from Discord. The lobby shows queued, sent, expired, or failed delivery.
## Saved delivery and retries
Announcements are stored in a private Supabase queue. Delivery runs after map
approval or party publication, leaving gameplay and approval independent of
Discord availability. Active workers hold leases, repeated clicks share one event,
and Discord receives a stable nonce with `enforce_nonce`. Rate limits and temporary
errors retry with a delay, up to eight attempts. Invalid tokens and missing channel
permissions require fixing the settings and selecting **Retry saved announcements**
as an Admin. Delivery resumes while the host polls their lobby or an Admin opens
the integration panel; other announcements also wake pending jobs.
Started, closed, full, removed, private, or stale parties are skipped. Pending
party announcements expire one hour after sharing; lobbies themselves last up to
24 hours. A map removed before delivery is skipped. Messages already posted are
kept when the match starts; the linked lobby shows its live state. When a party
closes (including its last player leaving), its posted announcement turns red,
says **Party closed**, and removes the join prompt. Five minutes after Discord
acknowledges that notice, the bot deletes the message. Vercel Workflow stores the
timer and resumes deletion even if every player closes the page. Retries preserve
the original deadline and tolerate messages already removed by a moderator.
Previously posted announcements for closed or deleted rooms receive the same
cleanup when the queue next runs. The original message's channel is used even
if `DISCORD_PARTIES_CHANNEL_ID` has since changed. Cleanup failures appear in the
Admin integration panel and can be retried after fixing bot access.
Discord only deduplicates nonces for a few minutes. A long outage after an
unconfirmed send can therefore result in a duplicate; inspect the channel before
manually retrying uncertain deliveries.
For optional scheduled retries, configure a private `CRON_SECRET` and call
`GET /api/integrations/discord/dispatch` with `Authorization: Bearer <CRON_SECRET>`.
This endpoint claims at most five due announcements. A Vercel Cron entry can use
that path; scheduling limits depend on your plan. No schedule is installed by
default, and the secret must never be placed in a URL.
## Database and verification
Apply `20261009133431_discord_announcements.sql` and
`20261009153708_discord_party_cleanup.sql` after existing migrations.
The queue and functions are service-role-only, use RLS and invoker permissions,
and verify the host from the room's server-verified access hash. No Discord
credentials are stored in Supabase or sent to a browser.
Vercel Workflow uses the project's managed backend automatically. Keep **Settings
→ Environment Variables → Enable access to System Environment Variables** enabled
and redeploy after changing it. No additional secret or scheduled cron is needed
for the five-minute timer. The bot edits and deletes its own announcements, so
**Manage Messages** is not required.
Run `npm test`, `npm run typecheck -- --incremental false`, `npm run build`, and
`node scripts/test-discord-sql.mjs`. `node scripts/test-discord-http.mjs` verifies
the live role gates, destination restrictions, optional configuration, and
host-only room action with temporary accounts and a room, which it removes.
Discord transport tests use a mocked API; real delivery requires your bot token
and channel permissions.

View File

@@ -1,11 +1,13 @@
# Moderator access
# Admin and Moderator access
MineGeo checks `public.profiles.role` on the server for every moderation request. A contributor cannot change their own role through the website or Supabase's public API. No Discord role, JWT metadata, or additional plugin is required.
1. Have the moderator sign in to MineGeo and open their dashboard once.
2. In the **MineGeo** Supabase project, open **Authentication → Users** and copy that person's user UUID.
3. Open **Table Editor → public.profiles**, find the matching `id`, and set `role` to `moderator`. Keep the existing ID and display name.
4. The moderator can open `/moderation`, inspect each location in 360°, approve a submitted map, return it with feedback, unpublish a map, and resolve community reports. The role takes effect on the next request.
3. Open **Table Editor → public.profiles**, find the matching `id`, and set `role` to `moderator` or `admin`. Keep the existing ID and display name.
4. Moderators can open `/moderation`, inspect pending maps and updates in 360°, approve them, or return them with feedback. Admins also manage the beta badge, player prefixes, community reports, ban appeals, and unpublishing. The role takes effect on the next request.
Apply `20261009123642_admin_permissions.sql` before deploying this role split. The account **PerfectPython500** has Admin permission. The username shows an Admin crown; Moderators retain their shield. Neither role can be assigned by editing a player profile through the website or a public Supabase client. Admin assignment remains a project-owner operation in Supabase.
Alternatively, the project owner can run this in Supabase's SQL Editor, replacing the email with the intended account:
@@ -26,9 +28,9 @@ Treat the website role separately from Supabase organization membership: moderat
Signed-in players can use **Report content** on a published map page to report the entire map, or during a game to report the current panorama. Reports include a category, a 10–2000 character explanation, and an optional HTTP/HTTPS evidence link. The website derives panorama identity from the player's accessible game round; it retains the reported immutable revision for moderator inspection. Owners cannot report their own maps. Each person can have only one open or confirmed report per map until its ban is appealed successfully; dismissed reports can be corrected and resubmitted.
In `/moderation`, inspect the reported surface map and 360° locations, then **Confirm report · add strike** or **Dismiss report**, with written feedback. Pending and dismissed reports never count toward bans. Each distinct reporter can contribute one confirmed strike. Confirming the third report atomically unpublishes the map and blocks publication. The ban applies to the map, not the owner's account. Existing games retain their original revisions; the map disappears from the public catalog and cannot start new games.
In `/moderation`, Admins inspect the reported surface map and 360° locations, then **Confirm report · add strike** or **Dismiss report**, with written feedback. Pending and dismissed reports never count toward bans. Each distinct reporter can contribute one confirmed strike. Confirming the third report atomically unpublishes the map and blocks publication. The ban applies to the map, not the owner's account. Existing games retain their original revisions; the map disappears from the public catalog and cannot start new games.
The owner opens the banned map from `/dashboard` and uses **Appeal map ban**. One appeal can await review at a time. Moderators can inspect the map and approve or reject the appeal with feedback. Rejection keeps the ban and permits another appeal with new information. Approval restores the last published revision, resets the three-strike counter, closes pending reports from the old cycle, and preserves all report and appeal history. A moderator cannot confirm their own report, confirm reports on their own map, or approve their own map's appeal.
The owner opens the banned map from `/dashboard` and uses **Appeal map ban**. One appeal can await review at a time. Admins can inspect the map and approve or reject the appeal with feedback. Rejection keeps the ban and permits another appeal with new information. Approval restores the last published revision, resets the three-strike counter, closes pending reports from the old cycle, and preserves all report and appeal history. An Admin cannot confirm their own report, confirm reports on their own map, or approve their own map's appeal.
Contribution uploads older than five days are hidden from the dashboard and the owner's **Player submissions** list. Nothing is deleted: direct contribution links, accepted panoramas, and quota accounting still work. The cutoff uses the contribution's creation time, including pending submissions.
@@ -37,8 +39,13 @@ Apply `supabase/migrations/20261006074343_content_reports_and_appeals.sql` after
```sh
npm install --prefix .data/sql-verification --no-save --package-lock=false @electric-sql/pglite@0.3.14
node scripts/test-moderation-sql.mjs
node scripts/test-admin-permissions-sql.mjs
```
After deployment, `node scripts/test-admin-permissions-production.mjs` verifies the
live API using disposable player, Moderator, and Admin accounts. It removes its
own maps and accounts afterward. It does not require a browser.
The reporting migration was applied to the configured **MineGeo** Supabase project on October 6, 2026 after `/api/v1/moderation` returned `PGRST205` for the missing `map_appeals` table. The live Data API now successfully reads moderation maps, open reports, and the appeal queue. Appeal RLS is enabled, and public roles cannot read appeals or execute moderation decisions. The earlier `map_refresh_fixes` migration remains unapplied; applying it was rejected by automatic approval review as outside this reporting fix.
# First beta map

View File

@@ -1,5 +1,23 @@
# Deploy MineGeo
Optional Discord map and multiplayer-party announcements use `DISCORD_BOT_TOKEN`,
`DISCORD_MAPS_CHANNEL_ID`, and `DISCORD_PARTIES_CHANNEL_ID`. All published parties
use the same configured channel. Follow [Discord bot setup](discord-integration.md)
for creation, permissions, Vercel variables, and retries. Apply the Discord queue
migration before deploying this feature; the bot token stays server-only.
Optional community buttons appear in the footer when `MINEGEO_DISCORD_URL` or
`MINEGEO_TELEGRAM_URL` is configured. Set these public links in `.env.local` for
local development (ordinary `.env` also works). Restart the local server after
changing a link. For the hosted site, add the same names as Production Config
variables in the MineGeo Vercel project, then redeploy. These values are public
destinations; no Discord bot token or Telegram API key is needed.
Discord accepts HTTPS links on `discord.gg`, `discord.com`, `www.discord.com`,
and `discordapp.com`. Telegram accepts `t.me` and `telegram.me`, including group
invite links. Blank, malformed, or unsupported URLs hide the corresponding
button. Each button opens its configured destination in a new tab.
Run `npm run deploy` from the project directory. The script collects all web
source files and public assets, uploads them in one request, waits for the build,
and prints the deployment URL. It uses the [Vercel deployments API](https://vercel.com/docs/rest-api/deployments/create-a-new-deployment). The database credentials already configured in

View File

@@ -21,7 +21,17 @@ import { moderatorUsersApi } from "./moderator-users-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
import {
canReviewMaps,
isAdministrator,
type StaffRole,
} from "@/shared/permissions";
import { processUploadAfterResponse } from "./process-uploads";
import {
discordAfterResponse,
discordStatus,
checkDiscordSetup,
} from "./discord-announcements";
import { needsProcessing } from "./fabric-jobs";
import { archiveMetadataSchema, importManifest } from "../shared/map-archive";
import {
@@ -56,14 +66,19 @@ async function contributor(request: NextRequest) {
if (error) throw new Error(error.message);
return owner;
}
async function moderator(request: NextRequest) {
async function moderator(request: NextRequest, adminOnly = false) {
const owner = await contributor(request);
const profile = required(
await admin().from("profiles").select("role").eq("id", owner).single(),
);
if (profile.role !== "moderator")
throw new Error("Moderator permission required");
return owner;
if (!canReviewMaps(profile.role))
throw new ServiceError("Moderator permission required", 403);
if (adminOnly) requireAdministrator(profile.role);
return { id: owner, role: profile.role };
}
function requireAdministrator(role: StaffRole) {
if (!isAdministrator(role))
throw new ServiceError("Admin permission required", 403);
}
async function limit(key: string, count: number, seconds: number) {
const allowed = required(
@@ -903,10 +918,42 @@ export async function handle(request: NextRequest, path: string[]) {
}
}
if (path[0] === "moderation") {
const reviewer = await moderator(request);
if (path[1] === "users")
const { id: reviewer, role } = await moderator(request);
if (path[1] === "discord" && path.length === 2) {
requireAdministrator(role);
if (method === "GET") return NextResponse.json(await discordStatus());
if (method === "POST") {
const body = z
.object({ action: z.enum(["check", "retry"]) })
.strict()
.parse(await jsonBody(request));
if (body.action === "check")
return NextResponse.json(await checkDiscordSetup());
required(
await client.rpc("retry_discord_announcements", {
p_reviewer: reviewer,
}),
);
required(
await client
.from("discord_announcements")
.update({ cleanup_lease_until: null })
.not("cleanup_error", "is", null)
.is("cleanup_run_id", null)
.is("deleted_at", null)
.select("id"),
);
discordAfterResponse();
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (path[1] === "users") {
requireAdministrator(role);
return await moderatorUsersApi(request, path, reviewer, jsonBody);
}
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
requireAdministrator(role);
const body = siteSettingsSchema.parse(await jsonBody(request));
const enabled = required(
await client.rpc("set_beta_badge", {
@@ -922,6 +969,7 @@ export async function handle(request: NextRequest, path: string[]) {
);
}
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
requireAdministrator(role);
const body = z
.object({
decision: z.enum(["approve", "reject"]),
@@ -946,6 +994,7 @@ export async function handle(request: NextRequest, path: string[]) {
method === "GET" &&
[4, 5].includes(path.length)
) {
requireAdministrator(role);
const report = required(
await client
.from("reports")
@@ -975,7 +1024,11 @@ export async function handle(request: NextRequest, path: string[]) {
path[4],
);
}
if (method === "GET" && path[2] === "panoramas") {
if (
method === "GET" &&
path[2] === "panoramas" &&
[3, 4].includes(path.length)
) {
const d = required(
await client
.from("datasets")
@@ -987,6 +1040,14 @@ export async function handle(request: NextRequest, path: string[]) {
throw new Error("Map is not awaiting review or published");
if (d.parent_dataset_id)
throw new Error("Contributions require owner approval first");
if (
!isAdministrator(role) &&
!(
d.state === "review" ||
(d.state === "published" && d.update_state === "review")
)
)
throw new ServiceError("Map is not awaiting review", 403);
const input = required(
(d.state === "published" && d.update_state !== "review") ||
d.state === "unpublished"
@@ -1007,29 +1068,36 @@ export async function handle(request: NextRequest, path: string[]) {
path[3],
);
}
if (method === "GET") {
const maps = required(
await client
.from("datasets")
.select("*")
.is("parent_dataset_id", null)
.in("state", ["review", "published", "unpublished"])
.order("created_at"),
);
const reports = required(
await client
.from("reports")
.select("*")
.eq("state", "open")
.order("created_at"),
);
const appeals = required(
await client
.from("map_appeals")
.select("*")
.eq("state", "open")
.order("created_at"),
);
if (method === "GET" && path.length === 1) {
let mapQuery = client
.from("datasets")
.select("*")
.is("parent_dataset_id", null)
.in("state", ["review", "published", "unpublished"])
.order("created_at");
if (!isAdministrator(role))
mapQuery = mapQuery.or(
"state.eq.review,and(state.eq.published,update_state.eq.review)",
);
const maps = required(await mapQuery);
const reports = isAdministrator(role)
? required(
await client
.from("reports")
.select("*")
.eq("state", "open")
.order("created_at"),
)
: [];
const appeals = isAdministrator(role)
? required(
await client
.from("map_appeals")
.select("*")
.eq("state", "open")
.order("created_at"),
)
: [];
const reportViews = await Promise.all(
reports.map(async (report) => {
const revision = report.revision_id
@@ -1058,6 +1126,7 @@ export async function handle(request: NextRequest, path: string[]) {
}),
);
return NextResponse.json({
role,
maps: await Promise.all(
maps.map(async (m) => {
const input =
@@ -1087,7 +1156,7 @@ export async function handle(request: NextRequest, path: string[]) {
appeals,
});
}
if (method === "POST") {
if (method === "POST" && path.length === 1) {
const b = z
.object({
datasetId: idSchema,
@@ -1095,6 +1164,7 @@ export async function handle(request: NextRequest, path: string[]) {
reason: z.string().max(2000).optional(),
})
.parse(await jsonBody(request));
if (b.decision === "unpublish") requireAdministrator(role);
if (b.decision === "reject" && !b.reason?.trim())
throw new Error("Provide a rejection reason");
const { error } = await client.rpc("publish_dataset", {
@@ -1104,6 +1174,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_reason: b.reason ?? null,
});
checkServiceError(error);
if (b.decision === "approve") discordAfterResponse();
return NextResponse.json({ ok: true });
}
}
@@ -1164,7 +1235,7 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ id });
}
if (path[0] === "reports" && path.length === 2 && method === "PATCH") {
const reviewer = await moderator(request);
const { id: reviewer } = await moderator(request, true);
const b = z
.object({
decision: z.enum(["confirm", "dismiss"]),

View File

@@ -0,0 +1,226 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { after } from "next/server";
import { admin, required } from "./supabase";
import {
announcementMessage,
discordConfiguration,
DiscordClient,
} from "./discord-client";
import { deliverDiscordAnnouncement } from "./discord-delivery";
import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler";
import type {
DiscordAnnouncement,
DiscordKind,
DiscordStatus,
PartyAnnouncement,
} from "@/shared/discord";
import type { RoomView } from "@/shared/multiplayer";
const configuration = () => discordConfiguration(process.env);
export function discordPartyReady() {
const config = configuration();
return !!(config.token && config.origin && config.partyChannel);
}
async function announcementSource(
job: DiscordAnnouncement,
): Promise<Parameters<typeof announcementMessage>[3] | null> {
const db = admin();
if (job.kind === "map") {
const row = await db
.from("datasets")
.select("id,name,owner_id,panorama_count")
.eq("id", job.target_id)
.eq("state", "published")
.is("parent_dataset_id", null)
.is("imported_from_id", null)
.maybeSingle();
if (row.error) required(row);
if (!row.data) return null;
const owner = await db
.from("profiles")
.select("display_name")
.eq("id", row.data.owner_id)
.maybeSingle();
if (owner.error) required(owner);
return {
id: row.data.id,
name: row.data.name,
owner: owner.data?.display_name ?? "Explorer",
locations: row.data.panorama_count,
};
}
const found = await db
.from("multiplayer_rooms")
.select("*")
.eq("id", job.target_id)
.maybeSingle();
if (found.error) required(found);
const room = found.data;
if (
!room ||
room.status !== "lobby" ||
Date.parse(room.created_at) < Date.now() - 86400000 ||
Date.parse(job.created_at) < Date.now() - 3600000
)
return null;
const revision = required(
await db
.from("revisions")
.select("dataset_id")
.eq("id", room.revision_id)
.single(),
);
const map = await db
.from("datasets")
.select("name")
.eq("id", revision.dataset_id)
.eq("state", "published")
.is("parent_dataset_id", null)
.is("imported_from_id", null)
.maybeSingle();
if (map.error) required(map);
if (!map.data) return null;
const players = required(
await db
.from("multiplayer_players")
.select("id,display_name")
.eq("room_id", room.id)
.eq("has_left", false),
);
const host = players.find((p) => p.id === room.host_player_id);
if (!host || players.length >= room.player_limit) return null;
return {
id: room.id,
name: map.data.name,
owner: host.display_name,
difficulty: room.difficulty,
players: players.length,
limit: room.player_limit,
};
}
export async function dispatchDiscordAnnouncements(target?: string) {
const config = configuration();
if (!config.token || !config.origin) return;
await scheduleDiscordCleanup(target);
const kinds: DiscordKind[] = [];
if (config.mapChannel) kinds.push("map");
if (config.partyChannel) kinds.push("party");
if (!kinds.length) return;
const db = admin(),
worker = randomUUID();
const jobs = required(
await db.rpc("claim_discord_announcements", {
p_worker: worker,
p_kinds: kinds,
p_target: target ?? null,
}),
);
const client = new DiscordClient(config.token);
for (const job of jobs) {
await deliverDiscordAnnouncement(job, {
origin: config.origin,
channel: job.kind === "map" ? config.mapChannel! : config.partyChannel!,
client,
load: () => announcementSource(job),
finish: async (patch) => {
required(
await db
.from("discord_announcements")
.update(patch)
.eq("id", job.id)
.eq("lease_owner", worker)
.select("id")
.single(),
);
},
});
}
await scheduleDiscordCleanup(target);
}
export function discordAfterResponse(target?: string) {
const c = configuration();
if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel)) return;
after(async () => {
try {
await dispatchDiscordAnnouncements(target);
} catch {
console.error(
"Discord delivery paused; saved announcements remain available for retry.",
);
}
});
}
export async function partyAnnouncement(
room: RoomView,
): Promise<PartyAnnouncement | undefined> {
if (!room.you || room.you !== room.hostId) return undefined;
const db = admin();
const result = await db
.from("discord_announcements")
.select("state,next_attempt_at,lease_until")
.eq("kind", "party")
.eq("target_id", room.id)
.maybeSingle();
if (result.error) required(result);
const map = await db
.from("datasets")
.select("state,imported_from_id,parent_dataset_id")
.eq("id", room.datasetId)
.maybeSingle();
if (map.error) required(map);
const publicMap =
map.data?.state === "published" &&
!map.data.imported_from_id &&
!map.data.parent_dataset_id;
if (
result.data &&
["pending", "sending"].includes(result.data.state) &&
Date.parse(result.data.next_attempt_at) <= Date.now() &&
(!result.data.lease_until ||
Date.parse(result.data.lease_until) <= Date.now())
)
discordAfterResponse(room.id);
return {
available: discordPartyReady() && !!publicMap,
state: result.data?.state ?? null,
};
}
export async function discordStatus(): Promise<DiscordStatus> {
const c = configuration();
const count = async (states: DiscordAnnouncement["state"][]) => {
let query = admin()
.from("discord_announcements")
.select("id", { count: "exact", head: true });
query = states.includes("failed")
? query.or("state.eq.failed,cleanup_error.not.is.null")
: query.in("state", states);
const r = await query;
if (r.error) required(r);
return r.count ?? 0;
};
const [pending, failed, sent] = await Promise.all([
count(["pending", "sending"]),
count(["failed"]),
count(["sent"]),
]);
discordAfterResponse();
return {
botConfigured: !!c.token,
publicUrlConfigured: !!c.origin,
mapsConfigured: !!c.mapChannel,
partiesConfigured: !!c.partyChannel,
pending,
failed,
sent,
};
}
export async function checkDiscordSetup() {
const c = configuration();
if (!c.token || !c.origin || (!c.mapChannel && !c.partyChannel))
throw new Error(
"Configure the Discord bot token, public site URL, and at least one channel ID.",
);
return new DiscordClient(c.token).check(
[c.mapChannel, c.partyChannel].filter((v): v is string => !!v),
);
}

View File

@@ -0,0 +1,38 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { start } from "workflow/api";
import { admin, required } from "./supabase";
import { discordConfiguration } from "./discord-client";
import { cleanupClosedDiscordParty } from "./discord-cleanup-workflow";
export async function scheduleDiscordCleanup(target?: string) {
const config = discordConfiguration(process.env);
if (!config.token || !config.origin) return;
const db = admin(),
claim = `starting:${randomUUID()}`;
const jobs = required(
await db.rpc("claim_discord_cleanup", {
p_worker: claim,
p_target: target ?? null,
}),
);
for (const job of jobs) {
try {
const run = await start(cleanupClosedDiscordParty, [job.id, claim]);
required(
await db
.from("discord_announcements")
.update({ cleanup_run_id: run.runId, cleanup_lease_until: null })
.eq("id", job.id)
.eq("cleanup_run_id", claim)
.select("id"),
);
} catch {
// An ambiguous start may already have enqueued a run. Keep the short lease;
// a replay keeps the persisted grace period and safely repeats deletion.
console.error(
"Discord cleanup scheduling paused; the saved announcement will retry.",
);
}
}
}

View File

@@ -0,0 +1,154 @@
import {
FatalError,
RetryableError,
getWorkflowMetadata,
sleep,
} from "workflow";
import { admin, required } from "./supabase";
import {
DiscordClient,
DiscordDeliveryError,
discordConfiguration,
} from "./discord-client";
export async function cleanupClosedDiscordParty(event: string, claim: string) {
"use workflow";
const run = getWorkflowMetadata().workflowRunId;
try {
const deadline = await markClosed(event);
if (deadline) {
await sleep(new Date(deadline));
await deleteClosed(event);
}
} catch {
await releaseFailedCleanup(event, claim, run);
throw new Error(
"Discord party cleanup failed; the saved announcement can be retried.",
);
}
}
async function record(event: string) {
const result = await admin()
.from("discord_announcements")
.select("*")
.eq("id", event)
.maybeSingle();
if (result.error) required(result);
const job = result.data;
return job?.kind === "party" &&
job.state === "sent" &&
job.closed_at &&
!job.deleted_at &&
job.channel_id &&
job.message_id
? job
: null;
}
function client() {
const config = discordConfiguration(process.env);
if (!config.token || !config.origin)
throw new FatalError(
"Configure the Discord bot token and public site URL.",
);
return { client: new DiscordClient(config.token), origin: config.origin! };
}
function retry(error: unknown): never {
if (error instanceof DiscordDeliveryError) {
if (error.permanent) throw new FatalError(error.message);
throw new RetryableError(error.message, {
retryAfter: error.retryAfter * 1000,
});
}
throw new RetryableError("Discord cleanup could not be confirmed.", {
retryAfter: "30s",
});
}
async function markClosed(event: string): Promise<string | null> {
"use step";
try {
const job = await record(event);
if (!job) return null;
if (job.delete_after) return job.delete_after;
const setup = client();
const exists = await setup.client.closeParty(
job.channel_id!,
job.message_id!,
setup.origin,
job.target_id,
);
if (!exists) {
required(
await admin()
.from("discord_announcements")
.update({ deleted_at: new Date().toISOString(), cleanup_error: null })
.eq("id", event)
.select("id")
.single(),
);
return null;
}
const result = await admin().rpc("mark_discord_party_closed", {
p_event: event,
});
if (result.error) required(result);
return result.data;
} catch (error) {
if (error instanceof FatalError || error instanceof RetryableError)
throw error;
retry(error);
}
}
markClosed.maxRetries = 10;
async function deleteClosed(event: string) {
"use step";
try {
const job = await record(event);
if (!job) return;
if (!job.delete_after)
throw new FatalError("The closed notice has not been confirmed.");
const deadline = new Date(job.delete_after);
if (deadline.getTime() > Date.now())
throw new RetryableError("Keep the five-minute grace period.", {
retryAfter: deadline,
});
await client().client.remove(job.channel_id!, job.message_id!);
required(
await admin()
.from("discord_announcements")
.update({ deleted_at: new Date().toISOString(), cleanup_error: null })
.eq("id", event)
.select("id")
.single(),
);
} catch (error) {
if (error instanceof FatalError || error instanceof RetryableError)
throw error;
retry(error);
}
}
deleteClosed.maxRetries = 10;
async function releaseFailedCleanup(event: string, claim: string, run: string) {
"use step";
required(
await admin()
.from("discord_announcements")
.update({
cleanup_run_id: null,
cleanup_lease_until: new Date(Date.now() + 300000).toISOString(),
cleanup_error:
"Party cleanup failed. Check Discord setup and retry saved announcements.",
})
.eq("id", event)
.in("cleanup_run_id", [claim, run])
.is("deleted_at", null)
.select("id"),
);
console.error("Discord party cleanup failed", { event, run });
}
releaseFailedCleanup.maxRetries = 10;

291
lib/discord-client.ts Normal file
View File

@@ -0,0 +1,291 @@
import { createHash } from "node:crypto";
import type { DiscordKind } from "../shared/discord";
export function discordConfiguration(env: Record<string, string | undefined>) {
let origin: string | null = null;
try {
const url = new URL(env.MINEGEO_URL ?? "");
if (
url.protocol === "https:" &&
!url.username &&
!url.password &&
!url.search &&
!url.hash &&
url.pathname === "/"
)
origin = url.origin;
} catch {
/* An integration needs a public site URL. */
}
const token = env.DISCORD_BOT_TOKEN?.trim() || null;
const channel = (value: string | undefined) =>
/^\d{17,20}$/.test(value?.trim() ?? "") ? value!.trim() : null;
return {
token: token && !/\s/.test(token) ? token : null,
origin,
mapChannel: channel(env.DISCORD_MAPS_CHANNEL_ID),
partyChannel: channel(env.DISCORD_PARTIES_CHANNEL_ID),
};
}
export function discordText(value: string, max = 256) {
return (
value
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
.replace(/([\\*_~`|[\]<>])/g, "\\$1")
.slice(0, max) || "Unknown"
);
}
export type DiscordMessage = {
content: string;
embeds: {
title: string;
url: string;
description: string;
color: number;
thumbnail: { url: string };
fields: { name: string; value: string; inline: boolean }[];
footer: { text: string };
}[];
allowed_mentions: { parse: never[] };
nonce: string;
enforce_nonce: true;
};
export function announcementMessage(
event: string,
kind: DiscordKind,
origin: string,
data: {
id: string;
name: string;
owner: string;
locations?: number;
difficulty?: string;
players?: number;
limit?: number;
},
): DiscordMessage {
const link = `${origin}/${kind === "map" ? "servers" : "multiplayer"}/${data.id}`;
return {
content:
kind === "map"
? "A new MineGeo map is ready to explore!"
: "A MineGeo multiplayer party is looking for players!",
allowed_mentions: { parse: [] },
nonce: createHash("sha256").update(event).digest("hex").slice(0, 24),
enforce_nonce: true,
embeds: [
{
title: discordText(data.name),
url: link,
color: 0x55d6ad,
description:
kind === "map"
? `[Explore this map](${link})`
: `[Join this party](${link}) — join while the lobby is open.`,
thumbnail: { url: `${origin}/images/minegeo-mark.webp` },
fields:
kind === "map"
? [
{
name: "Created by",
value: discordText(data.owner),
inline: true,
},
{
name: "Locations",
value: String(data.locations),
inline: true,
},
]
: [
{ name: "Host", value: discordText(data.owner), inline: true },
{
name: "Difficulty",
value: discordText(data.difficulty ?? "Normal"),
inline: true,
},
{
name: "Players",
value: `${data.players} / ${data.limit}`,
inline: true,
},
],
footer: {
text:
kind === "map"
? "MineGeo · Approved community map"
: "MineGeo · Lobby details at the time of sharing",
},
},
],
};
}
export class DiscordDeliveryError extends Error {
constructor(
message: string,
public retryAfter = 60,
public permanent = false,
) {
super(message);
}
}
export class DiscordClient {
constructor(
private token: string,
private request: typeof fetch = fetch,
) {}
private async api(
path: string,
body?: object,
method = body ? "POST" : "GET",
) {
let response: Response;
try {
response = await this.request(`https://discord.com/api/v10${path}`, {
method,
headers: {
Authorization: `Bot ${this.token}`,
"Content-Type": "application/json",
"User-Agent": "MineGeo (https://minegeo.vercel.app, 1.0)",
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(15_000),
redirect: "error",
});
} catch {
throw new DiscordDeliveryError(
"Discord could not be reached. Delivery will retry.",
);
}
const data = await response.json().catch(() => null);
// A manually removed message already satisfies cleanup. Other 404s still fail.
if (
["PATCH", "DELETE"].includes(method) &&
response.status === 404 &&
data?.code === 10008
)
return null;
if (!response.ok) {
if (response.status === 429) {
const delay = Number(
data?.retry_after ?? response.headers.get("retry-after"),
);
throw new DiscordDeliveryError(
"Discord rate limit. Delivery will retry.",
Number.isFinite(delay) && delay > 0
? Math.min(Math.ceil(delay), 86400)
: 60,
);
}
if (response.status === 401)
throw new DiscordDeliveryError(
"Discord bot token is invalid. Update it in Vercel.",
60,
true,
);
if (response.status === 403)
throw new DiscordDeliveryError(
"Discord bot needs View Channel, Send Messages, and Embed Links in the configured channel.",
60,
true,
);
if (response.status === 404)
throw new DiscordDeliveryError(
"Discord channel was not found. Check the channel ID and bot access.",
60,
true,
);
throw new DiscordDeliveryError(
"Discord rejected this notification.",
60,
response.status < 500,
);
}
if (method === "DELETE" && response.status === 204) return null;
if (!data)
throw new DiscordDeliveryError("Discord did not confirm delivery.");
return data;
}
async send(channel: string, message: DiscordMessage) {
if (!/^\d{17,20}$/.test(channel))
throw new DiscordDeliveryError(
"Discord channel ID is invalid.",
60,
true,
);
const result = await this.api(`/channels/${channel}/messages`, message);
if (!/^\d{17,20}$/.test(result.id ?? "") || result.channel_id !== channel)
throw new DiscordDeliveryError("Discord did not confirm delivery.");
return result.id as string;
}
private messagePath(channel: string, message: string) {
if (![channel, message].every((id) => /^\d{17,20}$/.test(id)))
throw new DiscordDeliveryError(
"Discord message identity is invalid.",
60,
true,
);
return `/channels/${channel}/messages/${message}`;
}
async closeParty(
channel: string,
message: string,
origin: string,
room: string,
) {
const result = await this.api(
this.messagePath(channel, message),
closedPartyMessage(origin, room),
"PATCH",
);
if (result === null) return false;
if (result.id !== message || result.channel_id !== channel)
throw new DiscordDeliveryError(
"Discord did not confirm the party update.",
);
return true;
}
async remove(channel: string, message: string) {
await this.api(this.messagePath(channel, message), undefined, "DELETE");
}
async check(channels: string[]) {
const bot = await this.api("/users/@me");
if (!bot.bot)
throw new DiscordDeliveryError("Use a Discord bot token.", 60, true);
for (const channel of new Set(channels)) {
if (!/^\d{17,20}$/.test(channel))
throw new DiscordDeliveryError(
"Discord channel ID is invalid.",
60,
true,
);
const found = await this.api(`/channels/${channel}`);
if (!found.guild_id || ![0, 5].includes(found.type))
throw new DiscordDeliveryError(
"Choose a Discord server text or announcement channel.",
60,
true,
);
}
return { botName: discordText(bot.username) };
}
}
export function closedPartyMessage(origin: string, room: string) {
return {
content: "🔴 This MineGeo multiplayer party is closed.",
allowed_mentions: { parse: [] },
embeds: [
{
title: "Party closed",
url: `${origin}/multiplayer/${room}`,
color: 0xed4245,
description:
"This party is no longer accepting players. This message will be removed in five minutes.",
fields: [{ name: "Status", value: "Closed", inline: true }],
footer: { text: "MineGeo · Party closed" },
},
],
components: [],
};
}

67
lib/discord-delivery.ts Normal file
View File

@@ -0,0 +1,67 @@
import {
announcementMessage,
DiscordClient,
DiscordDeliveryError,
} from "./discord-client";
import type { DiscordAnnouncement } from "../shared/discord";
type Source = Parameters<typeof announcementMessage>[3];
export async function deliverDiscordAnnouncement(
job: DiscordAnnouncement,
dependencies: {
origin: string;
channel: string;
client: Pick<DiscordClient, "send">;
load: () => Promise<Source | null>;
finish: (patch: Partial<DiscordAnnouncement>) => Promise<void>;
now?: () => number;
},
) {
const {
origin,
channel,
client,
load,
finish,
now = Date.now,
} = dependencies;
const released = { lease_owner: null, lease_until: null };
try {
const source = await load();
if (!source) {
await finish({ ...released, state: "skipped", last_error: null });
return;
}
const messageId = await client.send(
channel,
announcementMessage(job.id, job.kind, origin, source),
);
await finish({
...released,
state: "sent",
channel_id: channel,
message_id: messageId,
sent_at: new Date(now()).toISOString(),
last_error: null,
});
} catch (failure) {
const error =
failure instanceof DiscordDeliveryError
? failure
: new DiscordDeliveryError(
"Discord delivery could not be confirmed. Delivery will retry.",
);
await finish({
...released,
state: error.permanent || job.attempts >= 8 ? "failed" : "pending",
last_error: error.message,
next_attempt_at: new Date(
now() +
Math.max(
error.retryAfter,
Math.min(3600, 30 * 2 ** (job.attempts - 1)),
) *
1000,
).toISOString(),
});
}
}

View File

@@ -8,6 +8,12 @@ import { multiplayerAppearance } from "./multiplayer-appearance";
import { mediaUrls } from "./catalog";
import { requestOrigin } from "./request-origin";
import { ServiceError } from "./service-error";
import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler";
import {
discordPartyReady,
discordAfterResponse,
partyAnnouncement,
} from "./discord-announcements";
import {
createRoomSchema,
joinRoomSchema,
@@ -83,6 +89,35 @@ export async function multiplayerApi(
}
const id = z.string().uuid().parse(path[1]);
const action = path[2] ?? "view";
if (action === "discord" && path.length === 3 && request.method === "POST") {
const view = required(
await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: "view",
p_payload: {},
}),
) as unknown as RoomView;
if (!savedSecret || !view.you || view.you !== view.hostId)
throw new ServiceError("Only the room owner can publish this party", 403);
z.object({})
.strict()
.parse(await jsonBody(request));
if (!discordPartyReady())
throw new ServiceError(
"Discord party sharing is not configured yet",
503,
);
await limit("multiplayer:discord:" + accessHash, 5, 3600);
required(
await client.rpc("publish_discord_party", {
p_room: id,
p_access_hash: accessHash,
}),
);
discordAfterResponse(id);
return respond({ ok: true });
}
if (
action === "chat" &&
path.length === 3 &&
@@ -144,6 +179,13 @@ export async function multiplayerApi(
p_payload: payload,
}),
) as unknown as RoomView;
if (room.status === "closed") {
try {
await scheduleDiscordCleanup(room.id);
} catch {
console.error("Closed Discord party remains queued for cleanup.");
}
}
if (action === "join" && room.you) {
const account = await user();
if (account) {
@@ -170,7 +212,10 @@ export async function multiplayerApi(
...appearances.get(player.id),
}));
}
if (action !== "media") return respond(room);
if (action !== "media") {
room.discord = await partyAnnouncement(room);
return respond(room);
}
if (!room.you) throw new ServiceError("Join this room to play", 403);
if (["lobby", "closed"].includes(room.status))
throw new ServiceError("This round is not available yet", 409);

View File

@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const config: NextConfig = {
poweredByHeader: false,
outputFileTracingIncludes: { "/downloads": ["./public/tools/**/*.jar"] },
@@ -26,4 +27,4 @@ const config: NextConfig = {
},
allowedDevOrigins: ["192.168.0.120", "minegeotest.12345em.ru"],
};
export default config;
export default withWorkflow(config);

6705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"sharp": "0.35.5",
"three": "0.186.1",
"tus-js-client": "4.3.1",
"workflow": "5.2.0",
"zod": "4.1.12"
},
"devDependencies": {

View File

@@ -33,6 +33,6 @@ export async function proxy(request: NextRequest) {
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|tools/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
"/((?!_next/static|_next/image|favicon.ico|\\.well-known/workflow/|tools/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
],
};

View File

@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { randomUUID, randomBytes } from "node:crypto";
import { createClient } from "@supabase/supabase-js";
process.loadEnvFile(".env.local");
const origin = process.argv[2] || "https://minegeo.vercel.app";
const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const accounts = [],
maps = [];
const checked = (r) => {
if (r.error) throw new Error(r.error.message);
return r.data;
};
async function request(
account,
path,
body,
method = body === undefined ? "GET" : "POST",
status = 200,
) {
const response = await fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
origin,
"content-type": "application/json",
...(account?.cookie ? { cookie: account.cookie } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(60000),
});
const data = await response.json();
assert.equal(response.status, status, `${path}: ${JSON.stringify(data)}`);
return data;
}
async function account(role) {
const email = `admin-permissions-${randomUUID()}@example.com`,
password = randomBytes(24).toString("base64url");
const { user } = checked(
await db.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { display_name: `Permissions ${role} fixture` },
}),
);
const fixture = { id: user.id, cookie: "" };
accounts.push(fixture);
checked(
await db.from("profiles").insert({
id: user.id,
role,
display_name: `Permissions ${role} fixture`,
}),
);
const response = await fetch(`${origin}/api/v1/auth`, {
method: "POST",
headers: { origin, "content-type": "application/json" },
body: JSON.stringify({
action: "login",
email,
password,
next: "/moderation",
}),
});
assert.equal(response.status, 200, "Fixture login failed");
fixture.cookie = response.headers
.getSetCookie()
.map((value) => value.split(";")[0])
.join("; ");
assert.ok(fixture.cookie, "Login did not set a session cookie");
return fixture;
}
try {
const player = await account("contributor"),
moderator = await account("moderator"),
admin = await account("admin"),
missing = randomUUID();
const bounds = { minX: 0, minZ: 0, maxX: 64, maxZ: 64 };
for (const state of ["review", "unpublished"]) {
const map = checked(
await db
.from("datasets")
.insert({
owner_id: player.id,
name: `Permissions ${state} fixture`,
source: "plugin",
bounds,
state,
panorama_count: 5,
})
.select("id")
.single(),
);
maps.push(map.id);
checked(
await db.from("dataset_inputs").insert({
dataset_id: map.id,
validated: true,
manifest: {
format: "minegeo-dataset/v1",
minecraftVersion: "1.21.11",
dimension: "minecraft:overworld",
source: "plugin",
name: "Permissions map fixture",
description: "Disposable permission verification",
serverName: "Fixture server",
north: "-z",
bounds,
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i * 4,
y: 64,
z: i * 4,
})),
assets: [
{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 },
],
},
}),
);
}
await request(player, "moderation", undefined, "GET", 403);
const queue = await request(moderator, "moderation");
assert.equal(queue.role, "moderator");
assert.deepEqual(queue.reports, []);
assert.deepEqual(queue.appeals, []);
assert.ok(
queue.maps.every(
(m) =>
m.state === "review" ||
(m.state === "published" && m.update_state === "review"),
),
);
assert.ok(queue.maps.some((m) => m.id === maps[0]));
assert.ok(!queue.maps.some((m) => m.id === maps[1]));
assert.ok(
(await request(moderator, `moderation/${maps[0]}/panoramas`)).locations
.length === 5,
);
await request(
moderator,
`moderation/${maps[1]}/panoramas`,
undefined,
"GET",
403,
);
for (const actor of [player, moderator]) {
for (const [path, body, method] of [
["moderation/users", undefined, "GET"],
[
`moderation/users/${player.id}`,
{ prefix: "Spoof", icon: "star" },
"PATCH",
],
["moderation/settings", { betaBadgeEnabled: false }, "PATCH"],
[
`moderation/appeals/${missing}`,
{ decision: "approve", reason: "A valid reason here." },
"POST",
],
[`moderation/reports/${missing}/panoramas`, undefined, "GET"],
[
`reports/${missing}`,
{ decision: "confirm", reason: "A valid reason here." },
"PATCH",
],
["moderation", { datasetId: maps[0], decision: "unpublish" }, "POST"],
])
await request(actor, path, body, method, 403);
}
await request(moderator, "moderation", {
datasetId: maps[0],
decision: "reject",
reason: "Returned by the permission verification fixture.",
});
assert.equal(
checked(
await db.from("datasets").select("state").eq("id", maps[0]).single(),
).state,
"rejected",
);
const full = await request(admin, "moderation");
assert.equal(full.role, "admin");
assert.ok(full.maps.some((m) => m.id === maps[1]));
assert.equal(
(await request(admin, `moderation/users?q=${player.id}`)).users[0].id,
player.id,
);
await request(
admin,
`moderation/users/${player.id}`,
{ prefix: "Builder", icon: "gem" },
"PATCH",
);
assert.equal(
(await request(player, "profile")).profile.custom_prefix,
"Builder",
);
await request(
admin,
`moderation/users/${player.id}`,
{ prefix: null, icon: "star" },
"PATCH",
);
await request(
admin,
"moderation/settings",
{ betaBadgeEnabled: "invalid" },
"PATCH",
400,
);
await request(
admin,
`reports/${missing}`,
{ decision: "dismiss", reason: "A valid reason here." },
"PATCH",
400,
);
await request(
admin,
`moderation/appeals/${missing}`,
{ decision: "approve", reason: "A valid reason here." },
"POST",
400,
);
for (const actor of [player, moderator, admin])
await request(actor, "profile", { role: "admin" }, "PATCH", 400);
assert.equal((await request(admin, "profile")).profile.role, "admin");
console.log(
"Production permissions passed: signed-in players denied, moderator queue restricted, pending panoramas and feedback work, admin controls available, role spoofing denied.",
);
} finally {
if (maps.length)
checked(await db.from("review_decisions").delete().in("dataset_id", maps));
for (const id of maps)
checked(await db.from("datasets").delete().eq("id", id));
for (const fixture of accounts) {
if (fixture.cookie)
await request(fixture, "auth/logout", {}).catch(() => {});
checked(await db.from("profiles").delete().eq("id", fixture.id));
checked(await db.auth.admin.deleteUser(fixture.id));
}
console.log("Disposable permission fixtures removed.");
}

View File

@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
const player = randomUUID(),
moderator = randomUUID(),
admin = randomUUID(),
missing = randomUUID();
for (const [id, role] of [
[player, "contributor"],
[moderator, "moderator"],
[admin, "admin"],
]) {
await db.query("insert into auth.users values($1)", [id]);
await db.query("insert into public.profiles(id,role) values($1,$2)", [
id,
role,
]);
}
const makeMap = async () => {
const { rows } = await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values($1,'Permissions fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','review',5) returning id`,
[player],
);
const id = rows[0].id;
await db.query(
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
[
id,
JSON.stringify({
name: "Permissions fixture",
description: "Role verification",
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
assetId: `p${i}`,
})),
}),
],
);
return id;
};
const id = await makeMap();
const decide = (map, reviewer, decision, reason = null) =>
db.query("select public.publish_dataset($1,$2,$3,$4)", [
map,
reviewer,
decision,
reason,
]);
await assert.rejects(
decide(id, player, "approve"),
/Moderator permission required/,
);
await decide(id, moderator, "approve");
assert.equal(
(await db.query("select state from public.datasets where id=$1", [id]))
.rows[0].state,
"published",
);
await assert.rejects(
decide(id, moderator, "unpublish"),
/Admin permission required/,
);
await decide(id, admin, "unpublish");
assert.equal(
(await db.query("select state from public.datasets where id=$1", [id]))
.rows[0].state,
"unpublished",
);
const rejected = await makeMap();
await decide(
rejected,
moderator,
"reject",
"Please fix the missing terrain.",
);
assert.equal(
(
await db.query("select state from public.datasets where id=$1", [
rejected,
])
).rows[0].state,
"rejected",
);
const updated = await makeMap();
await decide(updated, admin, "approve");
await db.query(
"update public.datasets set update_state='review' where id=$1",
[updated],
);
await decide(updated, moderator, "approve");
assert.equal(
(
await db.query("select update_state from public.datasets where id=$1", [
updated,
])
).rows[0].update_state,
null,
);
for (const actor of [player, moderator]) {
await assert.rejects(
db.query("select public.set_beta_badge($1,false)", [actor]),
/Admin permission required/,
);
await assert.rejects(
db.query("select public.set_user_prefix($1,$2,'Spoof','crown')", [
actor,
player,
]),
/Admin permission required/,
);
await assert.rejects(
db.query(
"select public.review_content_report($1,$2,'dismiss','Valid feedback here')",
[missing, actor],
),
/Admin permission required/,
);
await assert.rejects(
db.query(
"select public.review_map_appeal($1,$2,'approve','Valid feedback here')",
[missing, actor],
),
/Admin permission required/,
);
}
await db.query("select public.set_beta_badge($1,false)", [admin]);
assert.equal(
(await db.query("select beta_badge_enabled from public.site_settings"))
.rows[0].beta_badge_enabled,
false,
);
await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
admin,
player,
]);
assert.equal(
(
await db.query("select custom_prefix from public.profiles where id=$1", [
player,
])
).rows[0].custom_prefix,
"Builder",
);
await db.exec(
"create role identity_probe bypassrls; grant select,update on public.profiles to identity_probe; grant usage on schema public to identity_probe; set role identity_probe",
);
await assert.rejects(
db.query("update public.profiles set role='admin' where id=$1", [player]),
/Moderator permission required/,
);
await db.exec("reset role");
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
for (const query of [
[
"select public.publish_dataset($1,$2,'unpublish',null)",
[updated, admin],
],
["select public.set_beta_badge($1,true)", [admin]],
["select public.set_user_prefix($1,$2,'Spoof','star')", [admin, player]],
[
"select public.review_content_report($1,$2,'dismiss','Valid feedback here')",
[missing, admin],
],
[
"select public.review_map_appeal($1,$2,'approve','Valid feedback here')",
[missing, admin],
],
])
await assert.rejects(db.query(...query), /permission denied/);
await db.exec("reset role");
}
console.log(
"Admin permissions SQL passed: moderators approve/reject maps and updates; administrative actions and role spoofing are blocked; admins retain full access.",
);
} finally {
await db.close();
}

View File

@@ -167,16 +167,15 @@ try {
);
const mod = randomUUID();
await db.query("insert into auth.users values($1)", [mod]);
await db.query(
"insert into public.profiles(id,role) values($1,'moderator')",
[mod],
);
await db.query("insert into public.profiles(id,role) values($1,'admin')", [
mod,
]);
await assert.rejects(
db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
account,
mod,
]),
/Moderator permission/,
/Admin permission/,
);
await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
mod,
@@ -205,6 +204,7 @@ try {
await db.exec("set role reward_probe");
for (const column of [
"role='moderator'",
"role='admin'",
"bug_finder=false",
"custom_prefix='Spoof'",
"custom_prefix_icon='crown'",

View File

@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { randomUUID, randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { createClient } from "@supabase/supabase-js";
process.loadEnvFile(".env.local");
const origin = process.argv[2] || "https://minegeo.vercel.app";
const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const accounts = [],
rooms = [];
const checked = (r) => {
if (r.error) throw new Error(r.error.message);
return r.data;
};
await mkdir(".data/discord-check", { recursive: true });
const journal = () =>
writeFile(
".data/discord-check/http-fixtures.json",
JSON.stringify({ accounts, rooms }),
);
function session() {
const cookies = new Map();
return async (
path,
body,
method = body === undefined ? "GET" : "POST",
expected = 200,
) => {
const response = await fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
origin,
"content-type": "application/json",
cookie: [...cookies]
.map(([key, value]) => `${key}=${value}`)
.join("; "),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(60000),
});
for (const cookie of response.headers.getSetCookie()) {
const part = cookie.split(";")[0],
at = part.indexOf("=");
cookies.set(part.slice(0, at), part.slice(at + 1));
}
const data = await response.json();
assert.equal(response.status, expected, `${path}: ${JSON.stringify(data)}`);
assert.match(response.headers.get("cache-control") ?? "", /no-store/);
return data;
};
}
async function account(role) {
const email = `discord-check-${randomUUID()}@example.com`,
password = randomBytes(24).toString("base64url");
const { user } = checked(
await db.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { display_name: `Discord ${role} fixture` },
}),
);
accounts.push(user.id);
await journal();
checked(
await db
.from("profiles")
.insert({ id: user.id, role, display_name: `Discord ${role} fixture` }),
);
const api = session();
await api("auth", { action: "login", email, password, next: "/moderation" });
return api;
}
try {
const admin = await account("admin"),
moderator = await account("moderator"),
visitor = session();
const status = await admin("moderation/discord");
assert.equal(typeof status.botConfigured, "boolean");
assert.equal(typeof status.pending, "number");
assert.ok(
!Object.keys(status).some((key) => /token|secret|channel_?id/i.test(key)),
);
await visitor("moderation/discord", undefined, "GET", 401);
for (const [body, method] of [
[undefined, "GET"],
[{ action: "check" }, "POST"],
[{ action: "retry" }, "POST"],
])
await moderator("moderation/discord", body, method, 403);
if (
!status.botConfigured ||
!status.publicUrlConfigured ||
(!status.mapsConfigured && !status.partiesConfigured)
)
await admin("moderation/discord", { action: "check" }, "POST", 400);
const dispatch = await fetch(`${origin}/api/integrations/discord/dispatch`);
assert.equal(dispatch.status, 401);
console.log(
"Discord HTTP: admin-only setup, secret-free status, disabled configuration and private dispatch verified.",
);
const dataset = checked(
await db
.from("datasets")
.select("id")
.eq("state", "published")
.is("parent_dataset_id", null)
.is("imported_from_id", null)
.limit(1)
.single(),
);
const host = session(),
friend = session(),
outsider = session();
const created = await host("rooms", {
datasetId: dataset.id,
difficulty: "normal",
displayName: "Discord host fixture",
playerLimit: 4,
});
rooms.push(created.id);
await journal();
const room = await host(`rooms/${created.id}`);
assert.equal(typeof room.discord.available, "boolean");
assert.equal(room.discord.state, null);
await outsider(`rooms/${created.id}/discord`, {}, "POST", 403);
await friend(`rooms/${created.id}/join`, {
displayName: "Discord friend fixture",
});
assert.equal((await friend(`rooms/${created.id}`)).discord, undefined);
await friend(`rooms/${created.id}/discord`, {}, "POST", 403);
await host(
`rooms/${created.id}/discord`,
{ channelId: "123456789012345678" },
"POST",
400,
);
if (
!status.botConfigured ||
!status.publicUrlConfigured ||
!status.partiesConfigured
) {
assert.equal(room.discord.available, false);
await host(`rooms/${created.id}/discord`, {}, "POST", 503);
}
assert.equal(
checked(
await db
.from("discord_announcements")
.select("id")
.eq("kind", "party")
.eq("target_id", created.id),
).length,
0,
"disabled and unauthorized actions never queue a notification",
);
await host(`rooms/${created.id}/close`, {});
console.log(
"Discord HTTP passed: guests and members cannot publish, destination overrides denied, unconfigured hosts get an actionable error, no test Discord messages sent.",
);
} finally {
for (const id of rooms) {
checked(
await db
.from("discord_announcements")
.delete()
.eq("kind", "party")
.eq("target_id", id),
);
checked(await db.from("multiplayer_rooms").delete().eq("id", id));
}
for (const id of accounts) {
checked(await db.from("profiles").delete().eq("id", id));
checked(await db.auth.admin.deleteUser(id));
}
await writeFile(
".data/discord-check/http-fixtures.json",
JSON.stringify({ accounts: [], rooms: [] }),
);
console.log("Discord HTTP fixtures removed.");
}

View File

@@ -0,0 +1,391 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon;create role authenticated;create role service_role;
create schema auth;create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
const owner = randomUUID(),
mod = randomUUID(),
admin = randomUUID();
for (const [id, role] of [
[owner, "contributor"],
[mod, "moderator"],
[admin, "admin"],
]) {
await db.query("insert into auth.users values($1)", [id]);
await db.query("insert into public.profiles(id,role) values($1,$2)", [
id,
role,
]);
}
const manifest = {
name: "Discord fixture",
description: "Notification verification",
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 64,
z: i,
})),
};
const map = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count) values($1,'Discord fixture','plugin',$2,'review',5) returning id`,
[owner, JSON.stringify(manifest.bounds)],
)
).rows[0].id;
await db.query(
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
[map, JSON.stringify(manifest)],
);
assert.equal(
(
await db.query(
"select count(*)::int as n from public.discord_announcements",
)
).rows[0].n,
0,
);
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
map,
mod,
]);
assert.equal(
(
await db.query(
"select count(*)::int as n from public.discord_announcements where kind='map' and target_id=$1",
[map],
)
).rows[0].n,
1,
);
await db.query(
"update public.datasets set update_state='review' where id=$1",
[map],
);
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
map,
mod,
]);
await db.query("select public.publish_dataset($1,$2,'unpublish',null)", [
map,
admin,
]);
await db.query("update public.datasets set state='review' where id=$1", [
map,
]);
await db.query("select public.publish_dataset($1,$2,'approve',null)", [
map,
admin,
]);
assert.equal(
(
await db.query(
"select count(*)::int as n from public.discord_announcements",
)
).rows[0].n,
1,
"updates and re-publication do not spam new-map announcements",
);
const hash = "a".repeat(64),
friend = "b".repeat(64),
outsider = "c".repeat(64);
const create = async () =>
(
await db.query(
"select public.create_map_room($1,'normal','Host fixture',4,$2,$3) as id",
[map, hash, owner],
)
).rows[0].id;
const room = await create();
await db.query(
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Friend fixture\"}')",
[room, friend],
);
for (const secret of [friend, outsider])
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [room, secret]),
/Only the room owner/,
);
const event = (
await db.query("select public.publish_discord_party($1,$2) as id", [
room,
hash,
])
).rows[0].id;
assert.equal(
(
await db.query("select public.publish_discord_party($1,$2) as id", [
room,
hash,
])
).rows[0].id,
event,
"repeated host clicks return the same event",
);
const claimed = (
await db.query(
"select * from public.claim_discord_announcements('first',array['party'],null)",
)
).rows;
assert.equal(claimed.length, 1);
assert.equal(claimed[0].attempts, 1);
assert.equal(
(
await db.query(
"select * from public.claim_discord_announcements('second',array['party'],null)",
)
).rows.length,
0,
"active leases prevent double sends",
);
await db.query(
"update public.discord_announcements set lease_until=clock_timestamp()-interval '1 second' where id=$1",
[event],
);
assert.equal(
(
await db.query(
"select * from public.claim_discord_announcements('second',array['party'],null)",
)
).rows[0].attempts,
2,
"expired workers recover",
);
await db.query(
"update public.discord_announcements set attempts=8,lease_until=clock_timestamp()-interval '1 second' where id=$1",
[event],
);
assert.equal(
(
await db.query(
"select * from public.claim_discord_announcements('third',array['party'],null)",
)
).rows.length,
0,
);
assert.equal(
(
await db.query(
"select state from public.discord_announcements where id=$1",
[event],
)
).rows[0].state,
"failed",
"exhausted expired leases become actionable failures",
);
await assert.rejects(
db.query("select public.retry_discord_announcements($1)", [mod]),
/Admin permission/,
);
await db.query("select public.retry_discord_announcements($1)", [admin]);
assert.equal(
(
await db.query(
"select state from public.discord_announcements where id=$1",
[event],
)
).rows[0].state,
"pending",
);
const full = await create();
for (let i = 0; i < 3; i++)
await db.query("select public.multiplayer_action($1,$2,'join',$3)", [
full,
String(i + 4).repeat(64),
JSON.stringify({ name: `Player ${i}` }),
]);
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [full, hash]),
/room is full/,
);
const expired = await create();
await db.query(
"update public.multiplayer_rooms set created_at=clock_timestamp()-interval '25 hours' where id=$1",
[expired],
);
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [expired, hash]),
/Only open lobbies/,
);
await db.query(
"update public.multiplayer_rooms set status='closed' where id=$1",
[room],
);
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [room, hash]),
/Only open lobbies/,
);
await db.query(
"update public.discord_announcements set state='sent',channel_id='123456789012345678',message_id='223456789012345678' where id=$1",
[event],
);
let cleanup = (
await db.query(
"select * from public.claim_discord_cleanup('starting:first',$1)",
[room],
)
).rows;
assert.equal(
cleanup.length,
1,
"a send acknowledged after closure is cleaned up",
);
assert.ok(cleanup[0].closed_at);
assert.equal(
(
await db.query(
"select * from public.claim_discord_cleanup('starting:second',$1)",
[room],
)
).rows.length,
0,
"cleanup scheduling has a lease",
);
const deadline = (
await db.query("select public.mark_discord_party_closed($1) as deadline", [
event,
])
).rows[0].deadline;
assert.ok(
Math.abs(new Date(deadline).getTime() - Date.now() - 300000) < 5000,
"red notice gets five minutes",
);
assert.equal(
String(
(
await db.query(
"select public.mark_discord_party_closed($1) as deadline",
[event],
)
).rows[0].deadline,
),
String(deadline),
"retry never extends deletion deadline",
);
await db.query(
"update public.discord_announcements set cleanup_run_id='run_active',cleanup_lease_until=null where id=$1",
[event],
);
assert.equal(
(
await db.query(
"select * from public.claim_discord_cleanup('starting:third',$1)",
[room],
)
).rows.length,
0,
"durable workflow remains scheduled beyond the lease",
);
await db.query(
"update public.discord_announcements set deleted_at=clock_timestamp(),cleanup_run_id=null where id=$1",
[event],
);
assert.equal(
(
await db.query(
"select * from public.claim_discord_cleanup('starting:fourth',$1)",
[room],
)
).rows.length,
0,
"removed messages stay removed",
);
const closedSentRoom = await create();
const closedSentEvent = (
await db.query("select public.publish_discord_party($1,$2) as id", [
closedSentRoom,
hash,
])
).rows[0].id;
await db.query(
"update public.discord_announcements set state='sent',channel_id='123456789012345678',message_id='223456789012345679' where id=$1",
[closedSentEvent],
);
assert.equal(
(
await db.query(
"select * from public.claim_discord_cleanup('starting:open',$1)",
[closedSentRoom],
)
).rows.length,
0,
"open rooms are untouched",
);
await db.query("select public.multiplayer_action($1,$2,'leave','{}')", [
closedSentRoom,
hash,
]);
assert.equal(
(
await db.query(
"select * from public.claim_discord_cleanup('starting:leave',$1)",
[closedSentRoom],
)
).rows.length,
1,
"last-player departure queues cleanup",
);
const hiddenRoom = await create();
await db.query("select public.publish_dataset($1,$2,'unpublish',null)", [
map,
admin,
]);
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [hiddenRoom, hash]),
/Only parties on published maps/,
);
const imported = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count,imported_from_id,imported_owner_name) values($1,'Imported fixture','plugin',$2,'draft',5,$3,'Original owner') returning id`,
[owner, JSON.stringify(manifest.bounds), map],
)
).rows[0].id;
await db.query(
"insert into public.dataset_inputs(dataset_id,manifest,validated) values($1,$2,true)",
[imported, JSON.stringify(manifest)],
);
const privateRoom = (
await db.query(
"select public.create_map_room($1,'normal','Host fixture',4,$2,$3) as id",
[imported, hash, owner],
)
).rows[0].id;
await assert.rejects(
db.query("select public.publish_discord_party($1,$2)", [privateRoom, hash]),
/Only parties on published maps/,
);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
await assert.rejects(
db.query("select * from public.discord_announcements"),
/permission denied/,
);
for (const query of [
["select public.publish_discord_party($1,$2)", [room, hash]],
[
"select * from public.claim_discord_announcements('spoof',array['map'],null)",
[],
],
["select public.retry_discord_announcements($1)", [admin]],
["select * from public.claim_discord_cleanup('spoof',null)", []],
["select public.mark_discord_party_closed($1)", [event]],
])
await assert.rejects(db.query(...query), /permission denied/);
await db.exec("reset role");
}
console.log(
"Discord SQL passed: first-publication events, one post per party, host authorization, private-map isolation, channel-independent queue, leases, retries, expiry, capacity and server-only access.",
);
} finally {
await db.close();
}

View File

@@ -70,7 +70,7 @@ async function account(role) {
email_confirm: true,
user_metadata: {
display_name:
role === "moderator" ? "Prefix moderator test" : "Badge player test",
role === "admin" ? "Prefix admin test" : "Badge player test",
},
}),
);
@@ -81,7 +81,7 @@ async function account(role) {
id,
role,
display_name:
role === "moderator" ? "Prefix moderator test" : "Badge player test",
role === "admin" ? "Prefix admin test" : "Badge player test",
}),
);
const context = await browser.newContext({ locale: "en-US" });
@@ -98,7 +98,7 @@ try {
await mkdir(".data/support-deploy", { recursive: true });
await phase("starting");
const player = await account("contributor"),
mod = await account("moderator");
mod = await account("admin");
await api(player.context, "profile", { glowColor: "#54baff" }, "PATCH", 403);
await api(
player.context,
@@ -118,7 +118,7 @@ try {
await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
assert.equal(await player.page.locator("input[type=color]").count(), 0);
await mod.page.goto(origin + "/settings", { waitUntil: "networkidle" });
assert.ok((await mod.page.locator(".username-moderator svg").count()) > 0);
assert.ok((await mod.page.locator(".username-admin svg").count()) > 0);
await mod.page.goto(origin + "/moderation", { waitUntil: "networkidle" });
await mod.page
.getByLabel("Player name or account ID", { exact: true })
@@ -343,7 +343,7 @@ try {
);
await phase("passed");
console.log(
"Profile appearance production passed: moderator-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
"Profile appearance production passed: admin-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
);
} catch (error) {
for (const context of contexts)

View File

@@ -1,21 +1,21 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
create table public.profiles(id uuid primary key, role text not null);
grant select on public.profiles to service_role;
insert into public.profiles values
('00000000-0000-4000-8000-000000000001', 'moderator'),
('00000000-0000-4000-8000-000000000002', 'contributor');`);
await db.exec(
await readFile(
"supabase/migrations/20261006141532_site_settings.sql",
"utf8",
),
);
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
await db.exec(`insert into auth.users values
('00000000-0000-4000-8000-000000000001'),('00000000-0000-4000-8000-000000000002');
insert into public.profiles(id,role) values
('00000000-0000-4000-8000-000000000001','admin'),('00000000-0000-4000-8000-000000000002','contributor');`);
const flag = async () =>
(await db.query("select beta_badge_enabled from public.site_settings"))
.rows[0].beta_badge_enabled;
@@ -39,11 +39,11 @@ try {
db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000002', false)",
),
/Moderator permission required/,
/Admin permission required/,
);
await assert.rejects(
db.query("select public.set_beta_badge(null, false)"),
/Moderator permission required/,
/Admin permission required/,
);
assert.equal(await flag(), true);
await db.query(
@@ -66,7 +66,7 @@ try {
/visibility is required/,
);
console.log(
"Site settings SQL passed: guests and players denied; moderators can hide and restore; updates persist and record the moderator.",
"Site settings SQL passed: guests and players denied; admins can hide and restore; updates persist and record the admin.",
);
} finally {
await db.close();

View File

@@ -43,6 +43,13 @@ export type Database = {
};
public: {
Tables: {
discord_announcements: {
Row: import("./discord").DiscordAnnouncement;
Insert: Partial<import("./discord").DiscordAnnouncement> &
Pick<import("./discord").DiscordAnnouncement, "kind" | "target_id">;
Update: Partial<import("./discord").DiscordAnnouncement>;
Relationships: [];
};
bug_reports: {
Row: import("./bug-reports").BugReportRecord;
Insert: Partial<import("./bug-reports").BugReportRecord> &
@@ -695,6 +702,38 @@ export type Database = {
[_ in never]: never;
};
Functions: {
claim_discord_cleanup: {
Args: { p_worker: string; p_target?: string | null };
Returns: import("./discord").DiscordAnnouncement[];
SetofOptions: {
from: "*";
to: "discord_announcements";
isOneToOne: false;
isSetofReturn: true;
};
};
mark_discord_party_closed: {
Args: { p_event: string };
Returns: string;
};
publish_discord_party: {
Args: { p_room: string; p_access_hash: string };
Returns: string;
};
claim_discord_announcements: {
Args: { p_worker: string; p_kinds: string[]; p_target?: string | null };
Returns: import("./discord").DiscordAnnouncement[];
SetofOptions: {
from: "*";
to: "discord_announcements";
isOneToOne: false;
isSetofReturn: true;
};
};
retry_discord_announcements: {
Args: { p_reviewer: string };
Returns: number;
};
set_user_prefix: {
Args: {
p_reviewer: string;

35
shared/discord.ts Normal file
View File

@@ -0,0 +1,35 @@
export type DiscordKind = "map" | "party";
export type DiscordAnnouncement = {
id: string;
kind: DiscordKind;
target_id: string;
state: "pending" | "sending" | "sent" | "skipped" | "failed";
attempts: number;
next_attempt_at: string;
lease_owner: string | null;
lease_until: string | null;
channel_id: string | null;
message_id: string | null;
last_error: string | null;
created_at: string;
sent_at: string | null;
closed_at?: string | null;
delete_after?: string | null;
deleted_at?: string | null;
cleanup_run_id?: string | null;
cleanup_lease_until?: string | null;
cleanup_error?: string | null;
};
export type PartyAnnouncement = {
available: boolean;
state: DiscordAnnouncement["state"] | null;
};
export type DiscordStatus = {
botConfigured: boolean;
publicUrlConfigured: boolean;
mapsConfigured: boolean;
partiesConfigured: boolean;
pending: number;
failed: number;
sent: number;
};

View File

@@ -609,7 +609,7 @@
"Game mode": "Режим игры",
"Solo": "Одиночная игра",
"Multiplayer": "Мультиплеер",
"Your player name": "Ваше игровое имя",
"Your player name": "Ваше имя игрока",
"Enter a player name": "Введите игровое имя",
"Player limit (including you)": "Лимит игроков (включая вас)",
"Create a room, share the invite link, and start when your friends arrive.": "Создайте комнату, поделитесь ссылкой и начните игру, когда друзья присоединятся.",
@@ -751,15 +751,12 @@
"Tell us what happened. We’ll reply here.": "Расскажите, что произошло. Мы ответим здесь.",
"Guest reports stay in this browser. Sign in to keep your conversation in your account.": "Обращения гостей сохраняются в этом браузере. Войдите, чтобы сохранить переписку в аккаунте.",
"Bug reporting is temporarily unavailable. Please try again later.": "Отправка обращений временно недоступна. Попробуйте позже.",
"Your player name": "Ваше имя игрока",
"Bug title": "Название ошибки",
"What happened?": "Что произошло?",
"What did you do, what did you expect, and what happened instead?": "Что вы сделали, чего ожидали и что произошло вместо этого?",
"Screenshot (optional)": "Скриншот (необязательно)",
"PNG, JPG, or WebP, up to 5 MB. Shared privately with support.": "PNG, JPG или WebP, до 5 МБ. Доступен только вам и поддержке.",
"Your bug reports": "Ваши обращения",
"Your reports and support replies will appear here.": "Здесь появятся ваши обращения и ответы поддержки.",
"Draft": "Черновик",
"Waiting for support": "Ожидает поддержки",
"← Your bug reports": "← Ваши обращения",
"Your report is saved. We’re connecting it to support.": "Обращение сохранено. Передаём его поддержке.",
@@ -767,7 +764,6 @@
"Finish sending report": "Завершить отправку",
"Keep this report in my account": "Сохранить обращение в аккаунте",
"Support awarded you a username glow.": "Поддержка подарила вам свечение имени.",
"Account settings": "Настройки аккаунта",
"For guest reports, sign in and link this report to receive your reward.": "Если вы отправили обращение как гость, войдите и привяжите его к аккаунту, чтобы получить награду.",
"Attached bug screenshot": "Прикреплённый скриншот ошибки",
"Support conversation": "Переписка с поддержкой",
@@ -788,5 +784,51 @@
"Sign in to link this report": "Войдите, чтобы привязать обращение",
"Remove hidden control characters": "Удалите скрытые управляющие символы",
"Turn on light mode": "Включить светлую тему",
"Turn on dark mode": "Включить тёмную тему"
"Turn on dark mode": "Включить тёмную тему",
"Community": "Сообщество",
"Join us on Discord": "Присоединиться к Discord",
"Follow us on Telegram": "Мы в Telegram",
"Admin": "Администратор",
"Admin permission required": "Требуются права администратора",
"Moderators can inspect and review pending maps and updates. Other controls require Admin permission.": "Модераторы могут проверять ожидающие рассмотрения карты и обновления. Для остальных действий нужны права администратора.",
"Changes apply to every player. Only admins can edit these settings.": "Изменения применяются ко всем игрокам. Менять эти настройки могут только администраторы.",
"Map is not awaiting review": "Карта не ожидает проверки",
"Discord integration": "Интеграция с Discord",
"New approved maps are announced automatically. Hosts can publish an open party to the shared Discord channel.": "Новые одобренные карты публикуются автоматически. Хозяева открытых лобби могут поделиться приглашением в общем канале Discord.",
"Discord bot configured": "Бот Discord настроен",
"Discord setup required in Vercel environment variables": "Настройте Discord через переменные окружения Vercel",
"Map announcements: {state}": "Объявления о картах: {state}",
"Party announcements: {state}": "Объявления о лобби: {state}",
"Configured": "Настроено",
"Not configured": "Не настроено",
"{pending} pending · {failed} need attention · {sent} sent": "{pending} ожидают · {failed} требуют проверки · {sent} отправлено",
"Check Discord setup": "Проверить настройку Discord",
"Retry saved announcements": "Повторить отправку объявлений",
"Refresh status": "Обновить статус",
"Discord bot and channel access checked. Send Messages and Embed Links are also required.": "Бот и доступ к каналам проверены. Также нужны права на отправку сообщений и вставку ссылок.",
"Saved Discord announcements will retry.": "Сохранённые объявления Discord будут отправлены повторно.",
"Publish party to Discord": "Опубликовать лобби в Discord",
"Publishing…": "Публикация…",
"Publishing shares this invite with everyone in the community Discord channel.": "После публикации это приглашение увидят все участники общего канала Discord.",
"Party posted to Discord.": "Лобби опубликовано в Discord.",
"Party announcement is waiting for Discord delivery.": "Объявление о лобби ожидает отправки в Discord.",
"Discord delivery needs an administrator to check the bot setup and retry.": "Администратору нужно проверить настройку бота и повторить отправку.",
"This party announcement expired or the lobby is no longer available.": "Объявление устарело или лобби больше недоступно.",
"Party announcement queued. Anyone in the Discord channel can open the invite and join.": "Объявление добавлено в очередь. Любой участник канала Discord сможет открыть приглашение и присоединиться.",
"Only the room owner can publish this party": "Только хозяин лобби может опубликовать его",
"Discord party sharing is not configured yet": "Публикация лобби в Discord пока не настроена",
"Only open lobbies can be published": "Можно публиковать только открытые лобби",
"Only parties on published maps can be shared on Discord": "В Discord можно публиковать только лобби с общедоступными картами",
"Configure the Discord bot token, public site URL, and at least one channel ID.": "Настройте токен бота Discord, публичный адрес сайта и хотя бы один ID канала.",
"Discord bot token is invalid. Update it in Vercel.": "Токен бота Discord недействителен. Обновите его в Vercel.",
"Discord bot needs View Channel, Send Messages, and Embed Links in the configured channel.": "Боту Discord нужны права View Channel, Send Messages и Embed Links в выбранном канале.",
"Discord channel was not found. Check the channel ID and bot access.": "Канал Discord не найден. Проверьте ID канала и доступ бота.",
"Discord channel ID is invalid.": "Неверный ID канала Discord.",
"Choose a Discord server text or announcement channel.": "Выберите текстовый канал или канал объявлений на сервере Discord.",
"Use a Discord bot token.": "Используйте токен бота Discord.",
"Discord could not be reached. Delivery will retry.": "Discord недоступен. Отправка будет повторена.",
"Discord rate limit. Delivery will retry.": "Discord ограничил частоту запросов. Отправка будет повторена.",
"Discord rejected this notification.": "Discord отклонил это объявление.",
"Discord did not confirm delivery.": "Discord не подтвердил отправку.",
"Discord delivery could not be confirmed. Delivery will retry.": "Не удалось подтвердить отправку в Discord. Она будет повторена."
}

View File

@@ -48,6 +48,7 @@ export type RoomResult = {
guess: { x: number; z: number } | null;
};
export type RoomView = {
discord?: import("./discord").PartyAnnouncement;
id: string;
datasetId: string;
name: string;

10
shared/permissions.ts Normal file
View File

@@ -0,0 +1,10 @@
export type AccountRole = "contributor" | "moderator" | "admin";
export type StaffRole = Exclude<AccountRole, "contributor">;
export function canReviewMaps(role: string | undefined): role is StaffRole {
return role === "moderator" || role === "admin";
}
export function isAdministrator(role: string | undefined) {
return role === "admin";
}

View File

@@ -0,0 +1,119 @@
-- Admin accounts manage settings and enforcement. Moderators review map submissions.
alter table public.profiles drop constraint profiles_role_check;
alter table public.profiles add constraint profiles_role_check
check (role in ('contributor','moderator','admin'));
create or replace function public.publish_dataset(p_dataset uuid,p_reviewer uuid,p_decision text,p_reason text default null) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; m jsonb; rev uuid; first jsonb; count integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role in ('moderator','admin')) then raise exception 'Moderator permission required'; end if;
if p_decision='unpublish' and not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then raise exception 'Admin permission required'; end if;
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
if not found then raise exception 'Map not found'; end if;
if p_decision='approve' then
if not(d.state='review' or (d.state='published' and coalesce(d.update_state,'')='review')) then raise exception 'Map is not ready for publication'; end if;
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
count:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0;
if m is null or count<5 or count>24 then raise exception 'Map requires 5–24 validated panoramas'; end if;
insert into public.revisions(dataset_id,manifest,asset_root) values(p_dataset,m,p_dataset::text) returning id into rev;
update public.datasets set state='published',revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=count,
map_path=coalesce(m->>'mapPath',d.map_path),
cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=p_dataset;
elsif p_decision='reject' and (d.state='review' or (d.state='published' and d.update_state='review')) then
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide a rejection reason'; end if;
update public.datasets set state=case when state='published' then state else 'rejected' end,update_state=case when state='published' then 'rejected' end,rejection_reason=p_reason,updated_at=now() where id=p_dataset;
elsif p_decision='unpublish' and d.state='published' then
update public.datasets set state='unpublished',update_state=null,updated_at=now() where id=p_dataset;
else raise exception 'Invalid review transition'; end if;
insert into public.review_decisions(dataset_id,reviewer_id,decision,reason) values(p_dataset,p_reviewer,p_decision,p_reason);
end; $$;
create or replace function public.review_content_report(p_report uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare r public.reports; d public.datasets; map_id uuid; strikes integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
select dataset_id into map_id from public.reports where id=p_report;
select * into d from public.datasets where id=map_id for update;
select * into r from public.reports where id=p_report for update;
if not found or r.state<>'open' or r.report_cycle<>d.report_cycle then raise exception 'Report is no longer awaiting review'; end if;
if p_decision is null or p_decision not in ('confirm','dismiss') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide a review decision and feedback in 10–2000 characters'; end if;
if p_decision='confirm' then
if d.banned_at is not null then raise exception 'This map is already banned; dismiss the remaining report or review its appeal'; end if;
if p_reviewer=r.owner_id or p_reviewer=d.owner_id then raise exception 'An independent moderator must confirm this report'; end if;
-- Distinct reporters provide each strike, including legacy reports.
if exists(select 1 from public.reports where dataset_id=d.id and owner_id=r.owner_id and report_cycle=d.report_cycle and state='confirmed') then raise exception 'This reporter already contributed a strike'; end if;
strikes:=d.report_strikes+1;
update public.datasets set report_strikes=strikes,
state=case when strikes>=3 then 'unpublished' else state end,
update_state=case when strikes>=3 then null else update_state end,
banned_at=case when strikes>=3 then now() end,
ban_reason=case when strikes>=3 then trim(p_reason) end,updated_at=now() where id=d.id;
end if;
update public.reports set state=case when p_decision='confirm' then 'confirmed' else 'dismissed' end,
reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=r.id;
end; $$;
create or replace function public.review_map_appeal(p_appeal uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare a public.map_appeals; d public.datasets; map_id uuid;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
select dataset_id into map_id from public.map_appeals where id=p_appeal;
select * into d from public.datasets where id=map_id for update;
select * into a from public.map_appeals where id=p_appeal for update;
if not found or a.state<>'open' or d.banned_at is null or a.report_cycle<>d.report_cycle then raise exception 'Appeal is no longer awaiting review'; end if;
if p_reviewer=d.owner_id then raise exception 'An independent moderator must review your appeal'; end if;
if p_decision is null or p_decision not in ('approve','reject') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide an appeal decision and feedback in 10–2000 characters'; end if;
if p_decision='approve' then
if d.revision_id is null then raise exception 'Map has no approved version to restore'; end if;
update public.datasets set report_strikes=0,report_cycle=report_cycle+1,banned_at=null,ban_reason=null,state='published',updated_at=now() where id=d.id;
update public.reports set state='dismissed',reviewer_id=p_reviewer,reviewed_at=now(),review_reason='Closed when the map ban appeal was approved.' where dataset_id=d.id and report_cycle=d.report_cycle and state='open';
end if;
update public.map_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=a.id;
end; $$;
create or replace function public.set_beta_badge(p_reviewer uuid, p_enabled boolean)
returns boolean language plpgsql security invoker set search_path = '' as $$
begin
if not exists (
select 1 from public.profiles where id = p_reviewer and role = 'admin'
) then
raise exception 'Admin permission required';
end if;
if p_enabled is null then
raise exception 'Beta badge visibility is required';
end if;
update public.site_settings
set beta_badge_enabled = p_enabled, updated_at = now(), updated_by = p_reviewer
where id = true;
if not found then raise exception 'Site settings not found'; end if;
return p_enabled;
end;
$$;
create or replace function public.set_user_prefix(p_reviewer uuid,p_user uuid,p_prefix text,p_icon text) returns void
language plpgsql security invoker set search_path='' as $$
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
if p_icon not in ('star','crown','heart','flame','gem','trophy','sparkles') or p_icon is null then raise exception 'Invalid prefix icon'; end if;
if p_prefix is not null and (length(btrim(p_prefix)) not between 1 and 24 or p_prefix ~ '[[:cntrl:]]') then raise exception 'Invalid custom prefix'; end if;
update public.profiles set custom_prefix=case when p_prefix is null then null else btrim(p_prefix) end,custom_prefix_icon=p_icon where id=p_user;
if not found then raise exception 'Player not found'; end if;
end; $$;
revoke all on function public.publish_dataset(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.publish_dataset(uuid,uuid,text,text) to service_role;
revoke all on function public.review_content_report(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.review_content_report(uuid,uuid,text,text) to service_role;
revoke all on function public.review_map_appeal(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.review_map_appeal(uuid,uuid,text,text) to service_role;
revoke all on function public.set_beta_badge(uuid,boolean) from public,anon,authenticated;
grant execute on function public.set_beta_badge(uuid,boolean) to service_role;
revoke all on function public.set_user_prefix(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.set_user_prefix(uuid,uuid,text,text) to service_role;

View File

@@ -0,0 +1,100 @@
-- Durable, server-only announcements. One event per public map or shared party.
create table public.discord_announcements (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('map','party')),
target_id uuid not null,
state text not null default 'pending' check (state in ('pending','sending','sent','skipped','failed')),
attempts integer not null default 0 check (attempts>=0),
next_attempt_at timestamptz not null default clock_timestamp(),
lease_owner text,
lease_until timestamptz,
channel_id text,
message_id text,
last_error text,
created_at timestamptz not null default clock_timestamp(),
sent_at timestamptz,
unique(kind,target_id)
);
create index discord_announcements_due on public.discord_announcements(next_attempt_at,created_at)
where state in ('pending','sending');
alter table public.discord_announcements enable row level security;
revoke all on public.discord_announcements from public,anon,authenticated;
grant all on public.discord_announcements to service_role;
create policy "Server manages Discord delivery" on public.discord_announcements
for all to service_role using (true) with check (true);
create function public.queue_new_map_discord() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if new.state='published' and old.state<>'published' and old.revision_id is null
and new.parent_dataset_id is null and new.imported_from_id is null then
insert into public.discord_announcements(kind,target_id) values('map',new.id)
on conflict(kind,target_id) do nothing;
end if;
return new;
end; $$;
create trigger queue_new_map_discord after update of state on public.datasets
for each row execute function public.queue_new_map_discord();
create function public.publish_discord_party(p_room uuid,p_access_hash text) returns uuid
language plpgsql security invoker set search_path='' as $$
declare room public.multiplayer_rooms; event_id uuid; active integer;
begin
select * into room from public.multiplayer_rooms where id=p_room for update;
if not found then raise exception 'Room not found'; end if;
if not exists(select 1 from public.multiplayer_players where room_id=p_room
and id=room.host_player_id and access_hash=p_access_hash and not has_left) then
raise exception 'Only the room owner can publish this party';
end if;
if room.status<>'lobby' or room.created_at<clock_timestamp()-interval '24 hours' then
raise exception 'Only open lobbies can be published';
end if;
select count(*) into active from public.multiplayer_players where room_id=p_room and not has_left;
if active>=room.player_limit then raise exception 'This room is full'; end if;
if not exists(select 1 from public.revisions r join public.datasets d on d.id=r.dataset_id
where r.id=room.revision_id and d.state='published' and d.parent_dataset_id is null and d.imported_from_id is null) then
raise exception 'Only parties on published maps can be shared on Discord';
end if;
insert into public.discord_announcements(kind,target_id) values('party',p_room)
on conflict(kind,target_id) do nothing;
select id into event_id from public.discord_announcements where kind='party' and target_id=p_room;
return event_id;
end; $$;
create function public.claim_discord_announcements(p_worker text,p_kinds text[],p_target uuid default null)
returns setof public.discord_announcements language sql security invoker set search_path='' as $$
with exhausted as (
update public.discord_announcements set state='failed',lease_owner=null,lease_until=null,
last_error='Discord delivery could not be confirmed. Check the channel before retrying.'
where state='sending' and attempts>=8 and lease_until<clock_timestamp() returning id
)
update public.discord_announcements e set state='sending',attempts=e.attempts+1,
lease_owner=p_worker,lease_until=clock_timestamp()+interval '3 minutes'
where e.id in (
select id from public.discord_announcements where kind=any(p_kinds)
and (p_target is null or target_id=p_target) and state in ('pending','sending')
and attempts<8 and next_attempt_at<=clock_timestamp()
and (lease_until is null or lease_until<clock_timestamp())
order by created_at,id limit 5 for update skip locked
) returning e.*;
$$;
create function public.retry_discord_announcements(p_reviewer uuid) returns integer
language plpgsql security invoker set search_path='' as $$
declare changed integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then
raise exception 'Admin permission required';
end if;
update public.discord_announcements set state='pending',attempts=0,next_attempt_at=clock_timestamp(),last_error=null,
lease_owner=null,lease_until=null where state='failed' or (state='pending' and lease_owner is null)
or (state='sending' and lease_until<clock_timestamp());
get diagnostics changed=row_count;
return changed;
end; $$;
revoke all on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid)
from public,anon,authenticated;
grant execute on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid) to service_role;

View File

@@ -0,0 +1,82 @@
-- Preserve the original Discord destination even if environment settings change.
alter table public.discord_announcements
add column closed_at timestamptz,
add column delete_after timestamptz,
add column deleted_at timestamptz,
add column cleanup_run_id text,
add column cleanup_lease_until timestamptz,
add column cleanup_error text;
create index discord_cleanup_due on public.discord_announcements(closed_at)
where kind='party' and state='sent' and deleted_at is null;
create function public.close_party_discord() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if tg_op='DELETE' then
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
where kind='party' and target_id=old.id and state='sent';
return old;
end if;
if new.status='closed' then
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
where kind='party' and target_id=new.id and state='sent';
end if;
return new;
end; $$;
create trigger close_party_discord after update of status or delete on public.multiplayer_rooms
for each row execute function public.close_party_discord();
-- Handle the race where closing a room happens while Discord is acknowledging a send.
create function public.capture_closed_discord_party() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if new.kind='party' and new.state='sent' and new.closed_at is null
and not exists(select 1 from public.multiplayer_rooms where id=new.target_id and status<>'closed') then
new.closed_at=clock_timestamp();
end if;
return new;
end; $$;
create trigger capture_closed_discord_party before insert or update of state,channel_id,message_id
on public.discord_announcements for each row execute function public.capture_closed_discord_party();
-- Older closed announcements receive the same red notice and five-minute grace period.
update public.discord_announcements e set closed_at=clock_timestamp()
where kind='party' and state='sent' and not exists(
select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
create function public.claim_discord_cleanup(p_worker text,p_target uuid default null)
returns setof public.discord_announcements language plpgsql security invoker set search_path='' as $$
begin
-- Reconcile overlapping close/send transactions before claiming cleanup.
update public.discord_announcements e set closed_at=clock_timestamp()
where kind='party' and state='sent' and closed_at is null
and (p_target is null or target_id=p_target)
and not exists(select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
return query
update public.discord_announcements e set cleanup_run_id=p_worker,
cleanup_lease_until=clock_timestamp()+interval '3 minutes'
where e.id in (
select id from public.discord_announcements where kind='party' and state='sent'
and closed_at is not null and deleted_at is null and channel_id is not null and message_id is not null
and (p_target is null or target_id=p_target)
and (cleanup_run_id is null or cleanup_run_id like 'starting:%')
and (cleanup_lease_until is null or cleanup_lease_until<clock_timestamp())
order by closed_at,id limit 20 for update skip locked
) returning e.*;
end;
$$;
-- Only start the grace period after Discord has acknowledged the red notice.
-- Replayed workflows and retries keep the original deadline.
create function public.mark_discord_party_closed(p_event uuid) returns timestamptz
language sql security invoker set search_path='' as $$
update public.discord_announcements set delete_after=coalesce(delete_after,clock_timestamp()+interval '5 minutes'),
cleanup_error=null
where id=p_event and kind='party' and state='sent' and closed_at is not null and deleted_at is null
returning delete_after;
$$;
revoke all on function public.close_party_discord(),public.capture_closed_discord_party(),
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) from public,anon,authenticated;
grant execute on function public.close_party_discord(),public.capture_closed_discord_party(),
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) to service_role;

293
tests/discord.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
announcementMessage,
discordConfiguration,
DiscordClient,
DiscordDeliveryError,
closedPartyMessage,
} from "../lib/discord-client";
import { deliverDiscordAnnouncement } from "../lib/discord-delivery";
import type { DiscordAnnouncement } from "../shared/discord";
const channel = "123456789012345678",
messageId = "223456789012345678",
target = "00000000-0000-4000-8000-000000000001";
const job: DiscordAnnouncement = {
id: target,
kind: "party",
target_id: target,
state: "sending",
attempts: 1,
next_attempt_at: new Date(0).toISOString(),
lease_owner: "worker",
lease_until: new Date(100000).toISOString(),
channel_id: null,
message_id: null,
last_error: null,
created_at: new Date(0).toISOString(),
sent_at: null,
};
const source = {
id: target,
name: "Map @everyone [click](https://evil.example)",
owner: "Host <@123>",
difficulty: "normal",
players: 1,
limit: 8,
};
test("closed Discord parties turn red, lose their join prompt, and delete idempotently", async () => {
const payload = closedPartyMessage("https://minegeo.vercel.app", target);
assert.equal(payload.embeds[0].color, 0xed4245);
assert.ok(payload.embeds[0].description.includes("five minutes"));
assert.ok(!JSON.stringify(payload).includes("Join this party"));
assert.deepEqual(payload.allowed_mentions, { parse: [] });
const methods: string[] = [];
const client = new DiscordClient("private-token", async (url, init) => {
assert.equal(
url,
`https://discord.com/api/v10/channels/${channel}/messages/${messageId}`,
);
methods.push(init!.method!);
if (init?.method === "PATCH") {
assert.deepEqual(JSON.parse(String(init.body)), payload);
return Response.json({ id: messageId, channel_id: channel });
}
assert.equal(init?.body, undefined);
return new Response(null, { status: 204 });
});
assert.equal(
await client.closeParty(
channel,
messageId,
"https://minegeo.vercel.app",
target,
),
true,
);
await client.remove(channel, messageId);
assert.deepEqual(methods, ["PATCH", "DELETE"]);
const missing = new DiscordClient("token", async () =>
Response.json({ code: 10008 }, { status: 404 }),
);
assert.equal(
await missing.closeParty(
channel,
messageId,
"https://minegeo.vercel.app",
target,
),
false,
);
await missing.remove(channel, messageId);
await assert.rejects(
new DiscordClient("token", async () =>
Response.json({ code: 10003 }, { status: 404 }),
).remove(channel, messageId),
/channel was not found/,
);
await assert.rejects(
new DiscordClient("token", async () =>
Response.json({ retry_after: 10 }, { status: 429 }),
).remove(channel, messageId),
(e) => e instanceof DiscordDeliveryError && e.retryAfter === 10,
);
});
test("Discord config requires a public HTTPS site and valid channel IDs without exposing a public token", () => {
const env = {
MINEGEO_URL: "https://minegeo.vercel.app",
DISCORD_BOT_TOKEN: "test-token",
DISCORD_PARTIES_CHANNEL_ID: channel,
};
assert.equal(discordConfiguration(env).partyChannel, channel);
for (const url of [
"http://localhost:3000",
"https://user:secret@example.com",
"https://example.com/private?key=secret",
])
assert.equal(
discordConfiguration({ ...env, MINEGEO_URL: url }).origin,
null,
);
for (const id of ["bad", "123", `${channel}/messages`])
assert.equal(
discordConfiguration({ ...env, DISCORD_PARTIES_CHANNEL_ID: id })
.partyChannel,
null,
);
assert.equal(
discordConfiguration({ ...env, DISCORD_BOT_TOKEN: "secret\nheader" }).token,
null,
);
});
test("Discord announcements use fixed public links, bounded escaped text, suppressed mentions and a stable nonce", () => {
const body = announcementMessage(
job.id,
"party",
"https://minegeo.vercel.app",
source,
);
assert.deepEqual(body.allowed_mentions, { parse: [] });
assert.equal(
body.embeds[0].url,
`https://minegeo.vercel.app/multiplayer/${target}`,
);
assert.ok(!body.embeds[0].title.includes("[click]("));
assert.equal(body.nonce.length, 24);
assert.equal(body.enforce_nonce, true);
assert.equal(
body.nonce,
announcementMessage(job.id, "party", "https://minegeo.vercel.app", source)
.nonce,
);
assert.notEqual(
body.nonce,
announcementMessage(
"another",
"party",
"https://minegeo.vercel.app",
source,
).nonce,
);
const map = announcementMessage(job.id, "map", "https://minegeo.vercel.app", {
...source,
name: "A".repeat(500),
locations: 10,
});
assert.equal(map.embeds[0].title.length, 256);
assert.equal(map.embeds[0].fields[1].value, "10");
});
test("Discord REST sends the bot token only to Discord, confirms message and channel identity, and honors rate limits", async () => {
const payload = announcementMessage(
job.id,
"party",
"https://minegeo.vercel.app",
source,
);
const client = new DiscordClient("private-token", async (url, init) => {
assert.equal(
url,
`https://discord.com/api/v10/channels/${channel}/messages`,
);
assert.equal(
(init?.headers as Record<string, string>).Authorization,
"Bot private-token",
);
assert.equal(init?.redirect, "error");
assert.deepEqual(JSON.parse(String(init?.body)), payload);
return Response.json({ id: messageId, channel_id: channel });
});
assert.equal(await client.send(channel, payload), messageId);
const limited = new DiscordClient("token", async () =>
Response.json({ retry_after: 65.4 }, { status: 429 }),
);
await assert.rejects(
limited.send(channel, payload),
(e) =>
e instanceof DiscordDeliveryError && e.retryAfter === 66 && !e.permanent,
);
for (const status of [401, 403, 404]) {
const bad = new DiscordClient("secret-token", async () =>
Response.json({ message: "secret-token internal raw error" }, { status }),
);
await assert.rejects(
bad.send(channel, payload),
(e) =>
e instanceof DiscordDeliveryError &&
e.permanent &&
!e.message.includes("secret-token"),
);
}
await assert.rejects(
new DiscordClient("token", async () =>
Response.json({ id: messageId, channel_id: "wrong" }),
).send(channel, payload),
/did not confirm/,
);
await assert.rejects(
new DiscordClient("token", async () => {
throw new Error("token-secret");
}).send(channel, payload),
/could not be reached/,
);
});
test("durable delivery skips unavailable parties, records acknowledged sends and preserves recoverable failures", async () => {
const run = async (options: {
source: typeof source | null;
fail?: Error;
attempts?: number;
}) => {
const patches: Partial<DiscordAnnouncement>[] = [],
requests: unknown[] = [];
await deliverDiscordAnnouncement(
{ ...job, attempts: options.attempts ?? 1 },
{
origin: "https://minegeo.vercel.app",
channel,
now: () => 1000,
load: async () => options.source,
client: {
send: async (...args) => {
requests.push(args);
if (options.fail) throw options.fail;
return messageId;
},
},
finish: async (patch) => {
patches.push(patch);
},
},
);
return { patch: patches.at(-1)!, requests };
};
const skipped = await run({ source: null });
assert.equal(skipped.patch.state, "skipped");
assert.equal(skipped.requests.length, 0);
const sent = await run({ source });
assert.equal(sent.patch.state, "sent");
assert.equal(sent.patch.message_id, messageId);
assert.equal(sent.patch.lease_owner, null);
const rate = await run({
source,
fail: new DiscordDeliveryError("Rate limited", 65),
});
assert.equal(rate.patch.state, "pending");
assert.equal(Date.parse(rate.patch.next_attempt_at!), 66000);
assert.equal(
(
await run({
source,
fail: new DiscordDeliveryError("Bad credentials", 60, true),
})
).patch.state,
"failed",
);
assert.equal(
(await run({ source, attempts: 8, fail: new Error("private raw error") }))
.patch.state,
"failed",
);
});
test("Discord setup check only reads bot/channel details and rejects non-server text channels", async () => {
const paths: string[] = [];
const bot = new DiscordClient("token", async (url, init) => {
assert.equal(init?.method, "GET");
paths.push(String(url));
return Response.json(
String(url).endsWith("@me")
? { bot: true, username: "MineGeo" }
: { guild_id: channel, type: 0 },
);
});
assert.equal((await bot.check([channel, channel])).botName, "MineGeo");
assert.equal(paths.length, 2);
await assert.rejects(
new DiscordClient("token", async (url) =>
Response.json(
String(url).endsWith("@me")
? { bot: true, username: "Test" }
: { guild_id: channel, type: 2 },
),
).check([channel]),
/text or announcement channel/,
);
});

19
tests/permissions.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canReviewMaps, isAdministrator } from "../shared/permissions";
test("only staff can review maps and only admins have administrative access", () => {
for (const role of [
undefined,
"contributor",
"Admin",
"owner",
"administrator",
]) {
assert.equal(canReviewMaps(role), false);
assert.equal(isAdministrator(role), false);
}
assert.equal(canReviewMaps("moderator"), true);
assert.equal(isAdministrator("moderator"), false);
assert.equal(canReviewMaps("admin"), true);
assert.equal(isAdministrator("admin"), true);
});

View File

@@ -15,7 +15,7 @@ begin
select id into fourth from auth.users where id not in (owner,moderator,reporter,second,third) order by id limit 1;
if fourth is null then raise exception 'Six fixture accounts are required'; end if;
insert into public.profiles(id) values(owner),(moderator),(reporter),(second),(third),(fourth) on conflict(id) do nothing;
update public.profiles set role='moderator' where id=moderator;
update public.profiles set role='admin' where id=moderator;
update public.profiles set role='contributor' where id in (owner,reporter,second,third,fourth);
insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values(owner,'Report regression','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id into fixture;
@@ -38,7 +38,7 @@ begin
begin
perform public.review_content_report(report,owner,'confirm','Confirmed by the map owner.');
raise exception 'Unauthorized moderator succeeded';
exception when others then if sqlerrm<>'Moderator permission required' then raise; end if; end;
exception when others then if sqlerrm<>'Admin permission required' then raise; end if; end;
perform public.review_content_report(report,moderator,'dismiss','The reported terrain looks correct.');
if (select report_strikes from public.datasets where id=fixture)<>0 then raise exception 'Dismissed report added a strike'; end if;
report:=public.submit_content_report(fixture,reporter,revision,null,'spam','The map contains inappropriate advertisements.');