Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
dist
.git
.github
tests
*.db
attachments/
.env
.env.local
coverage/
101 changes: 101 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Release

# Runs on every push to main except the version-bump commit this workflow
# produces, preventing an infinite loop.
on:
push:
branches: [main]

jobs:
release:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
permissions:
contents: write # push version bump + tag, create release
packages: write # push Docker image to ghcr.io

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so we can inspect commits since last tag

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

# Inspect commit messages since the last tag (or all commits if no tag
# yet) and pick a semver bump level using conventional commits:
# BREAKING CHANGE or ! suffix → major
# feat: → minor
# anything else → patch
- name: Determine version bump
id: semver
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")

if [ -z "$LATEST_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s%n%b")
else
COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s%n%b")
fi

BUMP="patch"
while IFS= read -r line; do
if echo "$line" | grep -qE "BREAKING[- ]CHANGE|^[a-z]+(\(.+\))?!:"; then
BUMP="major"
break
elif echo "$line" | grep -qE "^feat(\(.+\))?:"; then
if [ "$BUMP" = "patch" ]; then BUMP="minor"; fi
fi
done <<< "$COMMITS"

echo "bump=$BUMP" >> "$GITHUB_OUTPUT"

- name: Bump version in package.json
id: version
run: |
NEW_VERSION=$(npm version ${{ steps.semver.outputs.bump }} --no-git-tag-version)
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.new_version }}
ghcr.io/${{ github.repository }}:latest

# Commit the package.json bump, tag it, and push. Requires that the
# github-actions bot is allowed to push to main (configure via branch
# protection → "Allow specific actors to bypass required pull requests").
- name: Commit, tag, and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
git commit -m "chore: release ${{ steps.version.outputs.new_version }}"
git tag "${{ steps.version.outputs.new_version }}"
git push origin main --follow-tags

- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.version.outputs.new_version }}" \
--title "${{ steps.version.outputs.new_version }}" \
--generate-notes
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Contributing

## Development setup

```sh
cp .env.example .env
# Edit .env — set OWNER_TOKEN and ENTITY_ID at minimum
pnpm install
pnpm dev
```

Run the full check suite before opening a PR:

```sh
pnpm typecheck
pnpm lint
pnpm format:check
pnpm test
```

All four run in CI and failures block merge.

## Commit style

