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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@
**/dist
**/build
**/.svelte-kit
**/coverage
.git
**/.env
**/.env.*
!**/.env.example
*.log
npm-debug.log*

.vscode
*.swp
*.swo

.git
.gitignore

**/Dockerfile*
docker-compose*.yml

*.md
!README.md
LICENSE*
.DS_Store
*.tsbuildinfo

**/coverage
**/test-results
**/playwright-report
10 changes: 10 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

3 changes: 3 additions & 0 deletions .github/CODEOWNERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# CODEOWNERS

- @Logan2234
24 changes: 24 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Summary *

<!-- What does this change, and why? A couple of sentences is plenty —
the emoji + type in the title/commits already say what kind of change
this is, no need to repeat it here. -->

## Related *

- [ ] Quackback ticket:
- [ ] Issue link:
- [ ] n/a

## Screenshots

<!-- UI change? Before/after screenshots or a short clip. Delete otherwise. -->

## Checklist

- [ ] Tests added/updated for the behavior this changes (a bug fix includes a regression test that failed before it)
- [ ] New user-facing strings added to **both** `fr` and `en` message catalogs
- [ ] `pnpm build:package` run if `packages/shared` changed
- [ ] A Prisma migration is included if `schema.prisma` changed
- [ ] No `--no-verify` — hooks ran clean
- [ ] Manual testing performed
34 changes: 34 additions & 0 deletions .github/workflows/ghcr-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: GHCR cleanup

on:
schedule:
# Weekly is plenty — untagged versions only pile up one push to main at a
# time (docker-push in ci.yml), never in bulk.
- cron: "12 4 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# Every push to main tags both loomkeep-api and loomkeep-web with
# `latest` and the commit's short SHA (see ci.yml's docker-push job) —
# nothing is ever pushed genuinely untagged by that job itself. What
# accumulates as "untagged" in the package view is Buildx's build
# provenance/SBOM attestation manifests, one extra per pushed image.
# GHCR has no built-in expiry for a personal-account package (that's an
# org-only setting), so without this, they'd sit there forever. This
# action understands the referrer/attestation relationship and only
# removes what nothing else points to — a plain "delete anything
# untagged" approach would be unsafe here.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
package: loomkeep-api,loomkeep-web
delete-untagged: true
delete-orphaned-images: true
registry-url: "https://ghcr.io"
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
90 changes: 90 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Contributing to Loomkeep

