Addresses findings from the security audit:
- H1: add check_project_role to the legacy misc.py create endpoints
(milestones=mgr, tasks/supports/meetings=dev) that previously required
only authentication — closing a cross-project write bypass available to
any logged-in user or agent API key.
- M2: comments are always attributed to the authenticated caller; the
client-supplied author_id is dropped (no author spoofing).
- M3: API keys are stored as SHA-256 hashes (key_hash) plus a short
key_prefix for display — never plaintext. Lookup hashes the presented
key; listings never expose the secret. Includes an idempotent migration
for existing deployments.
- M5: the OIDC session cookie's Secure flag is env-driven via
SESSION_COOKIE_SECURE (default True; set false for plain-HTTP dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lifts the two hard restrictions in PR #18:
* window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
* maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
((maint_to - maint_from) mod 1440)
## Schema migration (additive)
`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.
## Touched surfaces
- `models/schedule_type.py` — column comments updated; new
`compute_maintenance_duration()` helper (returns 1-1440 min, treats
from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
`_validate_maintenance_window` accepts 1-180min duration; response
includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
pair (partial updates can put the row into an invalid combo the
pydantic single-field validator can't catch); `_attach_derived`
populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
hour→min; the maintenance-window guard error message formats
HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
`scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
with hour/minute split
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## What this adds
1. **Maintenance window on ScheduleType**
- New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
- Invariant: window is exactly 1 hour (validated in pydantic;
maintenance_to must equal (maintenance_from + 1) % 24)
- Default applied via additive migration: 8:00-9:00 UTC for existing
rows so deployments don't crash on first boot
2. **ScheduleTypeSpecialSlot** — admin-managed slot template
- New table schedule_type_special_slots
- Admin (schedule_type.manage) CRUD via
/schedule-types/{id}/special-slots
- Fields: name, description, minute_in_window (0-59 inside the
parent maintenance window), estimated_duration, priority,
event_data (JSON merged into materialised slot), is_active
- Unique constraint (schedule_type_id, name) — name is the stable
human-readable identifier per cohort
3. **Per-agent materialisation**
- New service app/services/special_slot_materialiser.py
- GET /calendar/sync calls materialise_special_slots_for_claw
(idempotent, one row per agent per template per date)
- GET /calendar/day calls materialise_special_slots_for_user
- Materialised rows are slot_type=system, event_type=system_event,
is_admin_locked=true, special_slot_id pointing back to template
- Plugin's runSync picks them up like any other due slot via the
normal real-slots query path
4. **Admin-locked enforcement**
- New TimeSlot columns: is_admin_locked, special_slot_id (FK to
schedule_type_special_slots, ON DELETE SET NULL)
- PATCH /calendar/slots/{id}: refuses any edit on admin-locked
slots (423)
- POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
(423)
- PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
ongoing/paused/finished/aborted statuses (423 on other transitions)
5. **Maintenance-window guard on slot creation**
- POST /calendar/slots: rejects slot_type=system outright (only
materialiser may create system slots) and rejects any non-system
slot whose [scheduled_at, +duration] intersects the calling
user's schedule_type maintenance window (422). Handles 23->0 wrap
6. **Schema response**
- TimeSlotResponse / CalendarSlotItem now include is_admin_locked
and special_slot_id so clients can render the lock indicator and
trace back to the template
## Migration
Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In OIDC-only mode, before any admin is linked, an IdP user whose token
carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE /
oidc_settings.admin_role) auto-connects to the unbound hf admin on
first OIDC sign-in, then the window self-closes once any admin is
bound. Roles are scanned across userinfo + the (unverified) access
token: realm_access.roles, resource_access.*.roles, roles/role/groups.
Adds admin_role to settings model/env/effective/API and to the wizard
bootstrap config. Replaces the manual admin-subject approach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist OIDC config in a single-row oidc_settings table; non-empty DB
fields override the OIDC_* env vars (env = bootstrap default). The
Authlib client is rebuilt when config changes.
- GET/PUT /auth/oidc/settings — admin only, via JWT OR API key. The
API-key path is the recovery channel when OIDC-only mode is on and
OIDC is misconfigured (avoids total lockout).
- client_secret is write-only: never returned (has_client_secret bool),
preserved when the field is left blank on update.
- /auth/config, login/link/callback now use the effective (DB|env)
config so enabling OIDC needs no redeploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
issues the existing HS256 JWT on success. Unbound identities are
rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
JWT or API key; 409 on conflict). Self-link /auth/oidc/link
(non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
SessionMiddleware for OIDC state. Fixed _user_response to expose
the new binding fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add DayOfWeek and MonthOfYear enums for plan period parameters
- Add SchedulePlan model with at_time/on_day/on_week/on_month fields
- Add DB-level check constraints enforcing hierarchy:
on_month requires on_week, on_week requires on_day
- Add application-level @validates for on_week range (1-4),
on_month hierarchy, and estimated_duration (1-50)
- Add is_active flag for soft-delete (plan-cancel)
- Add bidirectional relationship between SchedulePlan and TimeSlot
- All existing tests pass (29/29)
- Updated model docstring with full deprecation strategy
- Updated column comment to mark as deprecated (BE-PR-010)
- Updated schema/router comments for deprecation clarity
- Added deprecation doc: docs/BE-PR-010-feat-task-id-deprecation.md
- feat_task_id superseded by Task.source_proposal_id (BE-PR-008)
- Add source_proposal_id and source_essential_id FK columns to Task model
- Populate tracking fields during Proposal Accept task generation
- Add generated_tasks relationship on Proposal model for reverse lookup
- Expose source_proposal_id/source_essential_id in TaskResponse schema
- Add GeneratedTaskBrief schema and include generated_tasks in ProposalDetailResponse
- Proposal detail endpoint now returns generated story tasks with status
- Add comprehensive docstring to Proposal model documenting all relationships
- Add column comments for all fields (title, description, status, project_id, etc.)
- Mark feat_task_id as DEPRECATED (will be replaced by Essential->task mapping in BE-PR-008)
- Add proposal_code hybrid property as preferred alias for DB column propose_code
- Update ProposalResponse schema to include proposal_code alongside propose_code
- Update serializer to emit both proposal_code and propose_code for backward compat
- No DB migration needed -- only Python-level changes
- New canonical model: Proposal, ProposalStatus (app/models/proposal.py)
- New canonical router: /projects/{id}/proposals (app/api/routers/proposals.py)
- Schemas renamed: ProposalCreate, ProposalUpdate, ProposalResponse, etc.
- Old propose.py and proposes.py kept as backward-compat shims
- Legacy /proposes API still works (delegates to /proposals handlers)
- DB table name (proposes), column (propose_code), and permission names
(propose.*) kept unchanged for zero-migration compat
- Updated init_wizard.py comments
- New dedicated meetings.py router with full CRUD (list/get/create/update/delete)
- All endpoints accept meeting_code or numeric id
- MeetingParticipant model for tracking meeting attendance
- POST /meetings/{id}/attend adds current user to participant list
- Serialization includes participants list, project_code, milestone_code
- Creator auto-added as participant on meeting creation
- Registered in main.py alongside existing routers
- add users.role_id for one global role per account
- seed protected account-manager role with account.create permission
- default new accounts to guest role
- block admin role assignment through user management
- allow account-manager permission to create accounts
- accept nginx installation status and sites-enabled list
- persist nginx fields in server state
- expose nginx data in monitor overview/admin views
- auto-migrate new server_states columns on startup
- Add server_states.plugin_version column
- Keep openclaw_version for remote OpenClaw runtime version
- Expose plugin_version in monitor server view
- Accept and persist plugin_version in heartbeat payloads
- Add api_key field to MonitoredServer model with unique index
- Add migration to create api_key column
- Add POST /admin/servers/{id}/api-key for key generation
- Add DELETE /admin/servers/{id}/api-key for key revocation
- Add POST /server/heartbeat-v2 with X-API-Key header auth
- TelemetryPayload includes load_avg and uptime_seconds
SQLAlchemy 2.0 defaults to mapping Python enum *names* (OPEN, CLOSED)
to DB values, but MySQL stores lowercase *values* (open, closed).
This mismatch causes LookupError on read.
Adding values_callable=lambda x: [e.value for e in x] tells SQLAlchemy
to use the enum values for DB mapping.
Affected models: milestone, task, meeting, propose, support
- New Propose model (app/models/propose.py) with status enum (open/accepted/rejected)
- New Propose schemas (ProposeCreate/Update/Response) in schemas.py
- MySQL enum migration in main.py for milestone/task status columns
- milestone: pending→open, deferred→closed, progressing→undergoing
- task: progressing→undergoing
- Import propose model in startup for create_all
- Add started_at column migration for milestones
Milestone: open/freeze/undergoing/completed/closed (was open/pending/deferred/progressing/closed)
Task: open/pending/undergoing/completed/closed (was open/pending/progressing/closed)
- Add MilestoneStatusEnum to schemas with typed validation
- Add started_at field to Milestone model
- Update all router/CLI references from progressing->undergoing
- Add completed status handling in task transition logic