Compare commits

..

4 Commits

Author SHA1 Message Date
syntaxbullet
1721ee469d fix: bundle consistent fonts for share card rendering 2026-09-07 11:57:23 +02:00
syntaxbullet
e6002ad619 fix: block development writes to Discord channels 2026-09-07 11:57:23 +02:00
syntaxbullet
6d1d12953c deploy: add Docker and Caddy production stack 2026-09-07 11:50:22 +02:00
syntaxbullet
50d7838de3 fix: support Discord sign-in behind a trusted TLS proxy 2026-09-07 11:50:22 +02:00
27 changed files with 629 additions and 13 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
.env.*
node_modules
dist
data
coverage
*.sqlite*
*.db*
*.log
.DS_Store

View File

@@ -1,5 +1,8 @@
# Loaded by bun start and bun run db:migrate:production. # Loaded by bun start and bun run db:migrate:production.
# Set your public origin, e.g. https://habits.example.com # Set your public origin, e.g. https://habits.example.com
APP_ORIGIN= APP_ORIGIN=
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
TRUST_PROXY=false
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID, # Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET. # DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.

4
Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
mina.teppelinlabs.com {
encode zstd gzip
reverse_proxy app:3000
}

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
ARG BUN_IMAGE=oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4
FROM ${BUN_IMAGE} AS build
WORKDIR /app
COPY package.json bun.lock bunfig.toml ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM ${BUN_IMAGE} AS dependencies
WORKDIR /app
COPY package.json bun.lock bunfig.toml ./
RUN bun install --frozen-lockfile --production
FROM ${BUN_IMAGE} AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/src ./src
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/drizzle ./drizzle
COPY package.json bunfig.toml ./
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATABASE_PATH=/data/minabot.sqlite
USER bun
EXPOSE 3000
CMD ["sh", "-c", "bun scripts/migrate.ts && exec bun scripts/start.ts"]

View File

@@ -27,4 +27,6 @@ for (const output of result.outputs) {
} }
await mkdir(path.join(outdir, "fonts"), { recursive: true }); await mkdir(path.join(outdir, "fonts"), { recursive: true });
await copyFile("src/assets/fonts/InstrumentSerif-Regular.ttf", path.join(outdir, "fonts/InstrumentSerif-Regular.ttf")); for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
}

61
compose.yaml Normal file
View File

