feat: open-source release prep, audit fixes, and deploy hardening
Production deploy completed (DigitalOcean Droplet, self-hosted Supabase, Caddy + Let's Encrypt, GitHub Actions auto-deploy on push to main, daily pg_dumpall backups). This commit lands the work that came out of the deep audit pass and the open-source release prep. Audit fixes: - Webhook signing secret is now displayed in the settings UI with show/hide and copy-to-clipboard; the broken external docs.hackerspace.sh link is replaced with a link to the new local docs. - Settings updateSpaceSettings parameter type now includes mission_statement so the field persists. - members page enforces member_directory_visibility for non-admin viewers (admin_only, board_visible, members_visible). - Mobile responsiveness pass: Projects Kanban grid, Members + Payments tables (column hiding on small screens), Proposal tally + voting buttons (2-col on mobile). Open-source release scaffolding: - LICENSE (MIT). - CONTRIBUTING.md with dev setup, conventions, PR flow. - SECURITY.md with disclosure policy. - CHANGELOG.md. - docs/WEBHOOKS.md with Node and Python signature verification examples. - README.md rewritten for an open-source audience. Removed v0/Vercel references: - @vercel/analytics dependency removed. - app/layout.tsx no longer imports Analytics, drops generator: 'v0.app'. - Canonical deployment docs (docs/DEPLOYMENT.md, docs/README.md, docs/ARCHITECTURE.md, DATABASE.md, CLAUDE.md, README.md) now describe the self-hosted Droplet target only. - e2e console-error check no longer filters [v0] noise. Landing page: - GitHub icon link to the public repo in the nav and footer.
This commit is contained in:
@@ -21,8 +21,6 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
# Strict host key checking would need us to pin a known_hosts;
|
||||
# for now the droplet's host key is trusted on first use.
|
||||
script_stop: true
|
||||
script: |
|
||||
set -e
|
||||
/opt/hackerspace-ops/deploy.sh
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Production deployment to a self-hosted DigitalOcean Droplet with automatic HTTPS via Caddy and Let's Encrypt.
|
||||
- GitHub Actions workflow that pushes a deploy on every commit to `main`. Migrations are applied automatically via `_migrations_applied` tracking.
|
||||
- Daily encrypted `pg_dumpall` backup cron, retained 14 days, written to the persistent block volume.
|
||||
- Resend SMTP integration for transactional email (GoTrue).
|
||||
- Open-source release scaffolding: `LICENSE` (MIT), `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, refreshed `README.md`, `docs/WEBHOOKS.md`.
|
||||
- GitHub repository link on the landing page.
|
||||
- Webhook signing secret is now displayed in the settings UI with show/hide and copy-to-clipboard.
|
||||
|
||||
### Changed
|
||||
- Mobile responsiveness pass on the Kanban board, members table, payments table, proposal voting grids, and the landing mini board preview.
|
||||
- `members` directory now respects the `member_directory_visibility` space setting for non-admin viewers.
|
||||
- `lib/actions/settings.ts` now persists `mission_statement` alongside the rest of the space metadata.
|
||||
|
||||
### Removed
|
||||
- `@vercel/analytics` dependency and all references to Vercel hosting. The project is self-hosted on a Droplet.
|
||||
- Dead v0 codegen artifacts.
|
||||
|
||||
### Fixed
|
||||
- Auth container crash-loop on first boot caused by an empty `DISABLE_SIGNUP` env value.
|
||||
- Kong port-binding race that left `supabase.hackerspace.sh` returning 502 after a fresh stack bring-up.
|
||||
|
||||
## [0.1.0] - 2026-03-10
|
||||
|
||||
Initial pre-production cut. See `docs/HANDOFF.md` for the full session-by-session history of work prior to the open-source release.
|
||||
@@ -38,7 +38,7 @@ This file is the standing brief for any Claude session in this repository. Read
|
||||
## Deployment rules
|
||||
|
||||
- A fresh clone must produce a working app with no more than: clone, `pnpm install`, copy `.env.example` to `.env.local` with real values, run `scripts/schema.sql` in Supabase, `pnpm dev`.
|
||||
- A fresh production deploy (Vercel, DigitalOcean App Platform, Droplet) must be documented in `docs/DEPLOYMENT.md` step by step, no implicit knowledge required.
|
||||
- A fresh production deploy (self-hosted Supabase + app on a DigitalOcean Droplet) must be documented in `docs/DEPLOYMENT.md` step by step, no implicit knowledge required.
|
||||
- Any new environment variable must be added to `.env.example` in the same change that introduces it. Never let a deploy fail because an env var was only documented in a Slack thread.
|
||||
- The Dockerfile and docker-compose configuration in the repo are the source of truth for self-hosted deploys. Update them when the runtime requirements change.
|
||||
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Contributing to hackerspace-management
|
||||
|
||||
Thank you for your interest. This document explains how to set up a development environment, the conventions the codebase follows, and how to submit a change.
|
||||
|
||||
## Getting set up
|
||||
|
||||
1. Fork the repository on GitHub and clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-user>/hackerspace-management.git
|
||||
cd hackerspace-management
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Copy the env template and fill it in:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
For a fully local stack (recommended), follow [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md). It walks you through running Supabase locally with the Supabase CLI in under five minutes.
|
||||
|
||||
4. Start the dev server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
5. Sign up, create a space, and you should land on the dashboard at `http://localhost:3000/dashboard`.
|
||||
|
||||
## How the project is structured
|
||||
|
||||
Top-level directories:
|
||||
|
||||
- `app/` — Next.js App Router routes, grouped by `(app)` (authenticated app), `(landing)` (public landing), `(resources)` (legacy hackerspace.sh subsite).
|
||||
- `components/` — Shared React components.
|
||||
- `lib/` — Server actions, helpers, validations, type definitions.
|
||||
- `scripts/` — Database schema and numbered SQL migrations.
|
||||
- `docs/` — Long-form documentation.
|
||||
- `e2e/` — Playwright end-to-end tests.
|
||||
- `__tests__/` — Vitest unit tests.
|
||||
|
||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a deeper tour.
|
||||
|
||||
## Database changes
|
||||
|
||||
The schema is owned by `scripts/schema.sql` (full, idempotent state) and a sequence of numbered files (`scripts/NNN_description.sql`). Rules:
|
||||
|
||||
1. Add a new numbered file under `scripts/` for every schema change. Do not edit prior numbered files.
|
||||
2. Update `scripts/schema.sql` to reflect the new canonical state.
|
||||
3. Keep migrations idempotent (`IF NOT EXISTS`, `OR REPLACE`, exception-handled `CREATE TYPE`).
|
||||
4. Add or update RLS policies whenever you add a table. Default to deny.
|
||||
5. Note the change in [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
`scripts/README.md` documents the migration conventions in full.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript strict mode. No `any` unless the third-party type is genuinely lost.
|
||||
- Prefer server components and server actions. Push client interactivity to small leaf components.
|
||||
- Tailwind for styling, shadcn/ui primitives for inputs, dialogs, dropdowns.
|
||||
- Avoid premature abstraction. Three similar lines of code is better than a clever helper.
|
||||
- Validation lives in `lib/validations.ts` (Zod). Every server action must validate its input.
|
||||
- All queries that touch tenant data go through the Supabase server client. Service role only inside trusted server actions, never in client components.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: `pnpm test`. Files live under `__tests__/`.
|
||||
- End to end: `pnpm test:e2e`. Files live under `e2e/`. Requires a local dev server.
|
||||
- New server actions need a unit test. New user-visible flows need an e2e test.
|
||||
|
||||
## Submitting a change
|
||||
|
||||
1. Create a branch off `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/short-description
|
||||
```
|
||||
|
||||
2. Make your change. Run `pnpm lint` and `pnpm test`.
|
||||
|
||||
3. Commit. Write the message in the imperative mood, focused on the why:
|
||||
|
||||
```
|
||||
feat: surface webhook secret with copy button
|
||||
|
||||
Admins could rotate the signing secret but never see the new value,
|
||||
making it impossible to use for signature verification.
|
||||
```
|
||||
|
||||
4. Push and open a pull request against `main`. Describe what changed and why, link any related issue, and call out anything reviewers should pay extra attention to.
|
||||
|
||||
5. CI runs lint and tests. A maintainer will review.
|
||||
|
||||
## Security issues
|
||||
|
||||
Do not file public GitHub issues for security vulnerabilities. See [SECURITY.md](./SECURITY.md) for the disclosure process.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
Be kind. Assume good faith. Hackerspaces and makerspaces thrive on collaboration. We extend the same expectation to the project.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
## Database Setup
|
||||
|
||||
> For full deployment instructions (Vercel, DigitalOcean App Platform, Docker on a Droplet), see [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
> For full deployment instructions, see [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
|
||||
### Fresh deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 hackerspace-management contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,72 +1,101 @@
|
||||
# hackerspace-management
|
||||
|
||||
Multi-tenant member, payments, tasks, projects, ops, and comms platform for hackerspaces. Each tenant (a "space") owns its members, tasks, projects, payments, knowledge base, and chat channels. All access is row-level secured at the database.
|
||||
[](./LICENSE)
|
||||
[](https://nextjs.org)
|
||||
[](https://supabase.com)
|
||||
|
||||
A multi-tenant operating system for hackerspaces, makerspaces, and member-run shops. One installation can host many independent spaces. Each space owns its own members, tasks, projects, payments, knowledge base, proposals, incidents, policies, and chat channels. All data access is enforced at the database with row-level security.
|
||||
|
||||
Source: <https://github.com/virgilvox/hackerspace-management>
|
||||
|
||||
## Features
|
||||
|
||||
- **Members and tiers.** Roster, tiers (Plus, Basic, Associate), roles (admin, board, treasurer, member, associate), state (current, late, inactive, unverified), per-member skills, certifications, and badges.
|
||||
- **Tasks and projects.** Project boards, task assignment, status, area tagging, priority, comments.
|
||||
- **Operations.** Knowledge base, secrets vault, area leads, equipment, maintenance log.
|
||||
- **Payments and financials.** Per-member payment ledger, integration credentials per space, exports, monthly financial summary.
|
||||
- **Governance.** Proposals with vote tracking and expiry, incident reports, policy library, area-of-interest configuration.
|
||||
- **Communications.** Multi-channel chat with realtime delivery.
|
||||
- **Recruitment.** Public-facing recruitment page per space.
|
||||
- **API.** PostgREST-generated REST for every table, gated by RLS. Optional webhooks with HMAC-signed deliveries.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 16 (App Router, React 19, TypeScript)
|
||||
- Tailwind v4 + shadcn/ui
|
||||
- Supabase (Postgres, Auth, Realtime)
|
||||
- Vitest + Playwright
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| App | Next.js 16, React 19, TypeScript, Tailwind v4, shadcn/ui |
|
||||
| Data | PostgreSQL 17 via self-hosted Supabase |
|
||||
| Auth | Supabase GoTrue, JWT, email + password |
|
||||
| Realtime | Supabase Realtime (Phoenix channels over WebSocket) |
|
||||
| Storage | Block volume on the host (database), local FS (uploads) |
|
||||
| Email | Resend (SMTP) |
|
||||
| Reverse proxy | Caddy 2 (automatic Let's Encrypt) |
|
||||
| Tests | Vitest (unit), Playwright (end to end) |
|
||||
|
||||
## Quick start (local)
|
||||
## Quick start (local development)
|
||||
|
||||
Requirements: Node.js 20 or later, pnpm 10 or later, Docker (for the local Supabase CLI), git.
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
git clone https://github.com/virgilvox/hackerspace-management.git
|
||||
cd hackerspace-management
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# Fill in Supabase URL, anon key, service role key in .env.local
|
||||
```
|
||||
|
||||
Apply the database schema in your Supabase project:
|
||||
|
||||
1. Supabase dashboard, SQL Editor, New query.
|
||||
2. Paste the contents of `scripts/schema.sql`.
|
||||
3. Run.
|
||||
|
||||
Then:
|
||||
Edit `.env.local` with values from your local Supabase project (see `docs/LOCAL_DEV.md` to start one in seconds using the Supabase CLI), then:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`, sign up, create a space, land on `/dashboard`.
|
||||
Open `http://localhost:3000`, sign up, create a space, you land on the dashboard.
|
||||
|
||||
Health probe: `curl http://localhost:3000/api/health`.
|
||||
Detailed local setup: [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md).
|
||||
|
||||
## Production deployment
|
||||
|
||||
The supported production target is a single DigitalOcean Droplet running self-hosted Supabase plus the Next.js app behind Caddy with Let's Encrypt. No managed Supabase, no third-party application data store.
|
||||
|
||||
End-to-end guide: [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md).
|
||||
|
||||
Summary:
|
||||
|
||||
1. Provision a Droplet (4 GB RAM, 2 vCPU, 80 GB disk recommended).
|
||||
2. Attach a block volume mounted at `/mnt/data` for the Postgres data directory and backups.
|
||||
3. Point DNS A records for your domain, `www`, `supabase`, `studio` at the Droplet.
|
||||
4. Run the bootstrap script in `scripts/` to install Docker, Supabase, Node, Caddy, the app, the systemd unit, backup cron, and the deploy hook.
|
||||
5. Push to `main`. GitHub Actions runs `deploy.sh` on the Droplet over SSH: pull, install, apply pending migrations, build, restart.
|
||||
|
||||
The deploy script is idempotent and tracked: every numbered migration in `scripts/` is recorded in `public._migrations_applied`. New migrations are applied automatically on push.
|
||||
|
||||
## Database
|
||||
|
||||
`scripts/schema.sql` is the canonical, idempotent, full schema. Run it once on a fresh Supabase project. Subsequent changes are added as numbered migrations (`scripts/021_*.sql`, `022_*.sql`, etc.) and applied incrementally.
|
||||
|
||||
| Topic | Doc |
|
||||
|-------|-----|
|
||||
| Conventions | [scripts/README.md](./scripts/README.md) |
|
||||
| Full reference | [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) |
|
||||
| Column map | [DB_SCHEMA_MAP.md](./DB_SCHEMA_MAP.md) |
|
||||
|
||||
## Documentation
|
||||
|
||||
| File | What it covers |
|
||||
|------|----------------|
|
||||
| [CLAUDE.md](./CLAUDE.md) | Working agreement for AI assistants in this repo |
|
||||
| [docs/AUDIT.md](./docs/AUDIT.md) | Latest deep audit (2026-05-13): findings, fixes applied, open issues |
|
||||
| [docs/HANDOFF.md](./docs/HANDOFF.md) | Session-by-session change log |
|
||||
| [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) | Local, Vercel, DigitalOcean App Platform, DigitalOcean Droplet (with managed Supabase) |
|
||||
| [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md) | End-to-end self-hosted on a single DigitalOcean Droplet (Supabase + app) |
|
||||
| [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md) | Fresh-clone to running app locally in five minutes, using the Supabase CLI |
|
||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System design, project structure, auth flows |
|
||||
| [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) | Full schema reference |
|
||||
| [DB_SCHEMA_MAP.md](./DB_SCHEMA_MAP.md) | Quick column-by-column lookup |
|
||||
| [docs/API_REFERENCE.md](./docs/API_REFERENCE.md) | Server actions |
|
||||
| [docs/COMPONENT_REFERENCE.md](./docs/COMPONENT_REFERENCE.md) | UI components |
|
||||
| [TESTING.md](./TESTING.md) | How to run tests |
|
||||
| [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Diagnostic-to-feature roadmap for governance modules (proposals, incidents, policies) |
|
||||
|
||||
## Deployment
|
||||
|
||||
Four supported targets:
|
||||
|
||||
- **Vercel** (managed Supabase): zero-config, connect the GitHub repo, set env vars. See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean App Platform** (managed Supabase): spec ships at [.do/app.yaml](./.do/app.yaml). See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean Droplet, app only** (managed Supabase): Dockerfile and docker-compose.yml ship in the repo root. See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean Droplet, fully self-hosted** (your own Supabase, your own Postgres, your own data): full end-to-end guide in [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md).
|
||||
|
||||
Required environment variables are in [.env.example](./.env.example).
|
||||
|
||||
## Database migrations
|
||||
|
||||
`scripts/schema.sql` is the canonical, idempotent, full-schema deploy. Run it once on a fresh Supabase project. For existing deployments, only run new incremental files (`scripts/NNN_description.sql`).
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System design, project structure, auth flow |
|
||||
| [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) | Tables, enums, indexes, RLS policies |
|
||||
| [docs/API_REFERENCE.md](./docs/API_REFERENCE.md) | Server actions and REST endpoints |
|
||||
| [docs/COMPONENT_REFERENCE.md](./docs/COMPONENT_REFERENCE.md) | UI components and props |
|
||||
| [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md) | End to end production deploy |
|
||||
| [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md) | Fresh clone to running locally |
|
||||
| [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Proposals, incidents, policies |
|
||||
| [docs/WEBHOOKS.md](./docs/WEBHOOKS.md) | Webhook payload, signing, verification |
|
||||
| [TESTING.md](./TESTING.md) | Test commands and coverage |
|
||||
| [CONTRIBUTING.md](./CONTRIBUTING.md) | How to contribute |
|
||||
| [CHANGELOG.md](./CHANGELOG.md) | Notable changes per release |
|
||||
| [SECURITY.md](./SECURITY.md) | Vulnerability disclosure |
|
||||
| [LICENSE](./LICENSE) | MIT |
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -77,9 +106,17 @@ pnpm start # serve production build
|
||||
pnpm lint # eslint
|
||||
pnpm test # vitest watch mode
|
||||
pnpm test:ui # vitest UI
|
||||
pnpm test:e2e # playwright
|
||||
pnpm test:e2e # playwright end-to-end
|
||||
```
|
||||
|
||||
## Project status
|
||||
|
||||
The system is feature-functional but pre-production. See [docs/AUDIT.md](./docs/AUDIT.md) section 4 for known gaps (validation schema enum drift, dead scripts, UI-only OAuth buttons, no secrets encryption at rest, CSV import not wired, etc.).
|
||||
The system is feature-functional and serving production traffic at `hackerspace.sh`. See [docs/AUDIT.md](./docs/AUDIT.md) for the latest audit and the open issues list.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. Security issues: see [SECURITY.md](./SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security vulnerabilities in public GitHub issues, discussions, or pull requests.
|
||||
|
||||
Instead, email a description of the issue, reproduction steps, and any proof-of-concept code or screenshots to `security@hackerspace.sh`. We will acknowledge receipt within three business days and provide a remediation timeline within seven business days.
|
||||
|
||||
If you do not receive a timely response, you may also open a private GitHub Security Advisory at <https://github.com/virgilvox/hackerspace-management/security/advisories/new>.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Authentication and authorization bypass
|
||||
- Tenant isolation failures (cross-space data leaks)
|
||||
- SQL injection, XSS, CSRF, SSRF
|
||||
- Secret exposure or weak secret storage
|
||||
- Insecure default configurations in the deploy scripts
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Self-XSS that requires user-supplied JavaScript pasted into the browser console
|
||||
- Issues only reproducible against unsupported runtime versions
|
||||
- Denial of service achievable by a single authenticated user (rate limiting is a known follow-up; see the audit doc)
|
||||
|
||||
## Hardening checklist for self-hosted operators
|
||||
|
||||
If you run a self-hosted deployment, you are responsible for the operational security of your installation. At a minimum:
|
||||
|
||||
1. Keep the host OS patched. The bootstrap installs unattended-upgrades.
|
||||
2. Restrict SSH to key-based auth. Root login is disabled by the bootstrap.
|
||||
3. Run a firewall. The bootstrap configures UFW with ports 22, 80, 443 only.
|
||||
4. Rotate the Supabase service role key and JWT secret if your `.env` is ever exposed.
|
||||
5. Monitor the `_migrations_applied` table after deploys.
|
||||
6. Verify backups by restoring to a staging instance at least quarterly.
|
||||
7. Use Resend (or another SMTP provider) over plain SMTP with credentials in environment variables, never embedded in code.
|
||||
|
||||
## Supported versions
|
||||
|
||||
The `main` branch is the supported version. Older tags receive security fixes on a best-effort basis only.
|
||||
@@ -241,13 +241,13 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
|
||||
<div className="bg-card rounded border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">TIER</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">JOINED</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">LAST PAYMENT</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden md:table-cell">JOINED</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden lg:table-cell">LAST PAYMENT</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">STATUS</th>
|
||||
{isAdmin(currentRole) && (
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">ACTIONS</th>
|
||||
@@ -280,11 +280,11 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
{m.tier?.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden md:table-cell">
|
||||
{m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '—'}
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden lg:table-cell">
|
||||
{(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{m.status === 'unverified' ? (
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { MembersClient } from './members-client'
|
||||
|
||||
// member_directory_visibility values come from space settings:
|
||||
// admin_only — only admins see the directory
|
||||
// board_visible — admins + board + treasurer see the directory
|
||||
// members_visible — every member of the space sees the directory (default)
|
||||
const ELEVATED_ROLES = new Set(['admin', 'board', 'treasurer'])
|
||||
|
||||
export default async function MembersPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
@@ -15,6 +21,36 @@ export default async function MembersPage() {
|
||||
|
||||
if (!self) return null
|
||||
|
||||
const { data: space } = await supabase
|
||||
.from('spaces')
|
||||
.select('member_directory_visibility')
|
||||
.eq('id', self.space_id)
|
||||
.single()
|
||||
|
||||
const visibility = space?.member_directory_visibility ?? 'members_visible'
|
||||
const isAdmin = self.role === 'admin'
|
||||
const isElevated = ELEVATED_ROLES.has(self.role)
|
||||
|
||||
const allowed =
|
||||
visibility === 'members_visible' ||
|
||||
(visibility === 'board_visible' && isElevated) ||
|
||||
(visibility === 'admin_only' && isAdmin)
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="font-mono text-sm tracking-widest uppercase text-muted-foreground mb-3">Members</h1>
|
||||
<div className="bg-card border border-border rounded p-6">
|
||||
<p className="font-sans text-sm text-foreground mb-2">The member directory is restricted in this space.</p>
|
||||
<p className="font-sans text-sm text-muted-foreground">
|
||||
A space admin has set member directory visibility to <code className="font-mono text-xs px-1 py-0.5 bg-muted rounded">{visibility}</code>.
|
||||
Contact an admin if you need access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { data: members } = await supabase
|
||||
.from('space_members')
|
||||
.select('*')
|
||||
|
||||
@@ -197,12 +197,14 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
|
||||
<div className="bg-card rounded border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{['PLATFORM', 'AMOUNT', 'FROM / NOTE', 'DATE', 'MEMBER'].map(h => (
|
||||
<th key={h} className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">{h}</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">PLATFORM</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">AMOUNT</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden md:table-cell">FROM / NOTE</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground hidden sm:table-cell">DATE</th>
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -212,11 +214,11 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
{p.platform?.toUpperCase()}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-sans text-sm font-medium">{'$'}{Number(p.amount).toFixed(2)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{p.from_identifier}{p.from_note ? ` — "${p.from_note}"` : ''}
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate hidden md:table-cell">
|
||||
{p.from_identifier}{p.from_note ? ` - "${p.from_note}"` : ''}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{p.transaction_date ? new Date(p.transaction_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden sm:table-cell">
|
||||
{p.transaction_date ? new Date(p.transaction_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.link_status === 'linked' && p.space_members ? (
|
||||
|
||||
@@ -75,8 +75,8 @@ export function ProjectsClient({
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="overflow-x-auto -mx-4 md:mx-0 px-4 md:px-0">
|
||||
<div className="grid grid-cols-4 gap-4 min-w-[640px]">
|
||||
<div className="md:overflow-x-auto md:-mx-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{STATUS_COLS.map(col => {
|
||||
const items = columns[col.key] ?? []
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default async function ProposalDetailPage({ params }: { params: Promise<{
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Tally
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||
<Tally label="Yes" value={proposal.outcome_yes} accent="text-primary" />
|
||||
<Tally label="No" value={proposal.outcome_no} accent="text-orange-600" />
|
||||
<Tally label="Abstain" value={proposal.outcome_abstain} accent="text-muted-foreground" />
|
||||
|
||||
@@ -53,13 +53,13 @@ export function ProposalActions({
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
{myVote ? 'Update your vote' : 'Cast your vote'}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
|
||||
{votePositions.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPosition(p)}
|
||||
className={`font-mono text-xs tracking-widest uppercase py-2 rounded border transition ${
|
||||
className={`font-mono text-xs tracking-widest uppercase py-2.5 rounded border transition ${
|
||||
position === p
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'border-border text-foreground hover:border-primary'
|
||||
|
||||
@@ -134,6 +134,8 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
// Webhook
|
||||
const [webhookSecret, setWebhookSecret] = useState(space.webhook_secret || '')
|
||||
const [copiedWebhook, setCopiedWebhook] = useState(false)
|
||||
const [copiedSecret, setCopiedSecret] = useState(false)
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false)
|
||||
|
||||
async function handleSaveSpace(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -191,7 +193,8 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
const result = await rotateWebhookSecret()
|
||||
if (result.secret) {
|
||||
setWebhookSecret(result.secret)
|
||||
setMessage({ type: 'success', text: 'Webhook secret rotated!' })
|
||||
setShowWebhookSecret(true)
|
||||
setMessage({ type: 'success', text: 'New signing secret generated. Copy it now and store it securely.' })
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -202,6 +205,13 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
setTimeout(() => setCopiedWebhook(false), 2000)
|
||||
}
|
||||
|
||||
function copySecretToClipboard(text: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
}
|
||||
|
||||
function openIntegrationModal(config: typeof INTEGRATIONS_CONFIG[0]) {
|
||||
const existing = integrations.find(i => i.platform === config.platform)
|
||||
const initialForm: Record<string, string> = {}
|
||||
@@ -673,44 +683,87 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
{activeTab === 'webhooks' && (
|
||||
<div className="bg-card rounded border border-border p-6">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Webhook Endpoint</p>
|
||||
|
||||
<p className="font-sans text-sm text-muted-foreground mb-4">
|
||||
POST members to this endpoint for external integration or automation.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded p-4 mb-4">
|
||||
<code className="font-mono text-xs text-primary break-all">
|
||||
https://api.hackerspace.sh/{space.slug}/members
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(`https://api.hackerspace.sh/${space.slug}/members`)}
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
{copiedWebhook ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<a
|
||||
href="https://docs.hackerspace.sh/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition"
|
||||
>
|
||||
View Docs
|
||||
</a>
|
||||
{isAdmin && (
|
||||
<div className="bg-card rounded border border-border p-6 space-y-6">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Webhook Endpoint</p>
|
||||
<p className="font-sans text-sm text-muted-foreground mb-4">
|
||||
POST members to this endpoint for external integration or automation.
|
||||
</p>
|
||||
<div className="bg-muted/50 border border-border rounded p-4 mb-4">
|
||||
<code className="font-mono text-xs text-primary break-all">
|
||||
https://api.hackerspace.sh/{space.slug}/members
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleRotateWebhook}
|
||||
disabled={saving}
|
||||
onClick={() => copyToClipboard(`https://api.hackerspace.sh/${space.slug}/members`)}
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Rotate Secret
|
||||
<Copy className="w-3 h-3" />
|
||||
{copiedWebhook ? 'Copied!' : 'Copy URL'}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/virgilvox/hackerspace-management/blob/main/docs/WEBHOOKS.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition"
|
||||
>
|
||||
View Docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">Signing Secret</p>
|
||||
<p className="font-sans text-sm text-muted-foreground mb-4">
|
||||
Every delivery is signed with HMAC-SHA256 using this secret. See <a
|
||||
href="https://github.com/virgilvox/hackerspace-management/blob/main/docs/WEBHOOKS.md#signing-and-verification"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>the docs</a> for verification examples in Node and Python.
|
||||
</p>
|
||||
<div className="bg-muted/50 border border-border rounded p-4 mb-4 flex items-center gap-3">
|
||||
<code className="font-mono text-xs text-foreground break-all flex-1">
|
||||
{webhookSecret
|
||||
? (showWebhookSecret ? webhookSecret : '•'.repeat(Math.min(webhookSecret.length, 48)))
|
||||
: <span className="text-muted-foreground italic">No secret set. Rotate to generate one.</span>}
|
||||
</code>
|
||||
{webhookSecret && (
|
||||
<button
|
||||
onClick={() => setShowWebhookSecret(v => !v)}
|
||||
className="font-mono text-[10px] text-muted-foreground hover:text-foreground transition flex items-center gap-1 shrink-0"
|
||||
title={showWebhookSecret ? 'Hide secret' : 'Show secret'}
|
||||
>
|
||||
{showWebhookSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{webhookSecret && (
|
||||
<button
|
||||
onClick={() => copySecretToClipboard(webhookSecret)}
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
{copiedSecret ? 'Copied!' : 'Copy secret'}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={handleRotateWebhook}
|
||||
disabled={saving}
|
||||
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{webhookSecret ? 'Rotate' : 'Generate'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{webhookSecret && (
|
||||
<p className="font-sans text-[11px] text-muted-foreground mt-3">
|
||||
Rotating immediately invalidates the previous secret. Update your receiver before rotating in production.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+31
-3
@@ -34,6 +34,13 @@ function IcoEye({ className }: { className?: string }) {
|
||||
function IcoPin({ className }: { className?: string }) {
|
||||
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
}
|
||||
function IcoGithub({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.01-.02-1.98-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.09-.12-.29-.51-1.45.11-3.02 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.57.23 2.73.11 3.02.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.4-5.25 5.68.41.35.78 1.04.78 2.11 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56 4.57-1.52 7.85-5.83 7.85-10.91C23.5 5.65 18.35.5 12 .5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mini UI Previews ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -318,6 +325,15 @@ export default function LandingPage() {
|
||||
<Link href="/resources" className="hidden sm:inline-flex font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
|
||||
Resources
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/virgilvox/hackerspace-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View source on GitHub"
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded text-sidebar-foreground hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<IcoGithub className="w-5 h-5" />
|
||||
</a>
|
||||
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
|
||||
Log in
|
||||
</Link>
|
||||
@@ -502,9 +518,21 @@ export default function LandingPage() {
|
||||
<IcoTerminal className="w-4 h-4 text-primary" />
|
||||
<span className="font-mono text-xs text-sidebar-foreground">hackerspace.sh</span>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-sidebar-foreground/50">
|
||||
Management software for makerspaces and community workshops.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="font-mono text-[10px] text-sidebar-foreground/50">
|
||||
Management software for makerspaces and community workshops.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/virgilvox/hackerspace-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View source on GitHub"
|
||||
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 hover:text-white transition font-mono text-[10px]"
|
||||
>
|
||||
<IcoGithub className="w-3.5 h-3.5" />
|
||||
source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Geist, Geist_Mono } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import { Toaster } from 'sonner'
|
||||
import './globals.css'
|
||||
|
||||
@@ -10,7 +9,6 @@ const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
export const metadata: Metadata = {
|
||||
title: 'hackerspace.sh — Manage Your Space',
|
||||
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
|
||||
generator: 'v0.app',
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
@@ -40,7 +38,6 @@ export default function RootLayout({
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -42,8 +42,9 @@ Hackerspace.sh is a comprehensive member management platform designed for hacker
|
||||
### Infrastructure
|
||||
| Service | Purpose |
|
||||
|---------|---------|
|
||||
| Vercel | Hosting, Edge Functions |
|
||||
| Supabase Cloud | Database, Auth, Realtime subscriptions |
|
||||
| DigitalOcean Droplet | Self-hosted runtime (Docker, systemd, Caddy with Let's Encrypt) |
|
||||
| Self-hosted Supabase | Postgres, GoTrue (auth), PostgREST, Realtime, Studio |
|
||||
| Resend | Transactional email (SMTP) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+40
-557
@@ -1,572 +1,55 @@
|
||||
# Hackerspace.sh — Deployment Guide
|
||||
# Deployment
|
||||
|
||||
Complete instructions for every supported deployment target that uses **managed Supabase Cloud** as the database / auth layer.
|
||||
The supported production target is a single DigitalOcean Droplet running self-hosted Supabase plus the Next.js app behind Caddy with automatic HTTPS. The end-to-end procedure lives in [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md).
|
||||
|
||||
For a fully self-hosted deployment where you run Supabase yourself on a DigitalOcean Droplet (Postgres, GoTrue, PostgREST, Realtime, Storage, Studio — all on your own server), see [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md). That guide is end-to-end and assumes nothing else.
|
||||
## At a glance
|
||||
|
||||
---
|
||||
| Component | Hosted on |
|
||||
|-----------|-----------|
|
||||
| Database (Postgres) | Supabase container on the Droplet, data on a persistent block volume |
|
||||
| Auth (GoTrue) | Supabase container on the Droplet |
|
||||
| REST and Realtime | Supabase containers on the Droplet |
|
||||
| App (Next.js) | systemd service on the Droplet, port 3000 |
|
||||
| Reverse proxy | Caddy 2 on the Droplet, ports 80 and 443 |
|
||||
| Email | Resend (SMTP) |
|
||||
| Backups | Daily `pg_dumpall` to `/mnt/data/backups`, 14-day retention |
|
||||
| Continuous deployment | GitHub Actions runs `/opt/hackerspace-ops/deploy.sh` over SSH on every push to `main` |
|
||||
|
||||
## Table of Contents
|
||||
## Required environment variables
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Supabase Setup (required for all targets)](#2-supabase-setup)
|
||||
3. [Environment Variables Reference](#3-environment-variables)
|
||||
4. [Option A — Vercel (recommended)](#4-option-a--vercel)
|
||||
5. [Option B — DigitalOcean App Platform](#5-option-b--digitalocean-app-platform)
|
||||
6. [Option C — DigitalOcean Droplet (Docker / self-hosted)](#6-option-c--digitalocean-droplet)
|
||||
7. [Post-Deploy Checklist](#7-post-deploy-checklist)
|
||||
8. [Upgrading an Existing Deployment](#8-upgrading)
|
||||
9. [Troubleshooting](#9-troubleshooting)
|
||||
All variables are listed in [.env.example](../.env.example). At minimum:
|
||||
|
||||
---
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` — public Supabase API URL
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` — anon JWT, public
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` — service-role JWT, server-side only
|
||||
- `NEXT_PUBLIC_APP_URL` — public URL of the deployed app, used in OAuth redirects
|
||||
|
||||
## 1. Prerequisites
|
||||
## How a deploy runs
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| Node.js | 20+ | Local development |
|
||||
| pnpm | 9+ | Package manager |
|
||||
| Git | any | Source control |
|
||||
| Supabase account | free tier works | Database + auth + realtime |
|
||||
| Vercel / DigitalOcean account | — | Hosting |
|
||||
1. A developer pushes to `main`.
|
||||
2. GitHub Actions (`.github/workflows/deploy.yml`) opens an SSH connection to the Droplet using a deploy key stored in repository secrets.
|
||||
3. The action invokes `/opt/hackerspace-ops/deploy.sh`, which:
|
||||
- Fetches latest `main`.
|
||||
- Runs `pnpm install --frozen-lockfile`.
|
||||
- Iterates over `scripts/0*.sql`. Any file not present in `public._migrations_applied` is applied and recorded.
|
||||
- Restarts PostgREST to refresh its schema cache.
|
||||
- Runs `pnpm build`.
|
||||
- Restarts the `hackerspace-app` systemd unit.
|
||||
- Probes the local app for a 200 response.
|
||||
|
||||
Clone the repo:
|
||||
Migrations are idempotent. A redeploy that introduces no new migrations is a no-op on the database.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/virgilvox/hackerspace-management.git
|
||||
cd hackerspace-management
|
||||
pnpm install
|
||||
```
|
||||
## Local development
|
||||
|
||||
---
|
||||
See [LOCAL_DEV.md](./LOCAL_DEV.md) for the fresh-clone-to-running sequence using the Supabase CLI.
|
||||
|
||||
## 2. Supabase Setup
|
||||
## First-time provisioning
|
||||
|
||||
This step is identical regardless of which hosting option you choose.
|
||||
For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SSH hardening, deploy-key registration, the systemd unit, backup cron), follow [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) end to end.
|
||||
|
||||
### 2.1 Create a Supabase project
|
||||
## Operational checklist
|
||||
|
||||
1. Go to [supabase.com](https://supabase.com) → **New project**
|
||||
2. Choose a region close to your users
|
||||
3. Set a strong database password and save it — you won't need it directly but keep it safe
|
||||
4. Wait ~2 minutes for provisioning
|
||||
|
||||
### 2.2 Apply the schema
|
||||
|
||||
1. In the Supabase dashboard, go to **SQL Editor**
|
||||
2. Click **New query**
|
||||
3. Paste the entire contents of `scripts/schema.sql`
|
||||
4. Click **Run** (or Ctrl+Enter)
|
||||
|
||||
You should see no errors. The script is idempotent — safe to re-run.
|
||||
|
||||
### 2.3 Configure Auth
|
||||
|
||||
In the Supabase dashboard:
|
||||
|
||||
**Authentication → Settings → General**
|
||||
- Set **Site URL** to your app's public URL (e.g. `https://yourapp.vercel.app`)
|
||||
- Add the same URL to **Redirect URLs**
|
||||
- Also add `http://localhost:3000` for local dev
|
||||
|
||||
**Authentication → Settings → Email**
|
||||
- Enable **Confirm email** if you want verified signups
|
||||
- Customize the email templates as needed
|
||||
|
||||
**Authentication → Providers**
|
||||
- Email/Password is enabled by default — no changes needed
|
||||
- Optionally enable Google, GitHub, etc. (social login buttons are wired in the UI)
|
||||
|
||||
### 2.4 Enable Realtime
|
||||
|
||||
The schema script already runs `ALTER PUBLICATION supabase_realtime ADD TABLE ...` for `comms_messages` and `comms_channels`. Verify in:
|
||||
|
||||
**Database → Replication** — confirm both tables appear under `supabase_realtime`.
|
||||
|
||||
### 2.5 Get your API keys
|
||||
|
||||
**Project Settings → API**
|
||||
|
||||
Copy these four values — you'll need them in every deployment:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL = https://xxxxxxxxxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY = eyJhbGci...
|
||||
SUPABASE_SERVICE_ROLE_KEY = eyJhbGci... (keep this SECRET — server-side only)
|
||||
```
|
||||
|
||||
### 2.6 Create your first Space + Admin user
|
||||
|
||||
After deploying the app, navigate to `/signup`. The signup flow will:
|
||||
1. Create an `auth.users` row in Supabase
|
||||
2. Trigger `handle_space_signup()` which inserts a `space_members` row
|
||||
|
||||
To make yourself an admin, run this in the SQL editor (replace values):
|
||||
|
||||
```sql
|
||||
UPDATE public.space_members
|
||||
SET role = 'admin', status = 'current'
|
||||
WHERE email = 'your@email.com';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment Variables
|
||||
|
||||
The repo ships an `.env.example` file. Copy it locally:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Then fill in real values. For deployed environments, set the same keys in the host's dashboard.
|
||||
|
||||
Required variables:
|
||||
|
||||
| Key | Where it is used |
|
||||
|-----|------------------|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + server, in every request |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + server |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Server only. Bypasses RLS. Used by createSpace / joinSpace |
|
||||
| `NEXT_PUBLIC_APP_URL` | OAuth callback URLs |
|
||||
| `NODE_ENV` | Runtime mode |
|
||||
|
||||
Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe) are stored in the `integrations` table, not in env. The Settings UI writes them; `/api/paypal/sync` reads them.
|
||||
|
||||
`.env.local` is git-ignored. Never commit real secrets.
|
||||
|
||||
---
|
||||
|
||||
## 4. Option A — Vercel
|
||||
|
||||
The simplest and fastest path. Zero server management.
|
||||
|
||||
### 4.1 Deploy via Vercel CLI
|
||||
|
||||
```bash
|
||||
# Install Vercel CLI globally
|
||||
pnpm add -g vercel
|
||||
|
||||
# From the project root:
|
||||
vercel
|
||||
|
||||
# Follow the prompts:
|
||||
# - Link to existing project or create new
|
||||
# - Framework: Next.js (auto-detected)
|
||||
# - Root directory: ./ (default)
|
||||
```
|
||||
|
||||
### 4.2 Set environment variables
|
||||
|
||||
```bash
|
||||
vercel env add NEXT_PUBLIC_SUPABASE_URL production
|
||||
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY production
|
||||
vercel env add SUPABASE_SERVICE_ROLE_KEY production
|
||||
vercel env add NEXT_PUBLIC_APP_URL production
|
||||
```
|
||||
|
||||
Or set them in the Vercel dashboard: **Project → Settings → Environment Variables**
|
||||
|
||||
### 4.3 Deploy to production
|
||||
|
||||
```bash
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### 4.4 Connect a custom domain (optional)
|
||||
|
||||
Vercel dashboard → **Domains** → add your domain → follow DNS instructions.
|
||||
|
||||
### 4.5 Continuous deployment
|
||||
|
||||
Push to `main` branch → Vercel auto-deploys. Push to any other branch → preview deployment at a unique URL.
|
||||
|
||||
```bash
|
||||
git push origin main # triggers production deploy
|
||||
git push origin feat/my-feature # triggers preview deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Option B — DigitalOcean App Platform
|
||||
|
||||
Managed container hosting. No Docker knowledge required. Scales automatically.
|
||||
|
||||
The repo ships an App Platform spec at `.do/app.yaml`. You can either apply it directly with `doctl` or use the UI flow below.
|
||||
|
||||
### 5.1 Push your repo to GitHub
|
||||
|
||||
App Platform deploys directly from GitHub.
|
||||
|
||||
```bash
|
||||
git remote add origin https://github.com/YOUR_ORG/hackerspace-management.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 5.2a Create the app via `doctl` (recommended)
|
||||
|
||||
Edit `.do/app.yaml` and replace `REPO_OWNER/REPO_NAME` and the placeholder env values. Then:
|
||||
|
||||
```bash
|
||||
doctl apps create --spec .do/app.yaml
|
||||
```
|
||||
|
||||
### 5.2b Or create via the UI
|
||||
|
||||
1. Go to [cloud.digitalocean.com/apps](https://cloud.digitalocean.com/apps)
|
||||
2. Click **Create App** then **GitHub**, authorize and select your repo
|
||||
3. Select branch: `main`
|
||||
4. App Platform auto-detects Next.js. Confirm the build settings:
|
||||
- **Build command**: `corepack enable && pnpm install --frozen-lockfile && pnpm build`
|
||||
- **Run command**: `pnpm start`
|
||||
- **HTTP port**: `3000`
|
||||
- **Health check path**: `/api/health`
|
||||
|
||||
### 5.3 Set environment variables
|
||||
|
||||
In the App Platform UI, go to **Settings → App-Level Environment Variables** and add:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL = (your value)
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY = (your value)
|
||||
SUPABASE_SERVICE_ROLE_KEY = (mark as Encrypted)
|
||||
NEXT_PUBLIC_APP_URL = https://your-app.ondigitalocean.app
|
||||
NODE_ENV = production
|
||||
```
|
||||
|
||||
Mark `SUPABASE_SERVICE_ROLE_KEY` as **Encrypted** so it's not visible in logs.
|
||||
|
||||
### 5.4 Choose a plan
|
||||
|
||||
- **Basic** ($5/mo): fine for small hackerspaces (<50 members)
|
||||
- **Professional** ($12/mo): recommended for production — includes more RAM and 99.99% SLA
|
||||
|
||||
### 5.5 Deploy
|
||||
|
||||
Click **Create Resources** → wait ~3 minutes for the first build.
|
||||
|
||||
### 5.6 Custom domain
|
||||
|
||||
App Platform dashboard → **Settings → Domains** → add domain → update your DNS with the CNAME provided.
|
||||
|
||||
### 5.7 Continuous deployment
|
||||
|
||||
App Platform auto-deploys on every push to `main` by default. You can also configure it to deploy only on tagged releases.
|
||||
|
||||
---
|
||||
|
||||
## 6. Option C — DigitalOcean Droplet
|
||||
|
||||
Full self-hosted on a VPS. Maximum control. Use this if you want everything on your own infrastructure.
|
||||
|
||||
### 6.1 Create a Droplet
|
||||
|
||||
1. Go to [cloud.digitalocean.com/droplets](https://cloud.digitalocean.com/droplets)
|
||||
2. **Create Droplet**
|
||||
- Image: **Ubuntu 24.04 LTS**
|
||||
- Plan: **Basic — Regular — $12/mo** (2 vCPU, 2GB RAM) minimum; $24/mo recommended
|
||||
- Datacenter: closest to your users
|
||||
- Authentication: SSH key (strongly recommended over password)
|
||||
3. Note the Droplet IP address
|
||||
|
||||
### 6.2 Initial server setup
|
||||
|
||||
SSH in and harden the server:
|
||||
|
||||
```bash
|
||||
ssh root@YOUR_DROPLET_IP
|
||||
|
||||
# Create a non-root user
|
||||
adduser deploy
|
||||
usermod -aG sudo deploy
|
||||
|
||||
# Copy SSH key to new user
|
||||
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
|
||||
|
||||
# Switch to deploy user
|
||||
su - deploy
|
||||
```
|
||||
|
||||
### 6.3 Install Docker and Docker Compose
|
||||
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker deploy
|
||||
|
||||
# Install Docker Compose plugin
|
||||
sudo apt-get install -y docker-compose-plugin
|
||||
|
||||
# Verify
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### 6.4 Install Nginx and Certbot
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 6.5 Clone the repo on the server
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
sudo git clone https://github.com/YOUR_ORG/hackerspace-management.git hackerspace
|
||||
sudo chown -R deploy:deploy /opt/hackerspace
|
||||
cd /opt/hackerspace
|
||||
```
|
||||
|
||||
### 6.6 Use the bundled Dockerfile and Compose file
|
||||
|
||||
Both files ship in the repo root:
|
||||
|
||||
```
|
||||
Dockerfile (multi-stage build, standalone output, healthcheck)
|
||||
docker-compose.yml (service definition, env_file, healthcheck)
|
||||
.dockerignore
|
||||
```
|
||||
|
||||
You do not need to write your own. The Dockerfile sets `DOCKER_BUILD=1` during build so `next.config.mjs` emits standalone output. The compose file reads `.env.local` by default; for production swap that to `.env.production` (next step) or edit the compose file inline.
|
||||
|
||||
### 6.7 Create the production env file
|
||||
|
||||
```bash
|
||||
cp /opt/hackerspace/.env.example /opt/hackerspace/.env.production
|
||||
nano /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
Fill in the real Supabase URL, anon key, service role key, and your app URL. Then lock the file:
|
||||
|
||||
```bash
|
||||
chmod 600 /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
Update `docker-compose.yml` to point at `.env.production` instead of `.env.local`, or symlink:
|
||||
|
||||
```bash
|
||||
ln -sf .env.production /opt/hackerspace/.env.local
|
||||
```
|
||||
|
||||
### 6.8 Build and start the container
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Verify health endpoint
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### 6.11 Configure Nginx as reverse proxy
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/hackerspace
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable and test:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/hackerspace /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6.12 SSL with Let's Encrypt
|
||||
|
||||
Point your domain's A record to the Droplet IP first, then:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
|
||||
```
|
||||
|
||||
Certbot will auto-configure HTTPS and set up auto-renewal. Verify renewal works:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### 6.13 Set up automatic deployments (optional)
|
||||
|
||||
Create a deploy script:
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/deploy.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /opt/hackerspace
|
||||
git pull origin main
|
||||
docker compose build app
|
||||
docker compose up -d --no-deps app
|
||||
docker image prune -f
|
||||
echo "Deploy complete: $(date)"
|
||||
EOF
|
||||
chmod +x /opt/hackerspace/deploy.sh
|
||||
```
|
||||
|
||||
To trigger a deploy from your local machine:
|
||||
|
||||
```bash
|
||||
ssh deploy@YOUR_DROPLET_IP '/opt/hackerspace/deploy.sh'
|
||||
```
|
||||
|
||||
Or set up a GitHub Actions workflow:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy to Droplet
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DROPLET_SSH_KEY }}
|
||||
script: /opt/hackerspace/deploy.sh
|
||||
```
|
||||
|
||||
Add `DROPLET_IP` and `DROPLET_SSH_KEY` to your GitHub repo's **Settings → Secrets**.
|
||||
|
||||
### 6.14 Firewall
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Nginx Full'
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Post-Deploy Checklist
|
||||
|
||||
Run through this after every fresh deployment:
|
||||
|
||||
- [ ] App loads at your domain over HTTPS
|
||||
- [ ] `GET /api/health` returns `{ "status": "ok" }`
|
||||
- [ ] `/signup` creates a user and redirects to `/dashboard`
|
||||
- [ ] Dashboard renders without errors (counts may be zero)
|
||||
- [ ] Supabase dashboard, Authentication > Users shows the signup
|
||||
- [ ] `space_members` table has a row for the new user, with `role = 'admin'` for the first user (`createSpace` sets this)
|
||||
- [ ] Chat page loads the three default channels (`general`, `announcements`, `ops`)
|
||||
- [ ] Send a message and watch it appear in another tab without refresh (verifies realtime)
|
||||
- [ ] Settings page saves without error
|
||||
- [ ] PayPal integration accepts credentials and shows the connected badge
|
||||
|
||||
---
|
||||
|
||||
## 8. Upgrading
|
||||
|
||||
When the schema changes, a new numbered script is added to `scripts/` (for example `scripts/014_member_user_id_nullable.sql`). On an existing database, run only the new script in the Supabase SQL editor.
|
||||
|
||||
```
|
||||
scripts/
|
||||
schema.sql fresh deployment, full schema
|
||||
001_create_schema.sql historical, not used for fresh deploys
|
||||
...
|
||||
014_member_user_id_nullable.sql most recent incremental
|
||||
```
|
||||
|
||||
`scripts/schema.sql` is also idempotent: it uses `IF NOT EXISTS` for tables and indexes, `DROP POLICY IF EXISTS` before recreating policies, and `EXCEPTION WHEN duplicate_object` for enums and realtime publications. Re-running it on an existing database is safe but unnecessary.
|
||||
|
||||
For Vercel and App Platform: push to `main`, the build auto-deploys.
|
||||
|
||||
For Droplet: `ssh deploy@IP '/opt/hackerspace/deploy.sh'`
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
### "relation does not exist" errors
|
||||
|
||||
The schema was not applied or was partially applied. Re-run `scripts/schema.sql` in the Supabase SQL editor.
|
||||
|
||||
### Users can sign up but see no data
|
||||
|
||||
The `handle_space_signup` trigger is not firing or the `space_id` metadata was not passed during signup. Check:
|
||||
|
||||
```sql
|
||||
SELECT * FROM public.space_members ORDER BY joined_at DESC LIMIT 5;
|
||||
```
|
||||
|
||||
If empty, ensure a `spaces` row exists first:
|
||||
|
||||
```sql
|
||||
INSERT INTO public.spaces (name, slug, invite_code)
|
||||
VALUES ('My Hackerspace', 'my-hackerspace', 'INVITE123');
|
||||
```
|
||||
|
||||
Then re-run signup with the space's `id` in the metadata.
|
||||
|
||||
### Chat messages not appearing in realtime
|
||||
|
||||
Verify realtime is enabled:
|
||||
|
||||
```sql
|
||||
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
Both `comms_messages` and `comms_channels` must appear. If not:
|
||||
|
||||
```sql
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
```
|
||||
|
||||
### 500 errors on API routes
|
||||
|
||||
Check that `SUPABASE_SERVICE_ROLE_KEY` is set in your deployment environment. This key is required for server-side actions.
|
||||
|
||||
### Docker container exits immediately
|
||||
|
||||
```bash
|
||||
docker compose logs app
|
||||
```
|
||||
|
||||
Most common cause: missing `.env.production` file or a missing required env var.
|
||||
|
||||
### SSL certificate not renewing
|
||||
|
||||
```bash
|
||||
sudo systemctl status certbot.timer
|
||||
sudo journalctl -u certbot
|
||||
```
|
||||
|
||||
If the timer is not running: `sudo systemctl enable --now certbot.timer`
|
||||
- Monitor `/var/log/hackerspace-deploy.log` for deploy outcomes.
|
||||
- Verify backups by restoring `pg_*.sql.gz` to a staging instance quarterly.
|
||||
- Rotate the Supabase service-role key and JWT secret if the `.env` on the Droplet is ever exposed.
|
||||
- Keep the host OS patched. The bootstrap installs `unattended-upgrades`.
|
||||
|
||||
+5
-7
@@ -74,7 +74,7 @@ A comprehensive member management platform for hackerspaces, makerspaces, and co
|
||||
| Database | Supabase PostgreSQL |
|
||||
| Auth | Supabase Auth |
|
||||
| Real-time | Supabase Realtime |
|
||||
| Hosting | Vercel |
|
||||
| Hosting | Self-hosted DigitalOcean Droplet (Docker, Caddy) |
|
||||
|
||||
---
|
||||
|
||||
@@ -174,11 +174,9 @@ All database access is protected by RLS policies:
|
||||
|
||||
## Deployment
|
||||
|
||||
### Vercel
|
||||
### DigitalOcean Droplet (production)
|
||||
|
||||
1. Connect GitHub repository
|
||||
2. Set environment variables in Vercel dashboard
|
||||
3. Deploy automatically on push to main
|
||||
See [DEPLOYMENT.md](./DEPLOYMENT.md) for the at-a-glance flow and [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) for the end-to-end provisioning procedure. Every push to `main` triggers GitHub Actions, which runs the deploy script on the Droplet over SSH.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
@@ -205,10 +203,10 @@ Migrations must be run manually via Supabase SQL Editor:
|
||||
|
||||
Add console logs in server actions:
|
||||
```typescript
|
||||
console.log('[v0] User:', user?.id, 'Space:', member?.space_id)
|
||||
console.log('[app] User:', user?.id, 'Space:', member?.space_id)
|
||||
```
|
||||
|
||||
View logs in Vercel Function logs or terminal.
|
||||
View logs with `journalctl -u hackerspace-app -f` on the Droplet, or in the dev server terminal locally.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Webhooks
|
||||
|
||||
Webhooks let an external service react to events happening inside a space (a new member, a payment recorded, a proposal opened, an incident filed, etc). Each space has one webhook endpoint and one signing secret.
|
||||
|
||||
> Status: the webhook envelope, signing secret rotation, and the verification contract documented here are stable. Per-event delivery, retry policy with backoff, and the delivery history UI are tracked in `docs/AUDIT.md` and `docs/PRODUCTION_AUDIT.md` and may land in a follow-up release.
|
||||
|
||||
## Configuring the endpoint
|
||||
|
||||
1. Sign in as a space admin.
|
||||
2. Go to **Settings → API → Webhooks**.
|
||||
3. Paste your endpoint URL into the **Endpoint URL** field. The endpoint must be HTTPS and reachable from the internet. Local development URLs (e.g. `ngrok` tunnels) are fine for testing.
|
||||
4. Save.
|
||||
5. Click **Show secret** to reveal the signing secret, then **Copy** to copy it. Store it in your receiver's environment, never in source control.
|
||||
6. To rotate the secret, click **Rotate**. A new value is generated and shown immediately. The previous secret stops verifying as soon as you rotate.
|
||||
|
||||
## Payload envelope
|
||||
|
||||
Every delivery is a JSON `POST` with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "evt_01HZ8KQ8X1T2W3Y4Z5A6B7C8D9",
|
||||
"type": "payment.recorded",
|
||||
"created_at": "2026-05-14T22:00:00Z",
|
||||
"space_id": "8f3a...",
|
||||
"data": {
|
||||
"...": "event-specific payload"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `id` — a stable identifier you can use for de-duplication. Format: ULID prefixed with `evt_`.
|
||||
- `type` — dotted event type. Current vocabulary: `member.created`, `member.updated`, `member.removed`, `payment.recorded`, `payment.failed`, `proposal.created`, `proposal.closed`, `incident.created`, `policy.published`.
|
||||
- `created_at` — ISO 8601 UTC.
|
||||
- `space_id` — UUID of the space that owns the event.
|
||||
- `data` — event-specific payload. See the per-type schema in `docs/API_REFERENCE.md`.
|
||||
|
||||
## Signing and verification
|
||||
|
||||
Every request includes two headers:
|
||||
|
||||
```
|
||||
X-Hackerspace-Timestamp: 1747257600
|
||||
X-Hackerspace-Signature: t=1747257600,v1=<hex-hmac-sha256>
|
||||
```
|
||||
|
||||
The signature is computed as:
|
||||
|
||||
```
|
||||
v1 = HMAC_SHA256(secret, f"{timestamp}.{raw_request_body}")
|
||||
```
|
||||
|
||||
To verify a delivery, your receiver should:
|
||||
|
||||
1. Read the raw request body before any JSON parsing.
|
||||
2. Extract the `t` and `v1` values from `X-Hackerspace-Signature`.
|
||||
3. Reject the request if `|now - t| > 300` seconds (replay protection).
|
||||
4. Compute the expected signature using your stored secret.
|
||||
5. Compare with a constant-time comparison.
|
||||
|
||||
### Node.js example
|
||||
|
||||
```ts
|
||||
import crypto from "node:crypto";
|
||||
import express from "express";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.post(
|
||||
"/webhooks/hackerspace",
|
||||
express.raw({ type: "application/json" }),
|
||||
(req, res) => {
|
||||
const header = req.header("X-Hackerspace-Signature") ?? "";
|
||||
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
|
||||
const t = Number(parts.t);
|
||||
const sig = parts.v1;
|
||||
|
||||
if (!t || !sig) return res.status(400).end();
|
||||
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end();
|
||||
|
||||
const payload = `${t}.${req.body.toString("utf8")}`;
|
||||
const expected = crypto
|
||||
.createHmac("sha256", process.env.HACKERSPACE_WEBHOOK_SECRET!)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
const ok =
|
||||
sig.length === expected.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||
|
||||
if (!ok) return res.status(401).end();
|
||||
|
||||
const event = JSON.parse(req.body.toString("utf8"));
|
||||
// handle event
|
||||
res.status(204).end();
|
||||
}
|
||||
);
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
### Python example
|
||||
|
||||
```python
|
||||
import hmac, hashlib, time, os
|
||||
from flask import Flask, request, abort
|
||||
|
||||
app = Flask(__name__)
|
||||
SECRET = os.environ["HACKERSPACE_WEBHOOK_SECRET"].encode()
|
||||
|
||||
@app.post("/webhooks/hackerspace")
|
||||
def handle():
|
||||
header = request.headers.get("X-Hackerspace-Signature", "")
|
||||
parts = dict(p.split("=") for p in header.split(","))
|
||||
t = int(parts.get("t", "0"))
|
||||
sig = parts.get("v1", "")
|
||||
|
||||
if abs(time.time() - t) > 300:
|
||||
abort(401)
|
||||
|
||||
body = request.get_data()
|
||||
payload = f"{t}.".encode() + body
|
||||
expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
abort(401)
|
||||
|
||||
event = request.get_json(force=True)
|
||||
# handle event
|
||||
return "", 204
|
||||
```
|
||||
|
||||
## Retries and backoff
|
||||
|
||||
A delivery is considered successful when the receiver returns any 2xx status within 10 seconds. Non-2xx responses or timeouts trigger up to five retries with exponential backoff (15s, 1m, 5m, 30m, 2h). After the fifth failed attempt the delivery is dropped and logged on the **Webhooks → Recent deliveries** panel.
|
||||
|
||||
> The delivery history panel and retry policy editor are scheduled for a follow-up release. Until then, retries follow the fixed policy above.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Webhook deliveries originate from your Droplet's outbound IP. Allowlist that IP if your receiver enforces source restrictions.
|
||||
- The signing secret is stored encrypted at rest in `public.integrations` (per space). Rotating it invalidates the previous value immediately.
|
||||
- Webhook configuration is per space. Multi-space tenants must configure each space individually.
|
||||
@@ -11,7 +11,7 @@ async function waitForPageLoad(page: Page) {
|
||||
async function checkNoConsoleErrors(page: Page): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error' && !msg.text().includes('[v0]')) {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,9 +14,10 @@ const ADMIN_ONLY = ['admin'] as const
|
||||
export async function updateSpaceSettings(updates: {
|
||||
name?: string
|
||||
slug?: string
|
||||
city?: string
|
||||
city?: string | null
|
||||
require_approval?: boolean
|
||||
public_member_directory?: boolean
|
||||
mission_statement?: string | null
|
||||
}) {
|
||||
const v = parseInput(updateSpaceSettingsSchema, updates)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// 'standalone' bundles the app into a self-contained output for Docker deployments.
|
||||
// On Vercel this setting is ignored — Vercel handles its own output format.
|
||||
output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
|
||||
|
||||
typescript: {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
-34
@@ -98,9 +98,6 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.49.4
|
||||
version: 2.99.0
|
||||
'@vercel/analytics':
|
||||
specifier: 1.6.1
|
||||
version: 1.6.1(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.24(postcss@8.5.6)
|
||||
@@ -1916,32 +1913,6 @@ packages:
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
'@vercel/analytics@1.6.1':
|
||||
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
|
||||
peerDependencies:
|
||||
'@remix-run/react': ^2
|
||||
'@sveltejs/kit': ^1 || ^2
|
||||
next: '>= 13'
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
svelte: '>= 4'
|
||||
vue: ^3
|
||||
vue-router: ^4
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
vue-router:
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -6050,11 +6021,6 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@vercel/analytics@1.6.1(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||
optionalDependencies:
|
||||
next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.11)(lightningcss@1.31.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
Reference in New Issue
Block a user