This project uses [Conventional Commits](https://www.conventionalcommits.org/). Every commit message must start with a type prefix. The release workflow reads commit messages since the last tag to determine the version bump automatically:

| Commit type | Example | Version bump |
| ------------------------------------------------------------------------ | ----------------------------------- | ------------ |
| `feat:` | `feat: add token expiry` | minor |
| `feat!:` or `BREAKING CHANGE` footer | `feat!: rename /entity to /owner` | major |
| Anything else (`fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`) | `fix: return 404 on missing record` | patch |

A scope is optional: `fix(attachments): reject empty filename`.

Getting the type wrong produces the wrong version bump with no warning, so when in doubt use `fix:` for patches and `feat:` only for genuinely new capabilities.

## Pull requests

- One concern per PR. Split unrelated changes.
- The PR description should explain _why_, not just _what_ — the diff already shows what changed.
- Link to any relevant issue with `Closes #N`.
- Keep PRs small enough to review in one sitting; large refactors are fine but flag them early.
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# deps: install production dependencies only
FROM node:22-slim AS deps
RUN npm install -g pnpm@10
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

# builder: compile TypeScript
FROM node:22-slim AS builder
RUN npm install -g pnpm@10
WORKDIR /app
COPY package.json pnpm-lock.yaml tsconfig.json tsconfig.build.json ./
RUN pnpm install --frozen-lockfile
COPY src ./src
RUN pnpm build

# runner: minimal production image
FROM node:22-slim AS runner
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=deps --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/package.json ./
RUN mkdir -p /app/data && chown app:app /app/data
VOLUME ["/app/data"]
ENV NODE_ENV=production
ENV DB_PATH=/app/data/stack.db
EXPOSE 3000
USER app
CMD ["node", "dist/index.js"]
108 changes: 29 additions & 79 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,92 +21,35 @@ The server listens on `PORT` (default `3000`). On first run it initializes a new

---

## Configuration

All configuration is via environment variables. See `.env.example` for the full list.

| Variable | Required | Default | Description |
| ---------------------- | --------- | ------------------ | -------------------------------------------------------- |
| `OWNER_TOKEN` | Yes | — | Bearer token for the stack owner. Treat like a password. |
| `ENTITY_ID` | First run | — | Owner entity ID. Only needed when initializing a new DB. |
| `DB_PATH` | No | `./stack.db` | Path to the SQLite database file. |
| `PORT` | No | `3000` | Port to listen on. |
| `TIMEZONE` | No | `UTC` | IANA timezone. Only used on first run. |
| `CORS_ORIGINS` | No | `*` | Allowed origins, comma-separated or `*`. |
| `BASE_URL` | No | auto-detected | Canonical base URL of this server. |
| `MAX_ATTACHMENT_BYTES` | No | `52428800` (50 MB) | Maximum attachment upload size. |
## Docker

---
```sh
docker run -d \
-e OWNER_TOKEN=<secret> \
-e ENTITY_ID=<your-entity-id> \
-p 3000:3000 \
-v haverstack-data:/app/data \
ghcr.io/haverstack/server:latest
```

## API

All routes are prefixed by the base URL. Requests are authenticated with a `Bearer` token in the `Authorization` header.

### Discovery

| Method | Path | Auth | Description |
| ------ | -------------------- | ---- | ------------------------------- |
| GET | `/.well-known/stack` | None | Stack metadata and capabilities |
| GET | `/health` | None | Liveness check |

### Records

| Method | Path | Auth | Description |
| ------ | -------------------------------- | -------- | --------------------------------------- |
| GET | `/records` | Optional | Query records via URL params |
| POST | `/records/query` | Optional | Query records with content filters |
| POST | `/records` | Required | Create a record |
| GET | `/records/:id` | Optional | Get a record by ID |
| PATCH | `/records/:id` | Required | Update record content (merge patch) |
| DELETE | `/records/:id` | Required | Soft-delete (or hard with `?hard=true`) |
| GET | `/records/:id/permissions` | Optional | Get permissions |
| PUT | `/records/:id/permissions` | Required | Replace permissions |
| GET | `/records/:id/associations` | Optional | List associations |
| POST | `/records/:id/associations` | Required | Add an association |
| DELETE | `/records/:id/associations` | Required | Remove an association |
| GET | `/records/:id/versions` | Optional | List version history |
| GET | `/records/:id/versions/:version` | Optional | Get a specific version |
| POST | `/records/:id/restore/:version` | Required | Restore a previous version |

### Types

| Method | Path | Auth | Description |
| ------ | ------------ | ---------- | ------------------------- |
| GET | `/types` | None | List all registered types |
| GET | `/types/:id` | None | Get a type by ID |
| POST | `/types` | Owner only | Register a new type |

### Attachments

| Method | Path | Auth | Description |
| ------ | ---------------------- | ---------- | --------------- |
| POST | `/attachments` | Required | Upload a file |
| GET | `/attachments/:fileId` | Optional | Download a file |
| DELETE | `/attachments/:fileId` | Owner only | Delete a file |

### Entity & Tokens

| Method | Path | Auth | Description |
| ------ | ------------- | ---------- | ------------------------------ |
| GET | `/entity` | Required | Get the owner entity record |
| PATCH | `/entity` | Owner only | Update the owner entity record |
| GET | `/tokens` | Owner only | List API tokens |
| POST | `/tokens` | Owner only | Create an API token |
| DELETE | `/tokens/:id` | Owner only | Revoke an API token |
`/app/data` holds the SQLite database and attachments — mount a volume there for persistence. Set `ENTITY_ID` only on first run; it is ignored once the database exists.

---

## Permissions

Records are private by default (readable only by the stack owner). The `permissions` field controls access:
## Configuration

```json
{ "access": "public" }
{ "access": "entity", "entityId": "...", "read": true, "write": false }
{ "access": "group", "groupId": "...", "read": true, "write": true }
```
All configuration is via environment variables. See `.env.example` for the full list.

Non-owner entities authenticate with tokens issued via `POST /tokens` and are subject to both record-level permissions and create-grant checks on write.
| Variable | Required | Default | Description |
| ---------------------- | --------- | -------------------------------------------- | -------------------------------------------------------- |
| `OWNER_TOKEN` | Yes | — | Bearer token for the stack owner. Treat like a password. |
| `ENTITY_ID` | First run | — | Owner entity ID. Only needed when initializing a new DB. |
| `DB_PATH` | No | `/app/data/stack.db` (Docker) / `./stack.db` | Path to the SQLite database file. |
| `PORT` | No | `3000` | Port to listen on. |
| `TIMEZONE` | No | `UTC` | IANA timezone. Only used on first run. |
| `CORS_ORIGINS` | No | `` (none) | Allowed origins, comma-separated or `*`. |
| `BASE_URL` | No | auto-detected | Canonical base URL of this server. |
| `MAX_ATTACHMENT_BYTES` | No | `52428800` (50 MB) | Maximum attachment upload size. |

---

Expand All @@ -123,6 +66,13 @@ pnpm format:check # Check formatting

---

## Docs

- [API reference](./docs/api.md) — routes, auth, and permissions
- [Deployment guide](./docs/deployment.md) — TLS, CORS, and rate limiting

---

## Related

- [`haverstack/core`](https://github.com/haverstack/core) — core library, types, adapters, and spec
Expand Down
67 changes: 67 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# API Reference

All routes are prefixed by the base URL. Requests are authenticated with a `Bearer` token in the `Authorization` header.

## Discovery

| Method | Path | Auth | Description |
| ------ | -------------------- | ---- | ------------------------------- |
| GET | `/.well-known/stack` | None | Stack metadata and capabilities |
| GET | `/health` | None | Liveness check |

## Records

| Method | Path | Auth | Description |
| ------ | -------------------------------- | -------- | --------------------------------------- |
| GET | `/records` | Optional | Query records via URL params |
| POST | `/records/query` | Optional | Query records with content filters |
| POST | `/records` | Required | Create a record |
| GET | `/records/:id` | Optional | Get a record by ID |
| PATCH | `/records/:id` | Required | Update record content (merge patch) |
| DELETE | `/records/:id` | Required | Soft-delete (or hard with `?hard=true`) |
| GET | `/records/:id/permissions` | Optional | Get permissions |
| PUT | `/records/:id/permissions` | Required | Replace permissions |
| GET | `/records/:id/associations` | Optional | List associations |
| POST | `/records/:id/associations` | Required | Add an association |
| DELETE | `/records/:id/associations` | Required | Remove an association |
| GET | `/records/:id/versions` | Optional | List version history |
| GET | `/records/:id/versions/:version` | Optional | Get a specific version |
| POST | `/records/:id/restore/:version` | Required | Restore a previous version |

## Types

| Method | Path | Auth | Description |
| ------ | ------------ | ---------- | ------------------------- |
| GET | `/types` | None | List all registered types |
| GET | `/types/:id` | None | Get a type by ID |
| POST | `/types` | Owner only | Register a new type |

## Attachments

| Method | Path | Auth | Description |
| ------ | ---------------------- | ---------- | --------------- |
| POST | `/attachments` | Required | Upload a file |
| GET | `/attachments/:fileId` | Optional | Download a file |
| DELETE | `/attachments/:fileId` | Owner only | Delete a file |

## Entity & Tokens

| Method | Path | Auth | Description |
| ------ | ------------- | ---------- | ------------------------------ |
| GET | `/entity` | Required | Get the owner entity record |
| PATCH | `/entity` | Owner only | Update the owner entity record |
| GET | `/tokens` | Owner only | List API tokens |
| POST | `/tokens` | Owner only | Create an API token |
| DELETE | `/tokens/:id` | Owner only | Revoke an API token |

## Permissions

Records are private by default (readable only by the stack owner). The `permissions` field controls access:

```json
{ "access": "public" }
{ "access": "entity", "entityId": "...", "read": true, "write": false }
{ "access": "group", "groupId": "...", "read": true, "write": true }
```

Non-owner entities authenticate with tokens issued via `POST /tokens` and are subject to both record-level permissions and create-grant checks on write.
Loading
Loading