19 KiB
All Future Features Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement all items in PLAN.md under "Future Features (Tracked)" for the porthole app.
Architecture: Add features incrementally behind small, testable boundaries: shared-secret admin auth for writes, a normalized derived-variant model for media, a transcoding pipeline (MP4 first), tagging/albums and metadata overrides with audit logging, dedupe + moments, GPS extraction + map UI (no reverse geocoding), endpoint selection for presigned URLs, and CI-based multi-arch builds.
Tech Stack: Bun workspaces, Next.js API route handlers (apps/web/app/api/**/route.ts), Node worker + BullMQ (apps/worker/src/jobs.ts), Postgres migrations (packages/db/migrations/*.sql), MinIO (S3) clients (packages/minio/src/index.ts), Helm (helm/porthole/*).
Preconditions / Ground Rules
- Do not mutate or delete anything under
originals/. - Prefer additive schema changes first; deprecate old columns after compatibility is maintained.
- Use Bun’s test runner (
bun test) for new TypeScript tests. - Keep Pi constraints: CPU-heavy work stays in worker; keep transcoding concurrency low.
Phase 0: Test Harness + Repo Hygiene
Task 0.1: Add Bun test runner scripts
Files:
- Modify:
package.json - Create:
apps/web/src/__tests__/smoke.test.ts
Step 1: Write failing test
Create apps/web/src/__tests__/smoke.test.ts:
import { test, expect } from "bun:test";
test("bun test runs", () => {
expect(1 + 1).toBe(2);
});
Step 2: Run test to verify it fails
Run: bun test
Expected: FAIL (no tests configured / command missing)
Step 3: Write minimal implementation
Add script to package.json:
{
"scripts": {
"test": "bun test"
}
}
Step 4: Run test to verify it passes
Run: bun test
Expected: PASS
Step 5: Commit
git add package.json apps/web/src/__tests__/smoke.test.ts
git commit -m "test: add bun test runner"
Phase 1: Shared-Secret Admin Auth (Write Protection)
Task 1.1: Add ADMIN_TOKEN env + helpers
Files:
- Modify:
packages/config/src/index.ts - Create:
packages/config/src/adminAuth.ts - Test:
packages/config/src/adminAuth.test.ts
Step 1: Write failing test
Create packages/config/src/adminAuth.test.ts:
import { test, expect } from "bun:test";
import { isAdminRequest } from "./adminAuth";
test("isAdminRequest returns false when ADMIN_TOKEN unset", () => {
expect(isAdminRequest({ adminToken: undefined }, { headerToken: "x" })).toBe(
false,
);
});
test("isAdminRequest returns true when header token matches", () => {
expect(
isAdminRequest({ adminToken: "secret" }, { headerToken: "secret" }),
).toBe(true);
});
Step 2: Run test to verify it fails
Run: bun test packages/config/src/adminAuth.test.ts
Expected: FAIL (module missing)
Step 3: Write minimal implementation
Create packages/config/src/adminAuth.ts:
export function isAdminRequest(
env: { adminToken: string | undefined },
input: { headerToken: string | null | undefined },
) {
if (!env.adminToken) return false;
return input.headerToken === env.adminToken;
}
Extend packages/config/src/index.ts to parse ADMIN_TOKEN (optional) and export getAdminToken().
Step 4: Run test to verify it passes
Run: bun test packages/config/src/adminAuth.test.ts
Expected: PASS
Step 5: Commit
git add packages/config/src/index.ts packages/config/src/adminAuth.ts packages/config/src/adminAuth.test.ts
git commit -m "feat: add admin token config and auth helper"
Task 1.2: Enforce admin on mutation API routes
Files:
- Modify:
apps/web/app/api/imports/route.ts - Modify:
apps/web/app/api/imports/[id]/upload/route.ts - Modify:
apps/web/app/api/imports/[id]/scan-minio/route.ts - Test:
apps/web/src/__tests__/admin-gates-imports.test.ts
Step 1: Write failing test
Create apps/web/src/__tests__/admin-gates-imports.test.ts:
import { test, expect } from "bun:test";
// This test intentionally asserts the handler behavior by calling the route function.
// It will require exporting a small pure helper from each route in the implementation.
test("imports POST rejects when missing admin token", async () => {
const { handleCreateImport } = await import("../../app/api/imports/handlers");
const res = await handleCreateImport({ adminOk: false, body: {} });
expect(res.status).toBe(401);
});
Step 2: Run test to verify it fails
Run: bun test apps/web/src/__tests__/admin-gates-imports.test.ts
Expected: FAIL (handlers module missing)
Step 3: Write minimal implementation
- Create
apps/web/app/api/imports/handlers.tsexporting pure functions that return{ status, body }for tests. - Update
apps/web/app/api/imports/route.tsto:- read
X-Porthole-Admin-Token - compute adminOk via
@tline/confighelper - reject with 401
{ error: "admin_required" }when not admin
- read
- Repeat pattern for upload + scan routes.
Step 4: Run test to verify it passes
Run: bun test apps/web/src/__tests__/admin-gates-imports.test.ts
Expected: PASS
Step 5: Commit
git add apps/web/app/api/imports/route.ts apps/web/app/api/imports/handlers.ts \
apps/web/app/api/imports/[id]/upload/route.ts apps/web/app/api/imports/[id]/scan-minio/route.ts \
apps/web/src/__tests__/admin-gates-imports.test.ts
git commit -m "feat: require admin token for ingestion endpoints"
Phase 2: Derived Variants Model (Thumbs/Posters/Video)
Task 2.1: Add derived variants table + minimal writer/reader
Files:
- Create:
packages/db/migrations/0003_asset_variants.sql - Modify:
apps/web/app/api/assets/[id]/url/route.ts - Modify:
apps/worker/src/jobs.ts - Test:
apps/web/src/__tests__/variant-url-404.test.ts
Schema (migration):
CREATE TYPE IF NOT EXISTS asset_variant_kind AS ENUM (
'thumb',
'poster',
'video_mp4'
);
CREATE TABLE IF NOT EXISTS asset_variants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
kind asset_variant_kind NOT NULL,
size int NOT NULL,
key text NOT NULL,
mime_type text NOT NULL,
width int,
height int,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(asset_id, kind, size)
);
CREATE INDEX IF NOT EXISTS asset_variants_asset_id_idx ON asset_variants(asset_id);
Step 1: Write failing test
Create apps/web/src/__tests__/variant-url-404.test.ts:
import { test, expect } from "bun:test";
test("/api/assets/:id/url returns 404 when requested variant missing", async () => {
const { pickVariantKey } =
await import("../../app/api/assets/[id]/url/variant");
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
expect(key).toBeNull();
});
Step 2: Run test to verify it fails
Run: bun test apps/web/src/__tests__/variant-url-404.test.ts
Expected: FAIL (module missing)
Step 3: Write minimal implementation
- Create
apps/web/app/api/assets/[id]/url/variant.ts:
export function pickVariantKey(
input: { variants: Array<{ kind: string; size: number; key: string }> },
req: { kind: string; size: number },
) {
const v = input.variants.find(
(x) => x.kind === req.kind && x.size === req.size,
);
return v?.key ?? null;
}
-
Update
apps/web/app/api/assets/[id]/url/route.tsto support query:kind=original|thumb|poster|video_mp4size=<int>(required for non-original)- Keep backward-compatible
variant=thumb_small|thumb_med|poster|originalfor now.
-
Update
apps/worker/src/jobs.tsto insert rows intoasset_variantswhen it uploads thumbs/posters.
Step 4: Run test to verify it passes
Run: bun test apps/web/src/__tests__/variant-url-404.test.ts
Expected: PASS
Step 5: Commit
git add packages/db/migrations/0003_asset_variants.sql \
apps/web/app/api/assets/[id]/url/route.ts apps/web/app/api/assets/[id]/url/variant.ts \
apps/web/src/__tests__/variant-url-404.test.ts apps/worker/src/jobs.ts
git commit -m "feat: add asset variants table and URL selection"
Task 2.2: Multiple thumb + poster sizes
Files:
- Modify:
apps/worker/src/jobs.ts - Modify:
apps/web/app/api/assets/[id]/url/route.ts - Test:
apps/worker/src/__tests__/variants-sizes.test.ts
Step 1: Write failing test
Create apps/worker/src/__tests__/variants-sizes.test.ts:
import { test, expect } from "bun:test";
import { computeImageVariantPlan } from "../variants";
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
expect(computeImageVariantPlan()).toEqual([
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
]);
});
Step 2: Run test to verify it fails
Run: bun test apps/worker/src/__tests__/variants-sizes.test.ts
Expected: FAIL (module missing)
Step 3: Write minimal implementation
- Create
apps/worker/src/variants.tswith exportedcomputeImageVariantPlan()andcomputeVideoPosterPlan(). - Refactor
apps/worker/src/jobs.tsto use these plans and generate additional poster size(s) (e.g. 256 + 768). - Insert each uploaded object into
asset_variantswith (kind,size,key,mime_type,width,height).
Step 4: Run test to verify it passes
Run: bun test apps/worker/src/__tests__/variants-sizes.test.ts
Expected: PASS
Step 5: Commit
git add apps/worker/src/jobs.ts apps/worker/src/variants.ts apps/worker/src/__tests__/variants-sizes.test.ts
git commit -m "feat: generate multiple thumbs and posters"
Phase 3: Video Transcoding + Prefer-Derived Playback
Task 3.1: Add MP4 transcode worker job
Files:
- Modify:
packages/queue/src/index.ts - Modify:
apps/worker/src/jobs.ts - Test:
apps/worker/src/__tests__/transcode-plan.test.ts
Step 1: Write failing test
Create apps/worker/src/__tests__/transcode-plan.test.ts:
import { test, expect } from "bun:test";
import { shouldTranscodeToMp4 } from "../transcode";
test("transcode runs for non-mp4 videos", () => {
expect(shouldTranscodeToMp4({ mimeType: "video/x-matroska" })).toBe(true);
});
test("transcode skips for mp4", () => {
expect(shouldTranscodeToMp4({ mimeType: "video/mp4" })).toBe(false);
});
Step 2: Run test to verify it fails
Run: bun test apps/worker/src/__tests__/transcode-plan.test.ts
Expected: FAIL
Step 3: Write minimal implementation
- Create
apps/worker/src/transcode.tsimplementingshouldTranscodeToMp4. - Add BullMQ job payload + enqueue helper (e.g.
enqueueTranscodeVideoMp4({ assetId })). - In
handleProcessAssetfor video, enqueue mp4 transcode when needed. - Implement ffmpeg transcode to
derived/video/${assetId}/mp4_720p.mp4(H.264 + AAC, fast preset). - Insert into
asset_variantsaskind='video_mp4', size=720, mime_type='video/mp4'. - Keep concurrency low (1) in worker for transcodes.
Step 4: Run test to verify it passes
Run: bun test apps/worker/src/__tests__/transcode-plan.test.ts
Expected: PASS
Step 5: Commit
git add packages/queue/src/index.ts apps/worker/src/jobs.ts apps/worker/src/transcode.ts \
apps/worker/src/__tests__/transcode-plan.test.ts
git commit -m "feat: add mp4 transcode job and variant record"
Task 3.2: Prefer derived in URL endpoint + viewer
Files:
- Modify:
apps/web/app/api/assets/[id]/url/route.ts - Modify:
apps/web/app/components/MediaPanel.tsx - Modify:
apps/web/app/components/Viewer.tsx - Test:
apps/web/src/__tests__/prefer-derived.test.ts
Step 1: Write failing test
Create apps/web/src/__tests__/prefer-derived.test.ts:
import { test, expect } from "bun:test";
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
test("prefer mp4 derived over original", () => {
const picked = pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }],
});
expect(picked).toEqual({ kind: "video_mp4", size: 720 });
});
Step 2: Run test to verify it fails
Run: bun test apps/web/src/__tests__/prefer-derived.test.ts
Expected: FAIL
Step 3: Write minimal implementation
- Create
apps/web/app/lib/playback.tsimplementing deterministic selection. - Update viewer to:
- ask server for
kind=video_mp4&size=720first - fall back to
original
- ask server for
- Keep existing poster behavior.
Step 4: Run test to verify it passes
Run: bun test apps/web/src/__tests__/prefer-derived.test.ts
Expected: PASS
Step 5: Commit
git add apps/web/app/api/assets/[id]/url/route.ts apps/web/app/lib/playback.ts \
apps/web/app/components/MediaPanel.tsx apps/web/app/components/Viewer.tsx \
apps/web/src/__tests__/prefer-derived.test.ts
git commit -m "feat: prefer derived mp4 playback with fallback"
Phase 4: Tags + Albums
Task 4.1: Schema for tags/albums + audit log
Files:
- Create:
packages/db/migrations/0004_tags_albums_audit.sql
Migration (example):
CREATE TABLE IF NOT EXISTS tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS asset_tags (
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY(asset_id, tag_id)
);
CREATE TABLE IF NOT EXISTS albums (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS album_assets (
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
ord int,
PRIMARY KEY(album_id, asset_id)
);
CREATE TABLE IF NOT EXISTS audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor text NOT NULL,
action text NOT NULL,
entity_type text NOT NULL,
entity_id uuid,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
Verification: run migrator (k8s migrate job or local script) and ensure no SQL errors.
Commit: git commit -m "feat: add tags, albums, and audit log tables"
Task 4.2: Admin API for tags and albums
Files:
- Create:
apps/web/app/api/tags/route.ts - Create:
apps/web/app/api/albums/route.ts - Create:
apps/web/app/api/albums/[id]/assets/route.ts - Test:
apps/web/src/__tests__/tags-admin-auth.test.ts
Steps:
- RED: test that POST without admin returns 401
- GREEN: implement CRUD (minimal: list + create; album add/remove assets)
- REFACTOR: write audit_log rows on each mutation
Commit: feat: add admin tags and albums APIs
Task 4.3: UI wiring for tags/albums
Files:
- Modify:
apps/web/app/admin/page.tsx - Modify:
apps/web/app/components/MediaPanel.tsx
Steps:
- Add minimal admin form to set admin token in browser (sessionStorage) and to create/list tags and albums.
- Add UI on asset detail to assign tags, and to add asset to album.
- Keep UX resilient (errors render inline, don’t crash).
Commit: feat: add tags/albums UI
Phase 5: Metadata Overrides + Timeline Uses Overrides
Task 5.1: Override table + API
Files:
- Create:
packages/db/migrations/0005_asset_overrides.sql - Create:
apps/web/app/api/assets/[id]/override-capture-ts/route.ts - Modify:
apps/web/app/api/tree/route.ts - Modify:
apps/web/app/api/assets/route.ts
Migration: table asset_overrides(asset_id PK, capture_ts_utc_override timestamptz, capture_offset_minutes_override int, created_at...).
Steps:
- RED: test route rejects without admin
- GREEN: implement POST to set override and insert audit_log
- GREEN: update aggregation queries to use
COALESCE(overrides.capture_ts_utc_override, assets.capture_ts_utc)
Commit: feat: add capture time overrides and apply in queries
Task 5.2: UI for capture-time override
Files:
- Modify:
apps/web/app/components/Viewer.tsx - Modify:
apps/web/app/components/MediaPanel.tsx
Steps:
- Add form to set ISO timestamp override and submit to API.
- Display current effective timestamp and base timestamp.
Commit: feat: add UI for capture time override
Phase 6: GPS Extraction + Map UI (No Reverse Geocode)
Task 6.1: Add gps fields + extraction
Files:
- Create:
packages/db/migrations/0006_assets_gps.sql - Modify:
apps/worker/src/jobs.ts
Steps:
- Add columns
gps_lat double precision,gps_lon double precision(nullable) - Parse ExifTool GPS fields for images (and where available for videos) and store them.
Commit: feat: extract and store GPS coords
Task 6.2: Map UI
Files:
- Create:
apps/web/app/map/page.tsx - Modify:
apps/web/app/page.tsx
Steps:
- Show a simple map view with markers for assets that have GPS.
- If tiles unavailable, show a clear fallback message.
Commit: feat: add map page for GPS assets
Phase 7: Dedupe by Hash + Moments
Task 7.1: Hash table + compute sha256
Files:
- Create:
packages/db/migrations/0007_asset_hashes.sql - Modify:
apps/worker/src/jobs.ts
Steps:
- During download to temp file, compute sha256 and store it.
- Add unique index on
(bucket, sha256)optionally (careful for partial/unknown).
Commit: feat: compute asset sha256 for dedupe
Task 7.2: Dedupe detection + API
Files:
- Create:
apps/web/app/api/assets/[id]/dupes/route.ts - Modify:
apps/web/app/components/MediaPanel.tsx
Steps:
- Endpoint returns assets with same sha256.
- UI indicates duplicates.
Commit: feat: expose and display duplicates
Task 7.3: Moments clustering
Files:
- Create:
apps/web/app/api/moments/route.ts - Create:
apps/web/app/lib/moments.ts - Test:
apps/web/src/__tests__/moments.test.ts
Steps:
- RED: test that assets within 30 minutes cluster together
- GREEN: implement clustering
- Wire UI to show moments as sub-groups
Commit: feat: add day moments clustering
Phase 8: Presign Endpoint Selection (LAN vs Tailnet)
Task 8.1: Add endpoint mode to config and presign
Files:
- Modify:
packages/minio/src/index.ts - Modify:
packages/config/src/index.ts - Modify:
apps/web/app/api/assets/[id]/url/route.ts
Steps:
- Add env for
MINIO_PUBLIC_ENDPOINT_LANandMINIO_ENDPOINT_MODE=tailnet|lan|auto. - If
endpoint=lan|tailnetquery param is provided, force that. - In
auto, use tailnet as safe default.
Commit: feat: support lan/tailnet endpoint selection for presigned URLs
Phase 9: Storage Policies (Derived Lifecycle) + CI Builds
Task 9.1: Optional MinIO lifecycle policy job
Files:
- Modify:
helm/porthole/values.yaml - Modify:
helm/porthole/templates/job-ensure-bucket.yaml.tpl - Create:
helm/porthole/templates/job-apply-lifecycle.yaml.tpl
Steps:
- Add optional Job to apply lifecycle rules for prefixes
thumbs/andderived/(expire after N days) without touchingoriginals/.
Commit: feat: add optional lifecycle policy job
Task 9.2: Add CI pipeline for multi-arch builds
Files:
- Create:
.gitea/workflows/build-images.yml(or alternative supported by your CI) - Modify:
README.md
Steps:
- Build and push multi-arch images for
apps/webandapps/worker. - Run:
bun run typecheck. - Run:
bash run_tests.sh(Go tests) to keep repo green.
Commit: ci: build and push multi-arch images
Verification Checklist (Per Phase)
bun testbun run typecheckbash run_tests.sh- Helm template renders:
helm template porthole helm/porthole -f your-values.yaml --namespace porthole