Thanks for taking the time to contribute! Loomkeep is maintained solo
([@Logan2234](https://github.com/Logan2234)) as a side project, so response
times can vary — but PRs and issues are genuinely welcome.

## Before you file something

- **Feature idea or a bug in the app itself** (UI, sync, watch tracking...)?
That goes on [feedback.loomkeep.app](https://feedback.loomkeep.app), not
GitHub Issues — the **Feature Requests** and **Bug Reports** boards there
are public and votable.
- **Bug in self-hosting/deployment** (Docker, migrations, reverse proxy)?
[Open a GitHub issue](https://github.com/Logan2234/loomkeep/issues/new/choose)
with the _Self-hosting / deployment bug_ template.
- **Anything about the codebase itself** (a contribution question, CI, docs)?
Same place, _Other_ template.
- **Security vulnerability?** Don't open a public issue — see
[SECURITY.md](SECURITY.md).

## Project layout

pnpm workspace, 100% TypeScript:

| Path | What |
| ----------------- | ---------------------------------------------------------- |
| `apps/api` | NestJS + Prisma + PostgreSQL |
| `apps/web` | SvelteKit PWA |
| `packages/shared` | DTOs/enums shared by both, consumed from its built `dist/` |

[`CLAUDE.md`](CLAUDE.md) is the deep-dive architecture doc (data model,
auth, feature flags, i18n, conventions) — worth a skim before a non-trivial
change, whether you're a human or an AI coding agent.

## Local setup

```sh
pnpm i
docker run -d --name loomkeep-dev-db -e POSTGRES_USER=loomkeep \
-e POSTGRES_PASSWORD=loomkeep -e POSTGRES_DB=loomkeep \
-p 5433:5432 postgres:18-alpine
cp .env.example .env
cp apps/api/.env.example apps/api/.env
pnpm --filter @loomkeep/api exec prisma migrate dev
pnpm generate
pnpm dev # api on :3000, web on :5173
```

Full self-hosting instructions (Docker, add-ons, SSO...) are in the
[README](README.md) — that setup is for _running_ Loomkeep, this one is for
_working on it_.

## Making a change

1. Branch off `main`, `feat/`, `fix/`, `chore/` prefixes are required.
2. Match the existing style rather than introducing a new one: read the
surrounding code before writing yours, and check `apps/web/DESIGN.md` for
anything UI-facing.
3. Keep changes surgical — a bug fix doesn't need a drive-by refactor of
nearby code, and vice versa. If you spot something else worth fixing,
mention it in the PR description rather than folding it in.
4. Every non-trivial feature needs at least one test; a bug fix needs a
regression test that fails before the fix and passes after.
5. `pre-commit` (lint-staged) and `pre-push` (typecheck) hooks run
automatically — don't skip them (`--no-verify`). CI runs the full test
suite, e2e, and a few security/quality scans (CodeQL, Trivy, pa11y) on
every PR.

### Commit messages

Imperative, English, one emoji prefix per the summary line:

| Emoji | Code | For |
| ----- | ------------ | --------------------------- |
| ✨ | `:sparkles:` | A new feature |
| 🐛 | `:bug:` | A bug fix |
| ♻️ | `:recycle:` | A refactor |
| 📝 | `:memo:` | Docs, comments, tests |
| ⚡ | `:zap:` | Performance, build, tooling |
| 🔖 | `:bookmark:` | A version bump |

## Opening a pull request

Target `main`. The PR template asks for the essentials — fill it in, it's
short on purpose. Draft PRs are fine if you want early feedback.

## License

AGPL-3.0. By contributing, you agree your changes are licensed under the
same terms as the rest of the project.
10 changes: 7 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Security Policy

Loomkeep is a self-hosted, single-owner project — there's no hosted
multi-tenant instance to protect, but a vulnerability in the code still
matters for anyone self-hosting it.
Loomkeep ships two ways: self-hosted (your own PostgreSQL, your own data),
and as a hosted instance at [loomkeep.app](https://loomkeep.app) with real
user accounts — registration, auth, and personal data (watch history,
reviews, social graph) that a vulnerability could actually expose. Both
matter: a report against the hosted instance is treated like a real
multi-tenant incident, and a report against the self-hosted path is treated
as something every self-hoster's data depends on.

## Supported versions

Expand Down
12 changes: 6 additions & 6 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.2",
"fastify": "^5.12.1",
"nestjs-pino": "^5.0.0",
"nodemailer": "^9.1.0",
"otpauth": "^9.5.1",
"fastify": "^5.12.3",
"nestjs-pino": "^5.1.0",
"nodemailer": "^10.0.0",
"otpauth": "^9.5.2",
"pino-http": "^11.0.0",
"prisma": "^7.10.0",
"reflect-metadata": "^0.2.2",
Expand All @@ -69,8 +69,8 @@
"@nestjs/cli": "^12.0.0",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@swc/core": "^1.16.1",
"@types/node": "^26.4.0",
"@swc/core": "^1.16.2",
"@types/node": "^26.4.1",
"@types/nodemailer": "^8.0.1",
"@types/supertest": "^7.2.1",
"@types/web-push": "^3.6.4",
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/gamification/gamification-feature.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ErrorCode } from "@loomkeep/shared";
import { type CanActivate, HttpStatus, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AppException } from "../common/app.exception";
import { FeatureFlagsService } from "../feature-flags/feature-flags.service";
import { isGamificationEnabled } from "./gamification.config";

/**
* Gates a gamification-dependent endpoint behind the runtime
* GAMIFICATION_ENABLED flag. Exact copy of `SocialFeatureGuard`'s pattern:
* 404 (not 403) when disabled, so a deployment that turned gamification off
* doesn't even advertise the endpoint exists.
*/
@Injectable()
export class GamificationFeatureGuard implements CanActivate {
constructor(
private readonly config: ConfigService,
private readonly flags: FeatureFlagsService,
) {}

canActivate(): boolean {
if (!isGamificationEnabled(this.config, this.flags)) {
throw new AppException(

Check warning on line 23 in apps/api/src/gamification/gamification-feature.guard.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/gamification-feature.guard.ts#L23

Added line #L23 was not covered by tests
HttpStatus.NOT_FOUND,
ErrorCode.GamificationFeatureDisabled,
);
}

return true;

Check warning on line 29 in apps/api/src/gamification/gamification-feature.guard.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/gamification-feature.guard.ts#L29

Added line #L29 was not covered by tests
}
}
9 changes: 5 additions & 4 deletions apps/api/src/gamification/gamification.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { GamificationController } from "./gamification.controller";
import { XpService } from "./xp.service";

// G1: XP ledger + level curve. G2 adds the achievement engine (registry in
// achievements/registry.ts) and its /achievements endpoints. Still no
// leaderboard endpoint yet — that is [G7]. GET /gamification/me serves the
// viewer their own XP without going through the social profile, so levels
// keep working on a SOCIAL_ENABLED=false instance ("solo first").
// achievements/registry.ts) and its /achievements endpoints. GET
// /gamification/me serves the viewer their own XP without going through the
// social profile, so levels keep working on a SOCIAL_ENABLED=false instance
// ("solo first"). The [G7] leaderboard is social-gated by nature, so it lives
// in SocialModule instead — see the comment there.
@Module({
imports: [JobsModule],
controllers: [AchievementsController, GamificationController],
Expand Down
58 changes: 29 additions & 29 deletions apps/api/src/gamification/level.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,26 @@
expect(xpForLevel(1)).toBe(0);
});

it("matches the calibrated thresholds from the [G1] plan", () => {
expect(xpForLevel(2)).toBe(52);
expect(xpForLevel(5)).toBe(280);
expect(xpForLevel(10)).toBe(900);
expect(xpForLevel(20)).toBe(3040);
expect(xpForLevel(40)).toBe(10_920);
expect(xpForLevel(60)).toBe(23_600);
expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(41_080);
expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(42_080);
expect(xpForLevel(100)).toBe(61_080);
expect(xpForLevel(430)).toBe(391_080);
it("matches the calibrated thresholds (raised once DOMAIN_STARTED alone was clearing level 2)", () => {
expect(xpForLevel(2)).toBe(112);
expect(xpForLevel(5)).toBe(520);
expect(xpForLevel(10)).toBe(1440);
expect(xpForLevel(20)).toBe(4180);
expect(xpForLevel(40)).toBe(13_260);
expect(xpForLevel(60)).toBe(27_140);
expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(40_700);
expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(41_700);
expect(xpForLevel(100)).toBe(65_700);
expect(xpForLevel(430)).toBe(395_700);

Check warning on line 23 in apps/api/src/gamification/level.util.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/level.util.spec.ts#L13-L23

Added lines #L13 - L23 were not covered by tests
});

it("is continuous across the cap boundary (798081)", () => {
const cost79to80 = xpForLevel(80) - xpForLevel(79);
const cost80to81 = xpForLevel(81) - xpForLevel(80);
const cost81to82 = xpForLevel(82) - xpForLevel(81);
expect(cost79to80).toBe(988); // 40 + 12*79, still uncapped
expect(cost80to81).toBe(1000); // 40 + 12*80 == cap, exactly
expect(cost81to82).toBe(1000); // flat cap from here on
it("is continuous across the cap boundary (747576)", () => {
const cost74to75 = xpForLevel(75) - xpForLevel(74);
const cost75to76 = xpForLevel(76) - xpForLevel(75);
const cost76to77 = xpForLevel(77) - xpForLevel(76);
expect(cost74to75).toBe(988); // 100 + 12*74, still uncapped
expect(cost75to76).toBe(1000); // 100 + 12*75 == cap, exactly
expect(cost76to77).toBe(1000); // flat cap from here on

Check warning on line 32 in apps/api/src/gamification/level.util.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/level.util.spec.ts#L26-L32

Added lines #L26 - L32 were not covered by tests
});

it("is strictly monotonic increasing", () => {
Expand All @@ -45,13 +45,13 @@
});

it("matches the calibrated thresholds exactly at the boundary", () => {
expect(levelForXp(52)).toBe(2);
expect(levelForXp(51)).toBe(1);
expect(levelForXp(900)).toBe(10);
expect(levelForXp(41_080)).toBe(LEVEL_CAP_LEVEL);
expect(levelForXp(42_080)).toBe(LEVEL_CAP_LEVEL + 1);
expect(levelForXp(43_080)).toBe(LEVEL_CAP_LEVEL + 2);
expect(levelForXp(391_080)).toBe(430);
expect(levelForXp(112)).toBe(2);
expect(levelForXp(111)).toBe(1);
expect(levelForXp(1440)).toBe(10);
expect(levelForXp(40_700)).toBe(LEVEL_CAP_LEVEL);
expect(levelForXp(41_700)).toBe(LEVEL_CAP_LEVEL + 1);
expect(levelForXp(42_700)).toBe(LEVEL_CAP_LEVEL + 2);
expect(levelForXp(395_700)).toBe(430);

Check warning on line 54 in apps/api/src/gamification/level.util.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/level.util.spec.ts#L48-L54

Added lines #L48 - L54 were not covered by tests
});

it("handles a very large XP total past the cap", () => {
Expand All @@ -76,21 +76,21 @@
expect(levelProgress(0)).toEqual({
level: 1,
xpInLevel: 0,
xpToNext: 52,
xpToNext: 112,
});
});

it("reports partial progress mid-level", () => {
// 30 XP into level 1 (which costs 52 to clear).
// 30 XP into level 1 (which costs 112 to clear).
expect(levelProgress(30)).toEqual({
level: 1,
xpInLevel: 30,
xpToNext: 22,
xpToNext: 82,
});
});

it("is consistent across the cap boundary", () => {
const progress = levelProgress(41_080 + 500);
const progress = levelProgress(40_700 + 500);

Check warning on line 93 in apps/api/src/gamification/level.util.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/level.util.spec.ts#L93

Added line #L93 was not covered by tests
expect(progress.level).toBe(LEVEL_CAP_LEVEL);
expect(progress.xpInLevel).toBe(500);
expect(progress.xpToNext).toBe(500);
Expand Down
Loading
Loading