@@ -0,0 +1,61 @@
name: minabot
services:
app:
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
env_file:
- path: .env.production
format: raw
volumes:
- /srv/minabot/data:/data
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
healthcheck:
test: [CMD, bun, -e, "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
logging:
driver: local
options:
max-size: 10m
max-file: "3"
caddy:
image: ${CADDY_IMAGE:?Set CADDY_IMAGE to a pinned image digest}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
app:
condition: service_healthy
logging:
driver: local
options:
max-size: 10m
max-file: "3"
monitor:
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
command: [bun, scripts/monitor.ts]
environment:
MONITOR_URL: https://mina.teppelinlabs.com/api/health
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
read_only: true
logging:
driver: local
options:
max-size: 10m
max-file: "3"
volumes:
caddy_data:
caddy_config:

2
deploy.env.example Normal file
View File

@@ -0,0 +1,2 @@
APP_IMAGE=minabot:RELEASE
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d

109
docs/DEPLOYMENT-PLAN.md Normal file
View File

@@ -0,0 +1,109 @@
# Minabot deployment plan
Target: `https://mina.teppelinlabs.com` on `syntaxbullet@46.62.156.93`.
Prepared September 7, 2026. Executed the same day; see
[production operations](PRODUCTION.md) for the deployed configuration and results.
## Verified baseline
- Ubuntu 26.04.1, x86_64, 3.7 GiB RAM, approximately 34 GiB free disk.
- DNS A resolves to this server; no AAAA answer was returned.
- Docker and Caddy are not installed. Only SSH is publicly listening.
- SSH keys work, password authentication is disabled, UFW and Fail2ban are active.
- Passwordless sudo is installed in `/etc/sudoers.d/90-syntaxbullet` and verified
with `sudo -k` followed by `sudo -n id` in a fresh SSH connection.
- The checkout has uncommitted live Discord image changes and migrations 0009/0010.
Include and validate the intended complete release before tagging it.
## Architecture
Internet → Caddy container on TCP 80/443 → app container on port 3000 → SQLite.
Use Docker Compose with one app process and one Caddy container on a shared bridge
network. The app needs outbound access to Discord. Publish only Caddy's ports;
do not publish app port 3000 or mount the Docker socket into either container.
Keep reminders, backups, and live Discord updates in the existing app process.
Multiple app replicas are unsuitable for the current live-image worker.
Use `/opt/minabot` for deployment configuration and tagged releases,
`/srv/minabot/data` mounted as `/data` for SQLite and snapshots, and persistent
Docker volumes for Caddy `/data` and `/config`. Match data ownership to the app's
non-root container UID. Use `restart: unless-stopped`, bounded container logs,
a health check, and a sufficient startup grace period for migrations/backups.
An unhealthy health check alone does not restart a container; monitor it separately.
## Implementation sequence
1. Add `HOST` configuration to `src/index.ts`, retaining `127.0.0.1` locally and
using `HOST=0.0.0.0` inside Docker. The current hard-coded loopback binding
prevents Caddy from reaching the app across containers.
2. Add a multi-stage Dockerfile, `.dockerignore`, Compose file, Caddyfile, and
deployment environment example. Pin tested Bun/Caddy versions and image digests.
Build Linux amd64 dependencies in the image using the frozen lockfile; never
copy macOS `node_modules`. Exclude real dotenv files, databases, backups, and Git.
3. Preserve the existing startup contract: migrations must succeed before
`scripts/start.ts` loads `dist/index.js`. Include migrations and required script
imports, production dependencies, built assets, and the bundled Instrument Serif
font. Use a glibc-based Bun runtime with a sans-serif font installed, and verify
the native `@napi-rs/canvas` renderer inside that exact image.
4. Install Docker Engine and Compose from Docker's official Ubuntu repository.
Ubuntu 26.04 is supported. Keep Docker's firewall management enabled; published
ports can bypass UFW. Publish only 80/443 and verify exposure externally. Align
the existing Fail2ban nftables action with the chosen Docker firewall backend
(prefer an iptables-compatible SSH action for the standard Docker backend),
then recheck SSH protection. Open TCP 80/443 in UFW and any Hetzner firewall.
5. Put runtime secrets in `/opt/minabot/.env.production` with mode 0600 and inject
through Compose `env_file`. Set `NODE_ENV=production`, `HOST=0.0.0.0`, `PORT=3000`,
`APP_ORIGIN=https://mina.teppelinlabs.com`, and `DATABASE_PATH=/data/minabot.sqlite`.
Supply `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, a stable random
`AUTH_COOKIE_SECRET`, and the bot/channel settings. Do not bake secrets into
images or build arguments. Register this Discord OAuth redirect exactly:
`https://mina.teppelinlabs.com/api/auth/discord/callback`.
6. Default to a fresh production database. If existing local data should be moved,
use a verified SQLite snapshot, not a live database-file copy. Avoid running
two workers against copied live Discord message state. Resolve the data choice
before first production startup.
7. Build and tag the release, start it on the private Docker network, verify its
health, then start Caddy. Caddy obtains and renews TLS certificates and redirects
HTTP to HTTPS. Persist its certificate storage across container replacement.
Proposed Caddyfile:
```caddyfile
mina.teppelinlabs.com {
encode zstd gzip
reverse_proxy app:3000
}
```
## Verification and operations
- Before release: typecheck, tests, build, and disposable production API smoke test.
Validate Compose and Caddy configuration; run migrations, health, static assets,
native PNG rendering, and a restart/persistence test inside the Linux image.
- After launch: verify public HTTPS, HTTP redirect, `/api/health`, Discord sign-in,
secure cookies, habit persistence, and port 3000 being unreachable externally.
Exercise bot workers with stubbed transports; a live Discord post or DM needs
an explicitly selected test destination and authorization to send it.
- Keep daily verified snapshots and seven retained copies using the existing app
backup system. Configure an off-server destination separately; none is provisioned
yet. Test restore to a new database with the matching image before relying on it.
- Configure an independent HTTPS monitor and alert destination. Existing monitor
scripts provide checks but do not provision an external monitoring service.
- Updates: build/test the next immutable image, snapshot the database, stop the
old app, and start the new release. Accept brief downtime; do not overlap workers.
Keep the previous image. If schema compatibility prevents image-only rollback,
stop the app and restore its matching pre-migration snapshot to a new file.
The restore tool revokes sessions and disables reminders; audit restored live
Discord state before starting workers. Never use `docker compose down -v`.
Remaining launch inputs: fresh database versus migration, Discord redirect setup
and production credentials, off-server backup destination, and monitoring alerts.
## References
- [Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/)
- [Docker firewall behavior](https://docs.docker.com/engine/network/packet-filtering-firewalls/)
- [Bun Docker guide](https://bun.sh/guides/ecosystem/docker)
- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https)
- [Existing backup and recovery workflow](OPERATIONS.md)

View File

@@ -1,5 +1,8 @@
# Deployment operations # Deployment operations
See [production deployment](PRODUCTION.md) for the Docker/Caddy server, release,
data paths, verification results, and current backup/monitoring limitations.
## Backups and recovery ## Backups and recovery
Production creates a verified SQLite snapshot before migrations and automatically Production creates a verified SQLite snapshot before migrations and automatically

90
docs/PRODUCTION.md Normal file
View File

@@ -0,0 +1,90 @@
# Minabot production
Live: https://mina.teppelinlabs.com
SSH: `ssh syntaxbullet@46.62.156.93` (key authentication; passwordless sudo).
Deployment root: `/opt/minabot`. Compose manages `app`, `caddy`, and `monitor`.
Docker starts at boot; containers use `restart: unless-stopped`.
## Release and configuration
Current image: `minabot:20260907-c7aa5a1-2`, built on the Linux amd64 server from
the local checkout based on `c7aa5a1` plus the Docker and trusted-proxy changes.
Its immutable image ID is recorded by `docker image inspect`; source is retained
under `/opt/minabot/releases/20260907-c7aa5a1-2`, with `source-v2.tar.gz` and build
logs in `/opt/minabot`. Bun 1.3.14 and Caddy images are pinned by digest.
`/opt/minabot/.env` selects release images. `/opt/minabot/.env.production` contains
runtime secrets (0600). Discord client and bot credentials were copied from the
effective development environment; production has a separate generated cookie
signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelinlabs.com`,
`TRUST_PROXY=true`, `HOST=0.0.0.0`, and `DATABASE_PATH=/data/minabot.sqlite`.
`TRUST_PROXY` only accepts HTTPS from `X-Forwarded-Proto`, retains the actual
request Host, and must only be enabled behind the private Caddy proxy. Port 3000
is not published. Caddy exposes TCP 80/443 and persists certificate state in
`minabot_caddy_data` and `minabot_caddy_config`. SSH remains on 22. Fail2ban uses
the iptables-compatible SSH action alongside Docker/UFW.
The registered Discord callback is
`https://mina.teppelinlabs.com/api/auth/discord/callback`; localhost remains registered.
## Data and backups
`/srv/minabot/data` is bind-mounted at `/data`, owned by UID 1000. A verified
`VACUUM INTO` snapshot copied the development database, including its one account,
four habits, history, reminders, and live Discord state. The transfer hash and
counts are recorded in `/opt/minabot/migration-manifest.json`. Local development
was stopped before the snapshot so only production manages the existing live cards.
Development now blocks channel writes at both snapshot-send and live-card entry
points. It also skips copied live-card recovery and change scheduling. Preview and
download remain available. Stubbed tests inject their own transport/configuration;
real writes through the environment loader are enabled only in production.
Daily verified snapshots and pre-migration snapshots are stored under
`/srv/minabot/data/backups`, retaining seven snapshots. The restore rehearsal
passed using a separate file; production data was not replaced by the rehearsal.
Restore revokes sessions and disables reminder opt-ins; see [OPERATIONS.md](OPERATIONS.md).
Off-server backup replication and delivered uptime alerts are **not configured**:
the operator has no destinations set up yet. The monitor checks public HTTPS every
minute in a separate container on this server and writes failure/recovery events
to its logs. It cannot report a complete server outage to an external destination.
## Common commands
Run after SSH login:
```sh
cd /opt/minabot
sudo docker compose ps
sudo docker compose logs --tail=100 app caddy monitor
sudo docker compose exec -T app bun scripts/backup.ts
sudo docker compose exec -T monitor bun scripts/monitor.ts --once
```
When sending several shell commands over SSH stdin, redirect noninteractive
`docker compose exec` calls from `/dev/null` so they do not consume later commands.
For updates, build a new release tag, run the container smoke test, take a backup,
update `APP_IMAGE` in `.env`, stop the old app, then run
`sudo docker compose up -d --wait --wait-timeout 120`. Never run overlapping app
workers or `docker compose down -v`. Keep the last known-good image and a matching
pre-migration snapshot. The initial `-1` image predates the proxy fix and is not
a suitable rollback target for working production sign-in.
## Verified September 7, 2026
- Typecheck, build, 195 tests, and production HTTP smoke tests passed.
- The Linux runtime passed migrations, native 1920×1080 PNG rendering, and
persistence across a process restart using a disposable database.
- Public HTTPS health returns `{"status":"ok"}`; HTTP redirects to HTTPS.
- Browser Discord sign-in completed and displayed the migrated account and habits.
- Read-only authenticated production API/PNG checks passed; unauthenticated access
and cross-origin writes were rejected. Temporary verification sessions were removed.
- Snapshot integrity, restore rehearsal, and transferred database hash passed.
- Application port 3000 is unpublished and an external HTTP probe timed out.
- Docker and Fail2ban are active; runtime secrets and the SQLite file are mode 0600.
Live Discord test posts/DMs were not sent. Existing configured workers continue
their normal production behavior.

View File

@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,94 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,14 @@
# Bundled share-card fonts
Instrument Serif Regular/Italic: downloaded from the
[Google Fonts source](https://github.com/google/fonts/tree/main/ofl/instrumentserif).
License: `InstrumentSerif-OFL.txt` (also retained in the original `OFL.txt`).
Noto Sans Regular (400), Medium (500), and SemiBold (600): downloaded from the
[Noto source](https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSans).
License: `NotoSans-LICENSE.txt`.
Downloaded September 7, 2026. The files are versioned in this repository and copied
into the production image. Browser and native canvas renderers use the alias
`Mina Sans`; no system sans-serif font is selected for card text. The native font
loader fails startup if a required font cannot be registered.

View File

@@ -55,6 +55,32 @@ beforeEach(() => {
afterEach(() => sqlite.close()); afterEach(() => sqlite.close());
describe("Discord sign-in and sessions", () => { describe("Discord sign-in and sessions", () => {
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
const publicOrigin = "https://habits.example";
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
const location = new URL(response.headers.get("Location")!);
expect(location.origin).toBe("https://discord.com");
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
expect(response.headers.get("Set-Cookie")).toContain("Secure");
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
});
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
});
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
});
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
expect(response.headers.get("Set-Cookie")).toBeNull();
}
});
test("me requires a valid session and health stays public", async () => { test("me requires a valid session and health stays public", async () => {
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) { for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } }); const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });

View File

@@ -3,6 +3,7 @@ export type AuthConfig = {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
cookieSecret: string; cookieSecret: string;
trustProxy?: boolean;
}; };
export function readAuthConfig(env = process.env): AuthConfig { export function readAuthConfig(env = process.env): AuthConfig {
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
clientId: env.DISCORD_CLIENT_ID ?? "", clientId: env.DISCORD_CLIENT_ID ?? "",
clientSecret: env.DISCORD_CLIENT_SECRET ?? "", clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
cookieSecret: env.AUTH_COOKIE_SECRET ?? "", cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
trustProxy: env.TRUST_PROXY === "true",
}; };
} }

View File

@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
routes.get("/discord", async c => { routes.get("/discord", async c => {
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
// Use one configured origin for redirects and cookie ownership. // Use one configured origin for redirects and cookie ownership.
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`); const requestUrl = new URL(c.req.url);
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
// Keep the actual Host; never trust a client-supplied forwarded hostname.
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
const state = randomToken(); const state = randomToken();
await setSignedCookie(c, stateName, JSON.stringify({ await setSignedCookie(c, stateName, JSON.stringify({
state, state,

View File

@@ -25,7 +25,7 @@ const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, rea
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing()); if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
const server = Bun.serve({ const server = Bun.serve({
hostname: "127.0.0.1", hostname: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
routes: { routes: {
"/api": req => app.fetch(req), "/api": req => app.fetch(req),

View File

@@ -35,7 +35,7 @@ async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */ /** Compact summary: fixed typography stays readable when Discord scales the attachment. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> { export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('64px "Instrument Serif"'); await Promise.all(['400 64px "Instrument Serif"', '400 32px "Mina Sans"', '500 32px "Mina Sans"', '600 32px "Mina Sans"'].map(font => document.fonts.load(font)));
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null; const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84; const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
@@ -52,7 +52,7 @@ export async function renderProgressCard(data: ShareData, user: PublicUser, priv
ctx.scale(1.25, 1.25); ctx.scale(1.25, 1.25);
const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme]; const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme];
const green = "#285d49"; const green = "#285d49";
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const sans = '"Mina Sans"';
const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => { const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`; ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color; ctx.textAlign = align; ctx.fillStyle = color; ctx.textAlign = align;

View File

@@ -1,10 +1,12 @@
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] }; export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[]; writesEnabled?: boolean };
/** Read only from the server entrypoint; never include credentials in public config. */ /** Read only from the server entrypoint; never include credentials in public config. */
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig { export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean); const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs"); if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
return { return {
// Real channel writes are production-only. Test fixtures may inject a stub config.
writesEnabled: env.NODE_ENV === "production",
allowedUserIds, allowedUserIds,
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""), token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(), channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),

View File

@@ -0,0 +1,36 @@
import { expect, test } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { createApi } from '../api';
import { liveDiscordCards } from '../db/schema';
import { readDiscordSharingConfig } from './config';
test('real Discord channel writes are enabled only in production', () => {
for (const NODE_ENV of ['development', 'test', undefined]) {
expect(readDiscordSharingConfig({ NODE_ENV, DISCORD_BOT_TOKEN: 'token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }).writesEnabled).toBe(false);
}
expect(readDiscordSharingConfig({ NODE_ENV: 'production' }).writesEnabled).toBe(true);
});
test('development preserves copied live state, allows PNG previews, and blocks all channel sends', async () => {
const f = fixture();
f.db.insert(liveDiscordCards).values({ userId: 'alice', channelId: '223456789012345678', messageId: '423456789012345678', nonce: 'copied-nonce', theme: 'light', status: 'active', dirty: true, updatedAt: 1 }).run();
const before = f.db.select().from(liveDiscordCards).all();
const requests: string[] = [];
const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-signing-secret-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async url => {
requests.push(url); throw new Error('Development contacted Discord');
}, readDiscordSharingConfig({ NODE_ENV: 'development', DISCORD_BOT_TOKEN: 'real-looking-token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }));
const headers = { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' };
try {
for (const path of ['/sharing/live', '/sharing/discord/send']) {
const response = await app.request(`${f.origin}/api${path}`, { method: 'POST', headers, body: JSON.stringify({ theme: 'light' }) });
expect(response.status).toBe(403);
}
await app.flushLiveSharing('alice');
const preview = await app.request(`${f.origin}/api/sharing/live/preview`, { headers });
expect(preview.status).toBe(200);
expect(preview.headers.get('Content-Type')).toBe('image/png');
await Bun.sleep(2100);
expect(requests).toEqual([]);
expect(f.db.select().from(liveDiscordCards).all()).toEqual(before);
} finally { app.stopLiveSharing(); f.close(); }
});

View File

@@ -10,9 +10,18 @@ import { cardDate } from "../lib/progress-card";
import { calendarTimeline } from "../components/design-system/calendar-model"; import { calendarTimeline } from "../components/design-system/calendar-model";
import { themedHabitShade } from "../lib/habit-palette"; import { themedHabitShade } from "../lib/habit-palette";
const sourceFont = new URL('../assets/fonts/InstrumentSerif-Regular.ttf', import.meta.url); for (const [file, family] of [
const builtFont = new URL('./fonts/InstrumentSerif-Regular.ttf', import.meta.url); ['InstrumentSerif-Regular.ttf', 'Instrument Serif'],
GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, 'Instrument Serif'); ['NotoSans-Regular.ttf', 'Mina Sans'],
['NotoSans-Medium.ttf', 'Mina Sans'],
['NotoSans-SemiBold.ttf', 'Mina Sans'],
]) {
const sourceFont = new URL(`../assets/fonts/${file}`, import.meta.url);
const builtFont = new URL(`./fonts/${file}`, import.meta.url);
if (!GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, family)) {
throw new Error(`Could not load bundled font: ${file}`);
}
}
export function liveCardData(db: AppDatabase, userId: string, now: number) { export function liveCardData(db: AppDatabase, userId: string, now: number) {
const user = db.select().from(users).where(eq(users.id, userId)).get(); const user = db.select().from(users).where(eq(users.id, userId)).get();
@@ -54,7 +63,7 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill(); ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
}; };
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => { const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : 'sans-serif'}`; ctx.fillStyle = color; ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : '"Mina Sans"'}`; ctx.fillStyle = color;
const chars = Array.from(value); let fitted = value; const chars = Array.from(value); let fitted = value;
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; } while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; }
ctx.fillText(fitted, x, y); ctx.fillText(fitted, x, y);

View File

@@ -33,12 +33,13 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null }; return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
} }
function schedule(id: string, delay: number) { function schedule(id: string, delay: number) {
if (stopped) return; if (stopped || config.writesEnabled === false) return;
clearTimeout(timers.get(id)); clearTimeout(timers.get(id));
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay)); const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
timer.unref(); timers.set(id, timer); timer.unref(); timers.set(id, timer);
} }
function changed(id: string) { function changed(id: string) {
if (config.writesEnabled === false) return;
const current = row(id); const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return; if (!current || !['active', 'sending'].includes(current.status)) return;
update(id, { dirty: true }); update(id, { dirty: true });
@@ -52,7 +53,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1); schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
} }
async function deliver(id: string) { async function deliver(id: string) {
if (stopped) return; if (stopped || config.writesEnabled === false) return;
const current = row(id); const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return; if (!current || !['active', 'sending'].includes(current.status)) return;
const owner = db.select().from(users).where(eq(users.id, id)).get(); const owner = db.select().from(users).where(eq(users.id, id)).get();
@@ -64,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
update(id, { dirty: false, retryAt: null }); update(id, { dirty: false, retryAt: null });
const data = liveCardData(db, id, now()); const data = liveCardData(db, id, now());
// Hash the rendered content, excluding timestamps and private task details. // Hash the rendered content, excluding timestamps and private task details.
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 2, content: liveMessageText })).digest('hex'); const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) { if (current.messageId && current.imageHash === hash) {
update(id, { error: null }); rollover(id); return; update(id, { error: null }); rollover(id); return;
} }
@@ -155,6 +156,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
return c.body(new Uint8Array(image)); return c.body(new Uint8Array(image));
}); });
app.post('/', async c => { app.post('/', async c => {
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.'); if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
const user = c.get('user'); const user = c.get('user');
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403); if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
@@ -183,6 +185,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
}); });
// Recover only known message edits. Never risk duplicating an interrupted first post. // Recover only known message edits. Never risk duplicating an interrupted first post.
for (const current of db.select().from(liveDiscordCards).all()) { for (const current of db.select().from(liveDiscordCards).all()) {
if (config.writesEnabled === false) break;
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' }); if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now())); else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
} }

View File

@@ -99,6 +99,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl }); return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
}); });
app.post("/discord/send", async c => { app.post("/discord/send", async c => {
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403);
const userId = c.get("user").id; const userId = c.get("user").id;
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403); if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server."); if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");

View File

@@ -1,3 +1,24 @@
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-Regular.ttf") format("truetype");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-Medium.ttf") format("truetype");
font-style: normal;
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-SemiBold.ttf") format("truetype");
font-style: normal;
font-weight: 600;
font-display: swap;
}
@font-face { @font-face {
font-family: "Instrument Serif"; font-family: "Instrument Serif";
src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype"); src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");