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
49 changes: 35 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ to action scope.

## Architecture: Concepts That Span Multiple Modules

UIs and the Telegram bot are thin clients over an API server composed of deep modules. Two architectural
rules are load-bearing and easy to violate if you only read one module:
UIs are thin clients over an API server composed of deep modules; Telegram is an outbound notification
channel only. Two architectural rules are load-bearing and easy to violate if you only read one module:

1. **The Request Workflow Module is the single source of truth for state transitions.** Telegram callbacks,
the web admin panel, and the guest-admin app must all route through the *same* workflow service — never
mutate `HardwareRequest.status` directly. The Telegram module in particular must call the workflow
module, not the database. This is what keeps web and bot behavior consistent and audited.
1. **The Request Workflow Module is the single source of truth for state transitions.** The web admin
panel, the guest-admin app and the `/control/` review page must all route through the *same* workflow
service — never mutate `HardwareRequest.status` directly. **Chat is not a decision surface**: the
Telegram accept/reject buttons and their callback route were removed, so there is no bot path into the
state machine to keep consistent any more. Re-introducing one means going through the workflow module,
never the database.

2. **The Inventory Availability Module owns all quantity math.** Reserve / issue / return / mark-lost all
flow through it. No other module computes available/reserved/issued counts. The invariant "availability
Expand All @@ -60,9 +62,10 @@ rules are load-bearing and easy to violate if you only read one module:
Manager are both retired** — handover is a custom role, and `print_manager` survives only as the frozen
legacy fallback in `_MEMBERSHIP_ROLE_ACTIONS` (migrations and enum archaeology under **Handover roles**
in `docs/INVARIANTS.md`). Inventory Manager is membership-only and covers the full hardware lifecycle but
not printing, staff, or makerspace settings. Also verifies Telegram actors and blocks
restricted/suspended users. Interface: `can(actor, action, resource)`,
`scope_by_makerspace(actor, query)`, `assertTelegramActorCan(...)`.
not printing, staff, or makerspace settings. Also blocks restricted/suspended users. Interface:
`can(actor, action, resource)`, `scope_by_makerspace(actor, query)`. (It no longer verifies Telegram
actors — that went with the callback route. `assertTelegramActorCan` never existed in the code at all;
this line asserted it for months.)
- **Request Workflow** — owns the state machine, emits audit logs, triggers Telegram alerts, coordinates
inventory reservation/issue/return.
- **Inventory Availability** — quantity math + asset status for QR-tracked tools.
Expand All @@ -72,8 +75,9 @@ rules are load-bearing and easy to violate if you only read one module:
- **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is
no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no
external verify dependency left to fail safe on.
- **Telegram Integration** — sends per-makerspace group alerts and processes accept/reject callbacks
(delegating to Request Workflow).
- **Telegram Integration** — sends per-makerspace group alerts. **Outbound only.** The webhook route is
retained but accept-and-ignores every callback, because a deployment that already ran `setWebhook` would
otherwise have Telegram retry a 404 for hours; no chat message may carry an inline keyboard.

## Request State Machine

Expand Down Expand Up @@ -198,6 +202,19 @@ starting a build.** These are the rules you must not violate without having read
`./scripts/dev-local.sh test` with `spaceworks-db` (:5433), `spaceworks-redis` (:6379) and
`spaceworks-minio` (:9200) up. **Never run two `pytest` procs against one DB**, and never run the full
suite concurrently with `codex review` (it runs its own).
- **`tests/backup` and `tests/tenant_migration` need a pg client whose MAJOR equals the server's (16),
and the host may not have one.** `postgres_client.client_binary` resolves
`/usr/lib/postgresql/{major}/bin` (Debian/PGDG) or `/usr/pgsql-{major}/bin` (RHEL) and otherwise
fails closed — so on Arch, where neither path exists and `/usr/bin/pg_dump` is whatever `pacman`
ships, every one of those tests refuses with `PostgresClientUnavailable`. That is the environment,
**not a regression**. Run them in Docker instead, and note the DATABASE_URL override: the backend
container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot
build a test database as itself.

