{"id":47810588,"url":"https://github.com/zntrio/vynilino","last_synced_at":"2026-04-03T18:09:34.718Z","repository":{"id":346998964,"uuid":"1191435321","full_name":"zntrio/vynilino","owner":"zntrio","description":"Vynil collection manager","archived":false,"fork":false,"pushed_at":"2026-03-26T19:31:40.000Z","size":304,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-27T03:25:26.467Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zntrio.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-25T08:38:57.000Z","updated_at":"2026-03-26T19:31:31.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/zntrio/vynilino","commit_stats":null,"previous_names":["zntrio/vynilino"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/zntrio/vynilino","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zntrio%2Fvynilino","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zntrio%2Fvynilino/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zntrio%2Fvynilino/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zntrio%2Fvynilino/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zntrio","download_url":"https://codeload.github.com/zntrio/vynilino/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zntrio%2Fvynilino/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31368158,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-03T17:53:18.093Z","status":"ssl_error","status_checked_at":"2026-04-03T17:53:17.617Z","response_time":107,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-03T18:09:34.114Z","updated_at":"2026-04-03T18:09:34.711Z","avatar_url":"https://github.com/zntrio.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# vynilino\n\n[![SLSA Level 2](https://slsa.dev/images/gh-badge-level2.svg)](SECURITY.md)\n[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)\n\nA self-hosted vinyl record collection manager with a GraphQL API.\n\n\u003e **Supply chain verification**: Release container images are signed with cosign and include SLSA provenance and an SPDX SBOM. See [SECURITY.md](SECURITY.md) for verification instructions.\n\n## About\n\nVynilino lets you catalogue, search, and manage your vinyl record collection from a single self-hosted instance. It connects to the [Discogs](https://www.discogs.com/) database for metadata and cover-art lookup, stores everything in a local SQLite file, and exposes a GraphQL API consumed by a lightweight built-in web UI.\n\nKey properties:\n\n- **Self-hosted first** — no cloud account required; all data stays on your server.\n- **Single binary** — the Go backend embeds the UI assets; one file to deploy.\n- **Lightweight** — \u003c 30 kB gzipped JS bundle (Alpine.js + Tailwind CSS 4); no React/Vue runtime.\n- **OIDC-ready** — integrate with any standards-compliant identity provider.\n- **Supply-chain secure** — SLSA Level 2 provenance, keyless cosign signatures, and SPDX SBOM on every release.\n\n## How this project was built — vibe engineering with OpenSpec\n\nVynilino was **vibe engineered**: the entire codebase was designed and implemented through an AI-assisted, spec-driven workflow powered by [OpenSpec](https://openspec.dev/).\n\n### What is vibe engineering?\n\nVibe engineering is a development approach where a human collaborates with an AI assistant (here, Claude Code) to go from idea to working software. The human describes intent and reviews outcomes; the AI writes code, runs tests, and iterates. The key discipline that makes this work at scale is **spec-driven development**.\n\n### What is OpenSpec?\n\nOpenSpec is a lightweight, file-based specification system that lives inside the repository (`openspec/`). Each feature starts as a **proposal** → **design** → **specs** → **tasks** chain. The AI reads these documents, implements the tasks, and marks them complete. The result is:\n\n- A full audit trail of every design decision inside the repo.\n- Human-readable specs that explain *why* code exists, not just *what* it does.\n- A repeatable workflow that a new contributor (or AI session) can pick up at any point.\n\n### Repository layout for OpenSpec artifacts\n\n```\nopenspec/\n  config.yaml               # project-level OpenSpec config\n  specs/                    # current, living specifications per domain\n  changes/\n    archive/                # completed changes, one directory per change\n      YYYY-MM-DD-\u003cslug\u003e/\n        .openspec.yaml      # change metadata\n        proposal.md         # what and why\n        design.md           # how (architecture, trade-offs)\n        specs/              # per-domain spec deltas for this change\n        tasks.md            # implementation checklist\n```\n\nArchived changes are the project's decision log. Reading them in chronological order tells the full story of how the project evolved.\n\n## Quickstart (Docker Compose)\n\n```bash\n# 1. Generate a 32-byte symmetric key (hex-encoded)\nexport VYNILINO_TOKEN_KEY=$(openssl rand -hex 32)\n\n# 2. Set allowed origins for your SPA\nexport VYNILINO_ALLOWED_ORIGINS=\"http://localhost:3000\"\n\n# 3. Start the service\ndocker compose up -d\n```\n\nThe service is now available at `http://localhost:8080`.\n\n## Reverse Proxy / TLS Termination (Caddy)\n\nVynilino binds to plain HTTP on `:8080`. For production, put [Caddy](https://caddyserver.com/) in front to handle TLS termination and automatic certificate management via Let's Encrypt.\n\n### Public domain (automatic HTTPS)\n\n```caddyfile\nvinyl.example.com {\n    reverse_proxy localhost:8080 {\n        header_up X-Real-IP        {remote_host}\n        header_up X-Forwarded-For  {remote_host}\n        header_up X-Forwarded-Proto {scheme}\n    }\n\n    header {\n        Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\"\n        X-Content-Type-Options    \"nosniff\"\n        X-Frame-Options           \"DENY\"\n        Referrer-Policy           \"strict-origin-when-cross-origin\"\n        -Server\n    }\n\n    encode gzip\n}\n```\n\nCaddy proxies WebSocket upgrades (GraphQL subscriptions) automatically — no extra configuration needed.\n\nUpdate these environment variables to match your public domain:\n\n```bash\nVYNILINO_OIDC_REDIRECT_URL=https://vinyl.example.com/oidc/callback\nVYNILINO_ALLOWED_ORIGINS=https://vinyl.example.com\n```\n\n### Local / self-signed TLS\n\nUse Caddy's built-in CA for a locally-trusted certificate (useful for LAN deployments or development over HTTPS):\n\n```caddyfile\nvinyl.local {\n    tls internal\n    reverse_proxy localhost:8080\n}\n```\n\n### Custom certificate\n\n```caddyfile\nvinyl.example.com {\n    tls /path/to/cert.pem /path/to/key.pem\n    reverse_proxy localhost:8080\n}\n```\n\n## Environment Variables\n\n| Variable                      | Default                            | Description                                      |\n|-------------------------------|------------------------------------|--------------------------------------------------|\n| `VYNILINO_LISTEN_ADDR`        | `:8080`                            | TCP address to bind                              |\n| `VYNILINO_ENV`                | `production`                       | `development` enables playground + verbose logs  |\n| `VYNILINO_DB_PATH`            | `./data/vynilino.db`               | SQLite database file path                        |\n| `VYNILINO_MEDIA_DIR`          | `./data/media`                     | Directory for cover art files                    |\n| `VYNILINO_TOKEN_KEY`          | *(required)*                       | 32-byte hex-encoded PASETO symmetric key         |\n| `VYNILINO_TOKEN_KEY_NEW`      | *(unset)*                          | New key during rotation bridge period; see [key rotation runbook](docs/runbooks/key-rotation.md) |\n| `VYNILINO_SINGLE_OWNER`       | `true`                             | Only one user account allowed when `true`        |\n| `VYNILINO_BOOTSTRAP_TOKEN`    | *(unset)*                          | When set, required as a one-time token for first-user registration |\n| `VYNILINO_ALLOWED_ORIGINS`    | `http://localhost:3000,...`        | Comma-separated CORS allowed origins             |\n| `VYNILINO_PLAYGROUND`         | `false` (`true` in development)    | Serve GraphQL Playground at `/playground`        |\n| `VYNILINO_INTROSPECTION`      | `false` (`true` in development)    | Enable GraphQL introspection                     |\n| `VYNILINO_OIDC_ISSUER`        | *(unset — OIDC disabled)*          | OIDC provider issuer URL (enables OIDC when set) |\n| `VYNILINO_OIDC_CLIENT_ID`     | *(unset)*                          | OIDC application client ID                       |\n| `VYNILINO_OIDC_CLIENT_SECRET` | *(unset)*                          | OIDC application client secret                   |\n| `VYNILINO_OIDC_REDIRECT_URL`  | *(unset)*                          | Callback URL registered with the OIDC provider   |\n| `VYNILINO_OIDC_AUTO_REDIRECT` | `false`                            | When `true`, `GET /login` redirects directly to the OIDC provider |\n| `VYNILINO_DISCOGS_TOKEN`      | *(unset)*                          | Personal access token for higher Discogs API rate limits |\n| `VYNILINO_BEHIND_PROXY`       | `false`                            | Trust `X-Forwarded-For` / `X-Real-IP` headers (set only behind a known reverse proxy) |\n| `VYNILINO_TLS_CERT`           | *(unset)*                          | Path to TLS certificate PEM file (enables native TLS) |\n| `VYNILINO_TLS_KEY`            | *(unset)*                          | Path to TLS private key PEM file                 |\n| `VYNILINO_BACKUP_HMAC_KEY`    | *(unset)*                          | HMAC-SHA256 key for backup authenticity signing/verification |\n\n## API\n\nGraphQL endpoint: `POST /graphql`\nWebSocket (subscriptions): `GET /graphql` (upgrade)\n\n### Auth Flow\n\n```graphql\n# Register (first user becomes admin in single-owner mode)\nmutation {\n  register(email: \"you@example.com\", password: \"YourPass1!\") {\n    accessToken\n    refreshToken\n    expiresIn\n  }\n}\n\n# Login\nmutation {\n  login(email: \"you@example.com\", password: \"YourPass1!\") {\n    accessToken\n    refreshToken\n  }\n}\n```\n\nPass `Authorization: Bearer \u003caccessToken\u003e` on all subsequent requests.\n\n### Collection\n\n```graphql\nmutation {\n  createRecord(input: {\n    title: \"The Dark Side of the Moon\"\n    artist: \"Pink Floyd\"\n    year: 1973\n    format: LP\n    condition: NEAR_MINT\n  }) {\n    record { id title artist }\n    duplicateWarning\n  }\n}\n\nquery {\n  records(first: 20, filter: { artist: \"Floyd\" }, sort: { field: YEAR, direction: DESC }) {\n    edges { node { id title year condition } }\n    pageInfo { hasNextPage endCursor }\n    totalCount\n  }\n}\n```\n\n### Cover Art\n\n```bash\ncurl -X POST http://localhost:8080/media/cover-art \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -F \"file=@cover.jpg\" \\\n  -F \"recordId=\u003crecord-id\u003e\"\n```\n\n## Import / Export\n\n```bash\n# Export as JSON\ncurl -o collection.json \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  http://localhost:8080/export/json\n\n# Export as CSV\ncurl -o collection.csv \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  http://localhost:8080/export/csv\n\n# Import from CSV (including Discogs export format)\ncurl -X POST http://localhost:8080/import/csv \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -F \"file=@discogs_export.csv\"\n```\n\n## Backup\n\nAll data is stored in two locations:\n\n- **Database**: `VYNILINO_DB_PATH` (single SQLite file)\n- **Cover art**: `VYNILINO_MEDIA_DIR` (flat directory per user)\n\n### Built-in backup CLI\n\nVynilino ships a `backup` subcommand that creates a compact, verified snapshot using SQLite's `VACUUM INTO` and optionally signs it with HMAC-SHA256.\n\n```bash\n# Create a backup (saved next to the database file)\nvynilino backup create --db ./data/vynilino.db\n\n# Create a signed backup (recommended for tamper detection)\nvynilino backup create \\\n  --db ./data/vynilino.db \\\n  --output /backups/ \\\n  --hmac-key \"$VYNILINO_BACKUP_HMAC_KEY\"\n\n# Verify backup integrity and row count\nvynilino backup verify \\\n  --backup /backups/vynilino-20260325-120000.db \\\n  --hmac-key \"$VYNILINO_BACKUP_HMAC_KEY\"\n```\n\nEach backup produces three files:\n- `\u003cname\u003e-\u003ctimestamp\u003e.db` — the compacted SQLite snapshot\n- `\u003cname\u003e-\u003ctimestamp\u003e.db.count` — expected row count sidecar\n- `\u003cname\u003e-\u003ctimestamp\u003e.db.sig` — HMAC-SHA256 signature (only when `--hmac-key` is set)\n\n### Example backup with restic\n\n```bash\nrestic backup \\\n  /path/to/vynilino.db \\\n  /path/to/media/\n```\n\n### Example backup with rclone\n\n```bash\nrclone sync /path/to/data/ remote:vynilino-backup/\n```\n\n## Migration Check\n\nVerify that the database schema is up to date without starting the server:\n\n```bash\ndocker run --rm \\\n  -v vynilino_data:/data \\\n  -e VYNILINO_DB_PATH=/data/vynilino.db \\\n  -e VYNILINO_TOKEN_KEY=\u003ckey\u003e \\\n  vynilino -check-migrations\n```\n\n## Web UI\n\nVynilino ships a built-in hybrid web UI (desktop + mobile) served at `GET /`. It is embedded directly into the Go binary at build time — no separate CDN or SPA host required.\n\n### UI Development Workflow\n\n```bash\n# Install frontend dependencies (first time only)\nmake ui-install\n\n# Start the Vite dev server (hot-reload) proxied to your running Go backend\nmake ui-dev\n# → opens http://localhost:5173\n\n# Build production assets into web/dist/ (runs automatically before `make build`)\nmake ui-build\n```\n\n### Lightweight by design\n\nThe production JS bundle is **\u003c 30 kB gzipped** using Alpine.js + Tailwind CSS 4. No React, Vue, or Angular runtime.\n\n### REST helpers (UI-specific endpoints)\n\n| Method | Path | Auth | Description |\n|--------|------|------|-------------|\n| `GET` | `/api/me` | Bearer | Returns `{\"id\",\"email\"}` for the current user or `401` |\n| `POST` | `/api/upload` | Bearer | Accepts `multipart/form-data` with a `file` field (≤ 5 MB JPEG/PNG/WebP); returns `{\"url\":\"...\"}` |\n\n### Architecture note\n\nStatic assets are compiled by Vite into `web/dist/` and embedded in the Go binary via `//go:embed all:dist` in `web/embed.go`. The `internal/adapter/ui` package exposes:\n\n- `SPAHandler()` — serves static files with immutable cache headers; falls back to `index.html` for all non-API paths (SPA routing)\n- `MeHandler()` — `GET /api/me`\n- `UploadHandler()` — `POST /api/upload`\n\nAPI routes (`/graphql`, `/api/`, `/auth/`, `/media/`, `/export/`, `/import/`) are registered before the SPA handler and always take precedence.\n\n## Development\n\n```bash\n# Copy env template\ncp .env.example .env\n# Edit .env with your settings\n\nVYNILINO_ENV=development \\\nVYNILINO_TOKEN_KEY=$(openssl rand -hex 32) \\\ngo run ./cmd/vynilino serve\n\n# Run tests\ngo test ./...\n\n# Regenerate GraphQL code (after schema changes)\ngo run github.com/99designs/gqlgen generate\n\n# Regenerate SQL code (after query changes)\nsqlc generate\n```\n\n### Project structure\n\n```\ncmd/vynilino/         # binary entrypoint and CLI commands\ninternal/\n  adapter/\n    discogs/          # Discogs API client\n    filestore/        # local cover-art storage\n    graphql/          # GraphQL server, middleware, resolvers\n    storage/sqlite/   # SQLite repositories (sqlc-generated)\n    ui/               # embedded SPA + REST helpers\n  app/                # application services (auth, records, OIDC, …)\n  config/             # environment-variable configuration\n  ctxutil/            # request-scoped context helpers\n  domain/             # core domain types (User, Record, Token, …)\nopenspec/             # OpenSpec specs and change archive\nui/                   # Vite + Alpine.js frontend source\nweb/                  # embedded assets (go:embed target)\n```\n\n## Contributing\n\nContributions are welcome! Please follow these steps:\n\n1. **Open an issue** or discussion before starting significant work, so we can align on design.\n2. **Fork** the repository and create a feature branch from `main`.\n3. **Write tests** for any new behaviour. Run `go test ./...` before submitting.\n4. **Follow the OpenSpec workflow** for non-trivial features: add a proposal under `openspec/changes/` and link it in your PR description. This keeps the decision log complete.\n5. **Submit a pull request** against `main` with a clear description of what changed and why.\n\nBy contributing you agree that your contributions will be licensed under the Apache License 2.0.\n\n## License\n\nCopyright 2026 Thibault NORMAND\n\nLicensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzntrio%2Fvynilino","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzntrio%2Fvynilino","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzntrio%2Fvynilino/lists"}