Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c87153b4 | ||
|
|
5e693e2cdd | ||
|
|
9440c651b6 | ||
|
|
ccb44fa253 | ||
|
|
cee0378f8f | ||
|
|
478f3a3b87 | ||
|
|
d91572b831 |
@@ -24,3 +24,6 @@ IP_INTELLIGENCE_PROVIDER=proxycheck
|
||||
PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -130,10 +130,21 @@ jobs:
|
||||
--target runner \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
||||
-t git.garvis.dev/dmg/minecraft-account-manager:latest \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
||||
docker push git.garvis.dev/dmg/minecraft-account-manager:latest
|
||||
|
||||
- name: Build and push Discord bot image
|
||||
if: steps.release.outputs.created == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
--target bot \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
||||
|
||||
- name: Build and push migration image
|
||||
if: steps.release.outputs.created == 'true'
|
||||
@@ -145,10 +156,8 @@ jobs:
|
||||
--target migrate \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
||||
-t git.garvis.dev/dmg/minecraft-account-manager-migrate:latest \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
||||
docker push git.garvis.dev/dmg/minecraft-account-manager-migrate:latest
|
||||
|
||||
- name: Create Gitea release and upload Velocity JAR
|
||||
if: steps.release.outputs.created == 'true'
|
||||
|
||||
+17
@@ -10,6 +10,7 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json
|
||||
COPY packages/auth/package.json ./packages/auth/package.json
|
||||
COPY packages/contracts/package.json ./packages/contracts/package.json
|
||||
COPY packages/database/package.json ./packages/database/package.json
|
||||
COPY packages/logging/package.json ./packages/logging/package.json
|
||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
||||
COPY packages/network/package.json ./packages/network/package.json
|
||||
RUN npm ci
|
||||
@@ -25,6 +26,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION} \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
@@ -34,6 +36,21 @@ USER app
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
FROM dependencies AS bot
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION}
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
|
||||
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
|
||||
COPY --chown=app:app packages ./packages
|
||||
USER app
|
||||
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
||||
|
||||
FROM dependencies AS migrate
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
|
||||
|
||||
@@ -3,3 +3,4 @@ APP_URL=http://localhost:3000
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
|
||||
@@ -2,10 +2,22 @@ import "dotenv/config";
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||
const rest = new REST({ version: "10" }).setToken(token);
|
||||
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
||||
try {
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
logger.info(
|
||||
{ event: "discord.commands_deployed", commandCount: commands.length },
|
||||
"Deployed global Discord commands",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.fatal(
|
||||
{ err: error, event: "discord.commands_deploy_failed" },
|
||||
"Failed to deploy global Discord commands",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
import { commandNames, requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const appUrl = requiredEnvironment("APP_URL");
|
||||
@@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db);
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
||||
logger.info(
|
||||
{ event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username },
|
||||
"Discord bot is ready",
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
@@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||
return;
|
||||
}
|
||||
console.error("Failed to create Discord account link", error);
|
||||
logger.error(
|
||||
{ err: error, event: "discord.magic_link_failed", command: interaction.commandName },
|
||||
"Failed to create Discord account link",
|
||||
);
|
||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
try {
|
||||
await client.login(token);
|
||||
} catch (error) {
|
||||
logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-discord-bot");
|
||||
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@minecraft-account-manager/contracts",
|
||||
"@minecraft-account-manager/database",
|
||||
"@minecraft-account-manager/logging",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
@@ -284,7 +285,10 @@ export async function POST(request: Request) {
|
||||
));
|
||||
}
|
||||
|
||||
console.error("Velocity access request failed");
|
||||
logger.error(
|
||||
{ err: error, event: "velocity.access_failed", instance },
|
||||
"Velocity access request failed",
|
||||
);
|
||||
return problemResponse(problemDetails(
|
||||
"urn:error:service-unavailable",
|
||||
"Service unavailable",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-ac
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { applicationUrl } from "@/lib/application-url";
|
||||
import { db } from "@/lib/database";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
const response = NextResponse.redirect(applicationUrl(destination));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
return NextResponse.redirect(applicationUrl("/auth/error"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("health endpoint", () => {
|
||||
it("reports process readiness without requiring external services", async () => {
|
||||
const response = GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function GET(): Response {
|
||||
return Response.json(
|
||||
{ status: "ok" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applicationUrl } from "./application-url";
|
||||
|
||||
describe("applicationUrl", () => {
|
||||
it("builds browser redirects from the configured public application URL", () => {
|
||||
expect(applicationUrl("/welcome", "https://portal.somc.club"))
|
||||
.toEqual(new URL("https://portal.somc.club/welcome"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function applicationUrl(path: string, baseUrl = process.env.APP_URL) {
|
||||
if (!baseUrl) throw new Error("APP_URL is required to build public application URLs");
|
||||
return new URL(path, baseUrl);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/database";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const classifications = new Set<IpClassification>([
|
||||
"unknown",
|
||||
@@ -77,6 +78,13 @@ export async function getIpIntelligence(
|
||||
options: { now?: Date; forceRefresh?: boolean } = {},
|
||||
): Promise<IpIntelligenceResult> {
|
||||
if (!isPublicIp(ipAddress)) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "ip_intelligence.skipped",
|
||||
reason: "non_public_address",
|
||||
},
|
||||
"IP intelligence lookup skipped for a non-public client address",
|
||||
);
|
||||
return { classification: "unknown", provider: null };
|
||||
}
|
||||
|
||||
@@ -96,14 +104,23 @@ export async function getIpIntelligence(
|
||||
const result = await provider.classify(ipAddress);
|
||||
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
||||
return result;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
|
||||
const result: IpIntelligenceResult = {
|
||||
classification: "unknown",
|
||||
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
|
||||
provider: providerName,
|
||||
lookupError: true,
|
||||
};
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
|
||||
console.error("IP intelligence lookup failed");
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
|
||||
logger.error(
|
||||
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
|
||||
"Failed to cache an IP intelligence lookup error",
|
||||
);
|
||||
});
|
||||
logger.error(
|
||||
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
|
||||
"IP intelligence lookup failed",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
if (!ipAddress) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "client_ip.unavailable",
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
|
||||
realIpPresent: requestHeaders.has("x-real-ip"),
|
||||
},
|
||||
"Client IP address was unavailable for account addition",
|
||||
);
|
||||
return {
|
||||
allowed: false as const,
|
||||
reason: "unavailable" as const,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-web");
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
|
||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
||||
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
|
||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
||||
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
|
||||
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
||||
* **Document**: Added container deployment order, artifact names, and required repository secrets.
|
||||
* **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Authenticate with a Discord magic link
|
||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||
tags: [player, discord, authentication, security]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T20:43:46Z
|
||||
story_id: US-002
|
||||
status: verified
|
||||
---
|
||||
@@ -19,6 +19,7 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||
|
||||
# Implementation
|
||||
@@ -27,10 +28,12 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
||||
- Discord command and authentication workspaces pass TypeScript validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Deploy and operate the platform securely
|
||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||
tags: [operations, security, database, deployment]
|
||||
timestamp: 2026-08-01T19:01:47Z
|
||||
timestamp: 2026-08-01T21:37:26Z
|
||||
story_id: US-015
|
||||
status: verified
|
||||
---
|
||||
@@ -21,6 +21,8 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
|
||||
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
|
||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||
|
||||
@@ -32,10 +34,11 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`.
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ type: User Story
|
||||
title: Build and publish versioned releases
|
||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
||||
tags: [operations, ci, release, velocity, docker]
|
||||
timestamp: 2026-08-01T19:01:47Z
|
||||
timestamp: 2026-08-01T20:05:49Z
|
||||
story_id: US-016
|
||||
status: implemented
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
@@ -14,14 +14,16 @@ As a platform operator, I want automated validation and semantic releases, so th
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [ ] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
|
||||
- [ ] Pull requests validate conventional commit messages.
|
||||
- [ ] CI uploads the development Velocity JAR as a workflow artifact.
|
||||
- [ ] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
||||
- [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI uploads the development Velocity JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
||||
- [x] A release build embeds the semantic version in the Velocity plugin and JAR filename.
|
||||
- [ ] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
||||
- [ ] Releases publish versioned and `latest` web runtime images to the Gitea registry.
|
||||
- [ ] Releases publish versioned and `latest` migration images that run versioned Drizzle migrations.
|
||||
- [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
||||
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
|
||||
- [x] Releases do not publish mutable container tags such as `latest`.
|
||||
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
|
||||
- [x] Operators are told which repository secrets must be configured before the first push.
|
||||
|
||||
@@ -36,7 +38,7 @@ As a platform operator, I want automated validation and semantic releases, so th
|
||||
|
||||
# Validation
|
||||
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. A test `1.2.3` JAR was generated with matching Velocity metadata. Workflow YAML parses successfully. Container builds and remote publication remain pending because the local Docker daemon is unavailable and the first push is intentionally paused until repository secrets are configured.
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
+6
-3
@@ -32,11 +32,14 @@ Each release creates:
|
||||
|
||||
- Gitea release asset `minecraft-account-manager-velocity-VERSION.jar`
|
||||
- `git.garvis.dev/dmg/minecraft-account-manager:VERSION`
|
||||
- `git.garvis.dev/dmg/minecraft-account-manager:latest`
|
||||
- `git.garvis.dev/dmg/minecraft-account-manager-bot:VERSION`
|
||||
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:VERSION`
|
||||
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:latest`
|
||||
|
||||
Use immutable version tags for deployments. `latest` is a convenience pointer to the newest release.
|
||||
Only immutable semantic-version tags are published. Mutable tags such as `latest` must never be used in deployments or release-asset URLs.
|
||||
|
||||
## Discord bot
|
||||
|
||||
Run exactly one bot replica with the same immutable release version as the web application. It requires `DATABASE_URL`, `APP_URL`, `DISCORD_BOT_TOKEN`, and `DISCORD_GUILD_ID`. Deploy slash commands separately with the release image when command definitions change.
|
||||
|
||||
## Database migration
|
||||
|
||||
|
||||
Generated
+155
@@ -22,6 +22,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
@@ -38,6 +39,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -1781,6 +1783,10 @@
|
||||
"resolved": "apps/discord-bot",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@minecraft-account-manager/logging": {
|
||||
"resolved": "packages/logging",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@minecraft-account-manager/minecraft": {
|
||||
"resolved": "packages/minecraft",
|
||||
"link": true
|
||||
@@ -2038,6 +2044,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
||||
@@ -3810,6 +3822,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -7052,6 +7073,15 @@
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
@@ -7220,6 +7250,43 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7317,6 +7384,22 @@
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -7360,6 +7443,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -7388,6 +7477,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -7600,6 +7698,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -7834,6 +7941,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -7864,6 +7980,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -8106,6 +8231,24 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -9215,6 +9358,18 @@
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"packages/logging": {
|
||||
"name": "@minecraft-account-manager/logging",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"packages/minecraft": {
|
||||
"name": "@minecraft-account-manager/minecraft",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/logging",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import pino, { type DestinationStream, type Logger } from "pino";
|
||||
|
||||
const redactedPaths = [
|
||||
"apiKey",
|
||||
"authorization",
|
||||
"password",
|
||||
"token",
|
||||
"*.apiKey",
|
||||
"*.authorization",
|
||||
"*.password",
|
||||
"*.token",
|
||||
"headers.authorization",
|
||||
"req.headers.authorization",
|
||||
];
|
||||
|
||||
export function createLogger(
|
||||
service: string,
|
||||
options: { destination?: DestinationStream } = {},
|
||||
): Logger {
|
||||
return pino(
|
||||
{
|
||||
level: process.env.LOG_LEVEL?.trim() || "info",
|
||||
base: {
|
||||
service,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
version: process.env.APP_VERSION ?? "development",
|
||||
},
|
||||
redact: {
|
||||
paths: redactedPaths,
|
||||
censor: "[Redacted]",
|
||||
},
|
||||
},
|
||||
options.destination,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLogger } from "../src/index";
|
||||
|
||||
function captureLog() {
|
||||
let output = "";
|
||||
const destination = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
output += chunk.toString();
|
||||
callback();
|
||||
},
|
||||
});
|
||||
return { destination, read: () => JSON.parse(output.trim()) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
describe("structured application logging", () => {
|
||||
it("emits service metadata and redacts credential fields", () => {
|
||||
const capture = captureLog();
|
||||
const logger = createLogger("account-manager-test", { destination: capture.destination });
|
||||
|
||||
logger.info({ token: "secret-token", apiKey: "secret-key", operation: "test" }, "Test event");
|
||||
|
||||
expect(capture.read()).toMatchObject({
|
||||
service: "account-manager-test",
|
||||
token: "[Redacted]",
|
||||
apiKey: "[Redacted]",
|
||||
operation: "test",
|
||||
msg: "Test event",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node", "vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user