```bash
./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \
-T backend pytest tests/backup tests/tenant_migration -q
```
- **Chain every new migration off the ACTUAL leaf** — `ls backend/apps/<app>/migrations/`, never the number
a spec quotes.
- **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask
Expand All @@ -212,8 +229,11 @@ starting a build.** These are the rules you must not violate without having read

```bash
./scripts/dev-docker.sh up -d --build # default: all in Docker, live reload
./scripts/dev-docker.sh exec backend pytest
./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host fallback: faster pytest
./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite

# In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB.
./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \
-T backend pytest
```

Public inventory page: `http://localhost:5000/m/makerspace`. API: `http://localhost:8000/api` — Swagger UI
Expand Down Expand Up @@ -243,7 +263,8 @@ Stack (in use):
- **API documentation:** drf-spectacular / OpenAPI (snapshot `frontend/openapi-schema.json` + generated
`frontend/src/generated/api.ts`; regenerate both when routes/models change — spectacular needs
`--format openapi-json`).
- **Telegram:** request alerts, test alerts, authenticated webhook accept/reject callbacks.
- **Telegram:** request alerts and test alerts. Outbound only — the webhook acknowledges and discards
callbacks; decisions are made in the staff console or `/control/`.

## Current source map — in `docs/SOURCE-MAP.md`

Expand Down
49 changes: 35 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ to action scope.

## Architecture: Concepts That Span Multiple Modules

UIs and the Telegram bot are thin clients over an API server composed of deep modules. Two architectural
rules are load-bearing and easy to violate if you only read one module:
UIs are thin clients over an API server composed of deep modules; Telegram is an outbound notification
channel only. Two architectural rules are load-bearing and easy to violate if you only read one module:

1. **The Request Workflow Module is the single source of truth for state transitions.** Telegram callbacks,
the web admin panel, and the guest-admin app must all route through the *same* workflow service — never
mutate `HardwareRequest.status` directly. The Telegram module in particular must call the workflow
module, not the database. This is what keeps web and bot behavior consistent and audited.
1. **The Request Workflow Module is the single source of truth for state transitions.** The web admin
panel, the guest-admin app and the `/control/` review page must all route through the *same* workflow
service — never mutate `HardwareRequest.status` directly. **Chat is not a decision surface**: the
Telegram accept/reject buttons and their callback route were removed, so there is no bot path into the
state machine to keep consistent any more. Re-introducing one means going through the workflow module,
never the database.

2. **The Inventory Availability Module owns all quantity math.** Reserve / issue / return / mark-lost all
flow through it. No other module computes available/reserved/issued counts. The invariant "availability
Expand All @@ -60,9 +62,10 @@ rules are load-bearing and easy to violate if you only read one module:
Manager are both retired** — handover is a custom role, and `print_manager` survives only as the frozen
legacy fallback in `_MEMBERSHIP_ROLE_ACTIONS` (migrations and enum archaeology under **Handover roles**
in `docs/INVARIANTS.md`). Inventory Manager is membership-only and covers the full hardware lifecycle but
not printing, staff, or makerspace settings. Also verifies Telegram actors and blocks
restricted/suspended users. Interface: `can(actor, action, resource)`,
`scope_by_makerspace(actor, query)`, `assertTelegramActorCan(...)`.
not printing, staff, or makerspace settings. Also blocks restricted/suspended users. Interface:
`can(actor, action, resource)`, `scope_by_makerspace(actor, query)`. (It no longer verifies Telegram
actors — that went with the callback route. `assertTelegramActorCan` never existed in the code at all;
this line asserted it for months.)
- **Request Workflow** — owns the state machine, emits audit logs, triggers Telegram alerts, coordinates
inventory reservation/issue/return.
- **Inventory Availability** — quantity math + asset status for QR-tracked tools.
Expand All @@ -72,8 +75,9 @@ rules are load-bearing and easy to violate if you only read one module:
- **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is
no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no
external verify dependency left to fail safe on.
- **Telegram Integration** — sends per-makerspace group alerts and processes accept/reject callbacks
(delegating to Request Workflow).
- **Telegram Integration** — sends per-makerspace group alerts. **Outbound only.** The webhook route is
retained but accept-and-ignores every callback, because a deployment that already ran `setWebhook` would
otherwise have Telegram retry a 404 for hours; no chat message may carry an inline keyboard.

## Request State Machine

