Files
Moheeb Zara 8ad1ca44b7 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.
2026-05-14 16:28:16 -07:00

5.1 KiB

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:

{
  "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

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

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.