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:
Moheeb Zara
2026-05-14 16:28:16 -07:00
parent b8cd36abc3
commit 8ad1ca44b7
27 changed files with 662 additions and 720 deletions
+1 -3
View File
@@ -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
+31
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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.
+86 -49
View File
@@ -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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org)
[![Supabase](https://img.shields.io/badge/Supabase-self--hosted-3FCF8E.svg)](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
View File
@@ -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.
+7 -7
View File
@@ -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' ? (
+36
View File
@@ -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('*')
+10 -8
View File
@@ -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 ? (
+2 -2
View File
@@ -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 (
+1 -1
View File
@@ -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'
+88 -35
View File
@@ -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
View File
@@ -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>
-3
View File
@@ -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>
)
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
---
+145
View File
@@ -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.
+1 -1
View File
@@ -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())
}
})
+2 -1
View File
@@ -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 }
+1 -1
View File
@@ -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
View File
@@ -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: {
-1
View File
@@ -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",
-34
View File
@@ -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