Expand Down Expand Up @@ -198,6 +202,19 @@ starting a build.** These are the rules you must not violate without having read
`./scripts/dev-local.sh test` with `spaceworks-db` (:5433), `spaceworks-redis` (:6379) and
`spaceworks-minio` (:9200) up. **Never run two `pytest` procs against one DB**, and never run the full
suite concurrently with `codex review` (it runs its own).
- **`tests/backup` and `tests/tenant_migration` need a pg client whose MAJOR equals the server's (16),
and the host may not have one.** `postgres_client.client_binary` resolves
`/usr/lib/postgresql/{major}/bin` (Debian/PGDG) or `/usr/pgsql-{major}/bin` (RHEL) and otherwise
fails closed — so on Arch, where neither path exists and `/usr/bin/pg_dump` is whatever `pacman`
ships, every one of those tests refuses with `PostgresClientUnavailable`. That is the environment,
**not a regression**. Run them in Docker instead, and note the DATABASE_URL override: the backend
container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot
build a test database as itself.

```bash
./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \
-T backend pytest tests/backup tests/tenant_migration -q
```
- **Chain every new migration off the ACTUAL leaf** — `ls backend/apps/<app>/migrations/`, never the number
a spec quotes.
- **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask
Expand All @@ -212,8 +229,11 @@ starting a build.** These are the rules you must not violate without having read

```bash
./scripts/dev-docker.sh up -d --build # default: all in Docker, live reload
./scripts/dev-docker.sh exec backend pytest
./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host fallback: faster pytest
./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite

# In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB.
./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \
-T backend pytest
```

Public inventory page: `http://localhost:5000/m/makerspace`. API: `http://localhost:8000/api` — Swagger UI
Expand Down Expand Up @@ -243,7 +263,8 @@ Stack (in use):
- **API documentation:** drf-spectacular / OpenAPI (snapshot `frontend/openapi-schema.json` + generated
`frontend/src/generated/api.ts`; regenerate both when routes/models change — spectacular needs
`--format openapi-json`).
- **Telegram:** request alerts, test alerts, authenticated webhook accept/reject callbacks.
- **Telegram:** request alerts and test alerts. Outbound only — the webhook acknowledges and discards
callbacks; decisions are made in the staff console or `/control/`.

## Current source map — in `docs/SOURCE-MAP.md`

Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ cannot be removed. **Default** means it is on when you install without choosing
| | [`member_accounts`](docs/MODULES.md#member_accounts) | | | Member sign-up and member sign-in |
| **Notifications** | [`notifications`](docs/MODULES.md#notifications) | | | The in-app inbox |
| | [`email`](docs/MODULES.md#email) | | | Outbound email |
| | [`telegram`](docs/MODULES.md#telegram) | | | Telegram alerts and accept/reject buttons |
| | [`telegram`](docs/MODULES.md#telegram) | | | Telegram group alerts (outbound only) |
| | [`slack`](docs/MODULES.md#slack) | | | Slack alerts |
| | [`mattermost`](docs/MODULES.md#mattermost) | | | Mattermost alerts |
| | [`discord`](docs/MODULES.md#discord) | | | Discord alerts |
Expand Down Expand Up @@ -321,6 +321,26 @@ People still get named, two ways:
It is enough to issue them a tool, register them for an event or run a machine job for them, and it
keeps every handover attributable to a real person, which the hardware rules require.

#### Who may ask to borrow something

Borrow requests are only ever a *proposal* — staff still accept them, and staff acceptance is what
reserves stock — so who may submit one is a separate switch from who may sign in. There are three
states, and you never set them both:

| Member accounts | Account-less requests | Who may submit |
| --- | --- | --- |
| on | off | **Members** — an active member of that makerspace |
| off | off | **Account holders** — anyone signed in (the default without the module) |
| off | **on** | **Anyone** — no account at all |

The last row is opt-in per makerspace, and turning **Member accounts** on turns it back off: enabling
membership means asking for membership, so a stranger must not still walk past it. Account-less
submissions ask for a name, email and phone, require an `Idempotency-Key`, and are rate-limited per IP
and per email address; every one of them is recorded against a single shared requester principal, which
is deliberately excluded from every per-person ranking so a hundred strangers never add up to one
fictional "top borrower". `setup.sh` asks this question during first-run setup, and it can be changed
later with `manage.py set_request_access`.

### Choosing which ways in you offer

**`/control/` → Platform login methods** switches the four credential kinds independently: password,
Expand Down Expand Up @@ -354,8 +374,8 @@ Two things worth knowing before you set this up:
- **Webhook URLs are write-only.** They are stored encrypted and never shown again, so an edit that
only renames a room can leave the field blank.
- **Telegram rooms share the makerspace's bot** — add the same bot to each group and paste each
group's chat ID. That is what keeps the accept/reject buttons working, since Telegram sends every
button press back to one address.
group's chat ID. Delivery is outbound only: chat is not a decision surface, so no alert carries
accept/reject buttons and decisions are made in the staff console or `/control/`.

A makerspace that has added no rooms keeps using the single webhook under **Chat webhooks**, exactly
as before. Nothing changes until you add your first room.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.5
0.8.0
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ PUBLIC_IMAGE_URL_TTL_SECONDS=300
# Public request workflow throttle scopes.
THROTTLE_REQUEST_SUBMIT=10/min
THROTTLE_REQUEST_STATUS=60/min
THROTTLE_PUBLIC_REQUEST_SUBMIT=10/min
THROTTLE_ANONYMOUS_REQUEST_IP_BURST=2/min
THROTTLE_ANONYMOUS_REQUEST_IP_HOUR=10/hour
THROTTLE_ANONYMOUS_REQUEST_EMAIL=3/day
ANONYMOUS_REQUEST_OUTSTANDING_LIMIT=50
ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH=128

# Email defaults use the console backend for local development.
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
Expand Down
48 changes: 38 additions & 10 deletions backend/apps/accounts/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from rest_framework.exceptions import APIException

from apps.accounts.models import NativeAppRegistration, User
from apps.accounts.principal_guards import (
refuse_anonymous_requester_access_mutation,
refuse_anonymous_requester_credential,
)
from apps.accounts.transition_services import (
WalkInTransitionError,
transition_walk_in_to_account,
Expand Down Expand Up @@ -78,6 +82,17 @@ class UserAdmin(SuperuserOnlyModelAdmin, DjangoUserAdmin, ModelAdmin):
def user_change_password(self, request, id, form_url=""):
"""Make the inherited password form an explicit account transition."""
user = self.get_object(request, id)
if request.method == "POST" and user is not None:
try:
refuse_anonymous_requester_credential(user)
except APIException as exc:
self.message_user(request, _api_exception_message(exc), level=messages.ERROR)
return HttpResponseRedirect(
reverse(
f"{self.admin_site.name}:{user._meta.app_label}_{user._meta.model_name}_change",
args=(user.pk,),
)
)
if (
request.method != "POST"
or user is None
Expand Down Expand Up @@ -171,16 +186,28 @@ def restrict_access(self, request, queryset):
status = form.cleaned_data["status"]
reason = form.cleaned_data["reason"]
for user in queryset:
user.access_status = status
user.restriction_reason = reason
user.save(update_fields=["access_status", "restriction_reason"])
audit.record(
request.user,
"user.access_restricted",
target=user,
meta={"status": user.access_status, "reason": user.restriction_reason},
)
success_count += 1
try:
refuse_anonymous_requester_access_mutation(user)
except APIException as exc:
self.message_user(
request,
f"{user.username}: {_api_exception_message(exc)}",
level=messages.ERROR,
)
else:
user.access_status = status
user.restriction_reason = reason
user.save(update_fields=["access_status", "restriction_reason"])
audit.record(
request.user,
"user.access_restricted",
target=user,
meta={
"status": user.access_status,
"reason": user.restriction_reason,
},
)
success_count += 1

self.message_user(
request,
Expand All @@ -196,6 +223,7 @@ def restore_access(self, request, queryset):
try:
with transaction.atomic():
locked = User.objects.select_for_update().get(pk=user.pk)
refuse_anonymous_requester_access_mutation(locked)
if locked.access_status != User.AccessStatus.ACTIVE and locked.is_active:
memberships = MakerspaceMembership.objects.select_related(
"makerspace"
Expand Down
Loading
Loading