mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
6a37512227
* test(store): pin the events/1 taxonomy as an independent copy (TASK-2714)
TestCanonicalEventsAreFullyDeclared iterated kernelevents.Canonical() and
asserted each entry resolved something non-empty. That check cannot fail for
any table the compiler accepts: eventSpec requires both fields, so a corrupted
table — an entry deleted, an entry added, item.deleted quietly rebased onto the
ref-only payload — passed its own validation. A test that agrees with whatever
the table says is not a test of the table.
The sixteen name/subject/family triples are now written out as literals, so the
test DISAGREES with the table when the table moves. The wire strings behind the
name constants are pinned separately, because the triple map is keyed on
literals and a renamed constant would otherwise slip through as long as the
constant and the table moved together.
Ordered as this unit's first commit because TASK-2714 edits that table (the
handler-path bulk mapping): an independent copy earns its keep at the moment of
the edit, not before.
Mutation matrix, 4/4 caught: drop member.joined (17 -> 15 count mismatch and a
missing-name error), rehome item.deleted onto ref_only (family mismatch),
rename ItemMoved's wire string to item.move (constant leg), add an undeclared
item.frobnicated entry (count + undeclared-name + non-canonical legs). The
fourth reported "survived" on its first run because the sed never matched the
table's alignment — the mutation was verified present in the file before the
result was believed.
TASK-2714 requirement 4 (lead pass on #1172).
* feat(store): max-age prune for undispatched outbox rows (TASK-2714)
Requirement 3's missing half. PruneDispatchedOutbox filters on dispatched_at
IS NOT NULL, so a row that can never be delivered — a workspace whose only
webhook was deleted, an endpoint that 4xxs forever — is unreachable by it and
keeps its frozen payload indefinitely.
That matters because SPEC-3 makes payload privacy TEMPORAL. An outbox payload
is a frozen snapshot and account deletion's de-identify posture reaches only
live rows, so the retention window is the whole privacy claim; a window only
one of its two halves can close is not a window.
The trade is stated in the doc comment rather than left to be inferred:
at-least-once holds WITHIN the retention window and not past it, which is why
the caller's max-age must be far larger than any retry schedule. Deleting
rather than stamping the rows dispatched is deliberate — a dispatched stamp
would be a lie in the durable record, and this table is the only evidence of
what the kernel emitted.
Mutation matrix, 2/2 caught: drop the dispatched_at IS NULL clause (prunes the
aged DISPATCHED row too, handing retention two owners with different windows),
drop the occurred_at cutoff (prunes a young pending row a retry is still
owed). The test asserts its own premise — all three seeded rows are confirmed
present before the survivor checks, which would otherwise pass for a reason
unrelated to the prune.
No caller yet: the drain loop wires it up in the next commit.
* feat(events): derive SSE names from the taxonomy; retire item.updated_with_comment (TASK-2714)
SPEC-3 §"the choke point owns the canonical→surface name mapping". SSE's
snake_case vocabulary and the webhook dot-form vocabulary drifted because
nothing tied them together — each was hand-passed at its own call sites. This
ties them.
v1.5 pins what "derive" means: NAME derivation, not delivery path. SSE stays
direct-published at the mutation site, because it carries request-scoped
attribution (Actor / ActorName / Source) that a frozen outbox payload
deliberately does not hold; only its NAME now comes from the taxonomy. Moving
SSE behind the drain is TASK-2722.
- eventSpec gains an `sse` field — ONE table, not a second map, for the reason
round 11 of the last unit established: a separate map can disagree with the
first and fails open exactly when it matters. Empty is a real value (attachment,
member and pack events have no SSE surface) and SurfaceSSE reports false for it,
so silence can't be mistaken for a name.
- Several canonical events derive the SAME SSE name — status_changed and moved
both surface as item_updated — because the SSE vocabulary is coarser than
events/1 and the UI never distinguished them. The finer name is what the
webhook wire and bindings get.
- The 12 canonical SSE publish sites take their names from derived package vars,
resolved AT INIT. Every call site is a compile-time constant, so a missing
surface is a startup panic rather than a per-request decision between "log and
drop" and "publish under an empty name".
- handlers_item_links.go keeps the events.ItemUpdated literal, commented: link
mutations are silent in events/1 (v1.5), so there is no canonical name to
derive from. TASK-2723 carries link.created / link.removed.
- item.updated_with_comment retired (v1.2, Dave's ruling). One producer deleted;
the events.ItemUpdatedWithComment constant deleted with it — it had no producer
and no web consumer (grepped .go/.ts/.svelte), so leaving it would leave a name
a future publisher could reach for.
The compat guard is what makes this a refactor rather than a wire change:
TestDerivedSSENamesMatchTheLegacyWireVocabulary asserts each derived name equals
the events.* constant clients are pinned to. A derivation producing
"item.created" or "item_deleted" would break the live UI while every other Go
test still passed.
Mutation matrix, 3/3 caught: rename item.deleted's SSE surface to item_deleted
(both the taxonomy test and the compat guard fail), split item.moved onto its own
SSE name (same), make SurfaceSSE return (spec.sse, ok) so no-surface events fail
open (the taxonomy test's silence leg names all four). Running total 9/9.
go test ./internal/server ./internal/store ./internal/events: all green.
* feat(webhooks): synchronous DeliverEvent seam with per-endpoint outcome (TASK-2714)
Requirements 1 and 2. Dispatch returns once its per-hook goroutines are
spawned and reports nothing, so a drain built on it would stamp rows
dispatched while the HTTP requests were still in flight — losing exactly the
events the outbox exists to make unlosable. DeliverEvent blocks and tallies.
- Delivery carries WorkspaceID / EventID / Event / OccurredAt / Payload.
OccurredAt is the EVENT's timestamp, not dispatch time: SPEC-3 pins
time-relative binding predicates to it, so stamping time.Now() would make
every consumer's notion of when a mutation happened depend on how backed up
the queue was. Payload is json.RawMessage — []byte would base64 the snapshot
into a string that is valid JSON and completely unusable.
- WebhookPayload gains ID, the consumer dedupe key SPEC-3 §Delivery guarantees
already told consumers to use. Before this, that instruction named a field
nobody could see. omitempty, because the "webhook.test" ping is not a kernel
event, has no outbox row, and must not invent an id.
- DeliveryOutcome counts rather than a status, because one event fans out to N
endpoints and the answers differ. Three distinctions the drain branches on:
Matched==0 is SUCCESS (a webhook-less workspace is owed nothing; reading it
as undelivered would back up every event in every such workspace until
retention deleted it); Permanent does not hold the event pending (re-sending
to an endpoint that will reject it again costs the queue its progress);
Transient does. Retryable() states the ack rule once instead of letting each
caller re-derive it.
- A returned error is reserved for the SERVER's failures — listing hooks,
marshalling. Those must not ack: nothing was attempted, so the event is
still owed in full.
- Dispatch keeps its async shape for its one remaining caller and says so.
deliver() now returns the outcome it always computed; the async path
discards it.
Mutation matrix, 6/6 caught: stamp dispatch time instead of occurred_at; drop
the envelope id; pass the payload as []byte (base64); deliver asynchronously
and assume success (the synchronous leg names it exactly); count a permanent
rejection as transient; swallow a store failure into a zero outcome (the test
prints the outcome that would have acked an undelivered event).
Running total 15/15. go test ./internal/webhooks green.
* feat(store): batch_id correlation for handler-path bulk mutations (TASK-2714)
F2's write half. A lane-wide bulk action is a handler LOOP over per-item store
mutations with no enclosing transaction, so each member writes its own
canonical outbox row — which is what keeps SPEC-3's per-member binding
evaluation free, and also means that without a marker the drain would put 200
item.deleted events on the webhook wire for a 200-item lane archive: exactly
the flood TASK-1668's batch event exists to prevent.
RECORDED, NEVER INFERRED (SPEC-3 v1.5). The schema-free alternative was
grouping pending rows by workspace and a time window, which would fold two
unrelated single updates into somebody's bulk event whenever they landed in
the same tick. A wire event saying "these five items changed together" is only
true if something recorded that they did.
- migrations 082 / pgmigrations 060: nullable event_outbox.batch_id, no FK
(a batch is not a row anywhere, it is a name the handler minted), plus a
partial index on the pending set.
- store.MutationOption / WithEventBatch: variadic, because every existing call
site is a single-item mutation with nothing to declare and making all of them
pass a zero value would bury the one case that matters.
- The handler mints one id per bulk OPERATION, before the loop and
unconditionally — deciding mid-loop whether a run "counts as" a batch would
make the correlation depend on how far the loop got.
POPULATION CORRECTED: my escalation said four store methods; it is FIVE.
archive (DeleteItem), restore (RestoreItem), move (MoveItemWithPreCheck), field
update (UpdateItemWithPreCheck) and assign (UpdateItem) are the complete set of
mutating store calls handlers_items_bulk.go makes — restore was the one I
missed, which is CONVE-18's exact lesson arriving one level up. The test drives
all five rather than sampling, because the failure is per-method: a signature
that accepts the option and never threads it compiles, passes everything else,
and silently un-batches one of the six bulk verbs.
Mutation matrix, 5/5 caught across the four distinct emit sites: drop the stamp
on the update path (both Update legs fail), on delete, on restore, on move. The
delete mutation first read as SURVIVED — it had made the package fail to BUILD
(opt then unused), and the grep for test-level FAIL lines printed nothing. The
compiler catch is the stronger result, but the instrument mis-reported it, so
it was re-run with opt kept alive and the test named it directly.
go test ./internal/store ./internal/server green.
* test(server): anchor the SSE compat guard to the client's literal strings (TASK-2714)
The guard compared the derivation against events.* — the Go side. A
coordinated rename of the taxonomy AND the constants passes that, and is
exactly the change that breaks the browser: the client is pinned to the
STRINGS, in web/src/lib/services/sse.svelte.ts's ITEM_EVENTS.
The wanted column is now a literal copy of what the client listens for, with
the file named. events.* is asserted alongside as a second leg, so a drift
between the Go constants and the client is attributed rather than merely
reported. Same disagree-with-the-table principle as the taxonomy test, one
layer out: this file has to be edited by hand when the wire vocabulary
intentionally changes, and that edit is when someone goes and changes the
client too.
Mutation matrix, 2/2, each hitting only its own leg: rename events.ItemCreated
to the dot-form with the taxonomy untouched (drift leg fires), and make the
taxonomy publish the dot-form on SSE (browser leg fires). Running total 22/22.
Lead's catch on the day-49 review of commit 33662da0.
* feat(store): outbox claim protocol with lease and whole-batch claiming (TASK-2714)
F3. Every instance of a cloud deployment runs the drain, so an unclaimed
pending row is delivered once PER INSTANCE by construction. SPEC-3 permits
duplicates — consumers dedupe on the event id — but "occasionally, after a
crash" and "always, once per instance" are different promises, and only the
first is one a consumer can budget for.
- migrations 083 / pgmigrations 061: claimed_at + claimed_by, dialect-uniform
conditional UPDATE (BUG-2415's orphan-GC protocol). Postgres FOR UPDATE SKIP
LOCKED plus a separate SQLite path would be two implementations of one
behaviour, only one of which runs where it matters.
- claimed_at doubles as the lease: an instance that dies between claiming and
dispatching must not strand its rows, and at-least-once is exactly what makes
re-claiming safe.
- BATCHES ARE CLAIMED WHOLE, past the limit. The limit is a throughput knob;
letting it split a batch would make one bulk operation arrive as two wire
events each reporting a partial member count.
- MarkOutboxAttemptFailed RELEASES the claim rather than letting it expire. A
transient failure means the event is owed and nothing is in flight; on a
single-instance deployment the lease would otherwise be the only reason a
retry ever waited.
THE EXCLUSIVITY TEST WAS VACUOUS AND THE MATRIX CAUGHT IT. Removing the
availability predicate from the claim UPDATE left it green: the candidate query
already filters held rows, so single-threaded the end state is identical
(CONVE-12 — another mechanism produces it). That implementation double-claims
every row two instances select in the same moment, which is the entire bug.
claimOutboxIDs is now split out so a test can drive the arbiter with a
deliberately STALE candidate list, and the same mutation fails it by name.
Mutation matrix, 4/4: drop the UPDATE's availability predicate (survived the
first test, named by the race test); drop the batch expansion; keep the claim
on a failed attempt; and the vacuity finding above. Running total 26/26.
go test ./internal/store green.
* feat(server): the outbox drain — claim, fold, deliver, retain (TASK-2714)
The half of SPEC-3's choke point that turns stored events into delivered ones.
2a built the fill side; until this, the table filled and nothing read it.
NOT STARTED YET, deliberately: the hand-called dispatchWebhook sites are still
in place, so wiring the loop here would double-deliver every canonical event.
Starting it is the next commit, together with deleting them — the unit's
behaviour edge, kept as one reviewable diff.
- Two declared payload shapes for item.bulk_updated (SPEC-3 v1.6). The
store-side single-tx producers know every member at write time and embed
snapshots; the handler-path HEADER knows the operation, the shared delta and
the member refs, with snapshots living on the members' own rows. Declared
rather than loosened: stuffing placeholder snapshots to satisfy a
single-shape check would be a lie in the durable record, and dropping the
gate would drop it on the one event with two producers.
- EmitBulkHeaderEvent + bulkEventDelta: the delta is captured where it is
KNOWN. By the time the drain sees member rows they carry post-mutation
snapshots, and a diff of a snapshot against nothing is not a delta.
- The fold: header plus whatever member rows of that batch are still
undispatched. Members whose header is not in this claim deliver
individually — not a fallback, the defined behaviour for the window between
the loop committing and the header landing. batch_id is on the wire so a
consumer can tie the singles to the batch.
- Per-unit acking: a folded batch is many rows and ONE delivery, so a
partially acked batch would re-deliver.
- Retention runs every tick, both halves. The undispatched one is the privacy
bound; PruneDispatchedOutbox looks like it covers retention until you notice
which rows it can never see.
TWO REAL BUGS THE TESTS FOUND, both in this commit's own code:
1. DEFAULTS APPLIED ONLY IN StartOutboxDrain. A tick reached directly ran with
a ZERO undispatched max age, making the retention cutoff `now` and deleting
the entire pending set on its first pass. Every test does this, and so
would any future admin-triggered drain. Fixed by construction — one
resolver both entry points call — with a refusal guard behind it.
2. THE GUARD'S FIRST TEST WAS VACUOUS AND PASSED WITH THE GUARD REMOVED.
RFC3339 is second-granular, so a row written in the same second as a
zero-window cutoff survives `occurred_at < cutoff` either way: the end
state was reachable by another mechanism, and that mechanism was the clock.
runOutboxRetention now returns its refusal so the test asserts the refusal
rather than the survival, plus a positive control.
Mutation matrix, 7/7 after the instrument fix: ack regardless of outcome; ack
only when something succeeded (permanent failures would wedge the queue); fold
without acking its members; drop members that have no header instead of
delivering them; remove the retention guard; remove the resolver's max-age
default. Two mutations initially read as survivors — one had failed to build,
one met the vacuous test — and both are recorded above rather than counted as
passes. Running total 33/33.
go test ./internal/server ./internal/store green.
* feat(server): deliver canonical webhooks from the drain, not from the handlers (TASK-2714)
The unit's behaviour edge, kept as its own commit. The drain starts, and the
nine remaining hand-called dispatchWebhook sites go: comment.created,
comment.updated, item.created (x2 — plain and copy), item.updated,
item.deleted (x2), item.moved, item.bulk_updated. Each was verified to have an
outbox producer before its deletion, not assumed to.
The Server.dispatchWebhook helper goes with them — it had no production
callers left. Three copy tests used it as a probe and now call
s.webhooks.Dispatch directly, which is what it did.
WHAT CHANGES ON THE WIRE, stated plainly because "no behaviour change" would
be false here:
- TIMING. Deliveries were post-commit and inline; they are now up to one drain
interval (5s default) later. In exchange a delivery survives a crash: the
event is committed with the mutation it describes.
- THE DISJOINT-DELTA RULE ARRIVES (SPEC-3 v1.3, ruled in 2a). A bare status
flip now emits item.status_changed ONLY, where the hand-call always emitted
item.updated. A mixed update emits both. This was ruled while the webhook
surface has no known consumers; it is the same grounding as the v1.2 fold.
- PAYLOADS. The envelope gains `id` (the dedupe key SPEC-3 already told
consumers to use), and `timestamp` is now the event's occurred_at rather than
dispatch time. Item snapshots come from the in-transaction read-back and are
PII-scrubbed — the joined assignee name and email are gone, deliberately
(see scrubItemPII: a frozen payload outlives account de-identification).
- item.bulk_updated carries batch_id, the shared delta, and the member
snapshots folded in from the member rows.
Two copy tests needed real changes, not cosmetic ones: the DR-14 emission
matrix they assert (which workspace hears what) is unchanged, but nothing
arrives until a drain pass runs, and the fixture's own backlog — member joins,
filler items — would otherwise be reported as the copy's output. The observer
now drains once before the receivers are registered, which is what its
"baseline" has always meant, and drainWebhooks runs a pass before collecting.
go test ./internal/server ./internal/store ./internal/webhooks green.
* fix: codex round 1 — unbatched bulk verbs, member dedup, comment overclaims (TASK-2714)
THE P1, and it is CONVE-18 for the third time in this unit: batchID was
threaded into the bulk helpers' SIGNATURES but not passed at three of the six
store CALLS (set-priority/move-status via UpdateItemWithPreCheck, tag/untag via
UpdateItem, move via MoveItemWithPreCheck). Those verbs' member rows stayed
unbatched while the header was still written — N individual wire deliveries
plus a header claiming they were a batch.
My store-level test could not see it. It called the five store methods directly
with the option, so it proved the option WORKS and said nothing about whether
the handler passes it. TestBulkItems_EveryVerbStampsOneBatchID drives all seven
legs through the HTTP handler and asserts every row of the operation shares one
non-empty batch id with exactly one header. Reverting one stamp fails it by
name (set-priority and move-status both).
Writing that test also surfaced two legs that asserted nothing: untag and
assign were no-ops in the fixture (no such tag; nothing assigned), so no member
events existed at all. Both now perform real mutations, and the leg fails if
fewer than two rows appear.
Also from round 1:
- FOLD DEDUPS MEMBERS. The disjoint-delta rule means one member can write two
or three rows (a move that also changes status emits item.moved AND
item.status_changed), so the folded payload listed the same item repeatedly
while `count` reported ITEMS — the wire event contradicting itself. Keeps the
LAST snapshot per id; an unreadable snapshot is kept rather than dropped.
- BULK MOVE DELTA carries both collection and status when both were sent.
bulkMoveCollection applies req.Status as a field override, so a
move-with-status changes two things.
- FIVE COMMENT OVERCLAIMS, all mine or inherited and all now matching the code:
the taxonomy package doc still said nothing drains the outbox; "every event
produces exactly one payload shape" predates the batch event's second shape;
two places said "the dispatcher runs item-level selectors against each member
snapshot" when no binding engine exists and the dispatcher filters on event
NAME only; my own retirement comment said this path emits "item.updated +
comment.created" transactionally, when the item half is whichever slice moved
(a status-only update emits status_changed) and the comment is a separate
transaction; migration 083 described the claim as one statement doing both
the select and the mark.
One finding recorded rather than fixed: affectedIDs counts rows TOUCHED, not
rows semantically changed, so an all-no-op operation writes a header with a
count and no members. Verified against origin/main — the webhook this replaces
fired on the identical condition with the identical count, so it is inherited,
and narrowing it is a wire change to count/item_ids that belongs with a
contract version rather than a delivery refactor.
Gates: build clean, make lint 0 issues, go test ./internal/... green,
make test-pg exit 0 / 3463 PASS / 0 FAIL with the new outbox tests verified
present in the Postgres run.
* fix: codex round 2 — batch correlation on the wire, prior_status survival (TASK-2714)
Round 2 was aimed at round 1's own fixes, and that is where both P1s were.
- BATCH_ID REACHED ONLY THE FOLDED HALF. A member delivered individually — the
window this whole design accounts for — carried an item snapshot with no
batch anywhere in it, while three comments claimed consumers could correlate
the singles with the batch. They could not. batch_id is now an ENVELOPE field
on every delivery of a batched event, singles included, which is the only
place a consumer can read it for a member.
- FOLD DEDUP COULD DROP prior_status. A mixed update writes item.status_changed
(carrying the transition) and item.updated (not); round 1's last-wins kept the
later row and silently lost the one field a "nonterminal → terminal" binding
needs, in exactly the case that produces both rows. The snapshot is still
last-wins — every field IS fresher on the later row — but prior_status is
carried forward, because it is envelope metadata only one of the two events
ever has.
- Sibling scans deduped: a 100-row candidate slice from one batch ran the same
query 100 times.
- The "only when something actually changed" comment on the bulk emission
condition is corrected rather than left to be re-derived: the condition is
that a row was TOUCHED without erroring. Untagging a tag nobody has succeeds
on every row and changes nothing, so the header fires with a count while the
store writes no member events. Same inherited asymmetry round 1 recorded;
now the comment says it where the code is.
- MY OWN COUNTS WERE WRONG IN THREE PLACES, which is the number-discipline
lesson landing on documentation instead of a report: the handler test said
"six verbs" while driving seven legs and "three of the six verbs" for what
was three CALL SITES across four verbs; the store test implied it covered the
verbs when it covers entry points, and now says out loud that it is not
sufficient alone — round 1's bug lived one layer above it.
Mutations, 2/2 on the new fixes: deliver singles with an empty batch id (the
member leg names it twice, once per member); revert the dedupe to plain
last-wins (the prior_status leg names it).
go test ./internal/... green.
* fix: codex round 3 — ack and release are conditioned on the claim (TASK-2714)
The P1, and it is round 2's area again: claim tokens were minted and never
checked. MarkOutboxDispatched and MarkOutboxAttemptFailed matched on the row id
alone, so once a lease expired, a slow pass could still reach rows a newer pass
legitimately owned — a late ack stamping a row the new holder is mid-delivery
on, and a late release CLEARING a live claim and handing the event to a third
pass.
Reachable, not theoretical: a workspace's endpoints are delivered sequentially,
each with three attempts and a 10s timeout, and the "well under a minute"
estimate behind the lease default is an estimate rather than a bound.
Both writes now carry the token and condition on claimed_by, and an empty token
is refused outright rather than matching NULL. OutboxEvent carries ClaimToken
so the drain never has to track it separately. A stale ack matches zero rows,
which is exactly right — the event has become the new claim's problem.
Also round 3, all P3:
- The fold's "embedded VERBATIM" claim now names its one exception: a deduped
survivor is re-encoded to carry prior_status across. Non-duplicate members
are untouched bytes.
- Four stale comments corrected where they live, not just where they were
introduced: migration 081 still said nothing drained the table and webhooks
fired from hand-calls; createItemChecked's summary still ended in "webhook
dispatch"; handlers_watch_notify still called publishBulkItemsEvent "the
SSE/webhook bulk path"; and two copies of the PII rationale said nothing
drains or prunes, when the window is now bounded (bounded is not zero, which
is why the scrub still does the work).
Mutations, 2/2: drop claimed_by from the ack (the stale-ack leg fires), drop it
from the release (the stale-release leg fires, naming the instance that took
the freed row). Both mutations were verified present in the file first — the
initial pair silently failed to apply and reported green, which is the third
instrument mis-report this unit.
Gates: go test ./internal/... green, make lint 0 issues.
* fix: codex round 4 — retention spares live claims, token refusal is unconditional (TASK-2714)
- RETENTION COULD DELETE A ROW MID-DELIVERY. Every instance runs retention, so
one instance's prune could remove an old undispatched row another instance
was actively delivering: the delivery would succeed while the ack matched
zero rows, and a crash in that window loses an event the outbox had already
committed. Live claims are now exempt, using the same lease predicate the
claim itself uses. An EXPIRED claim stays fair game — that is what expiry
means — and the test asserts both directions.
- THE EMPTY-TOKEN REFUSAL SAT BEHIND THE EMPTY-ID SHORT-CIRCUIT, so
MarkOutboxDispatched("", nil) returned nil: a contract that depended on the
argument it was not about. Token check first.
- The taxonomy comment claimed per-member events for ALL bulk mutations. True
only of the handler path; the store-side single-transaction producers have no
loop, and for those the snapshots INSIDE the payload are the only per-member
view there is. Both mechanisms now named, since the distinction is visible in
the payloads.
- ListPendingOutboxEvents is documented as the diagnostic reader. The drain
claims; a reader finding two pending-row queries should not have to guess
which one production uses.
Mutation, 1/1: drop the claim predicate from the prune (the new test names the
count). Verified applied before the result was read.
Round 4 also found a REGRESSION I am not fixing here because it is a fork:
create-with-parent webhooks carry a pre-link snapshot. CreateItem writes the
item.created row in its own transaction, SetParentLink runs in a separate one,
and main's hand-called webhook dispatched the RE-READ item — so the parent and
the post-link seq were visible then and are not now. Escalated with a
recommendation (emit item.updated from SetParentLink's own transaction, which
also covers the general case); it sits close enough to SPEC-3 v1.5's
link-silence ruling that it is not mine to infer.
go test ./internal/... green.
* fix: SetParentLink emits item.updated on its own transaction (TASK-2714)
Codex round 4's regression, ruled (a) by the lead with the F4 boundary made
mechanical rather than inferred (SPEC-3 v1.6): a mutation that writes the
ITEM'S OWN ROW emits item.updated; a relationship-graph link, which writes only
the links table, stays silent. A parent link advances seq and flips the
is_unparented bit, so it is on the emitting side of that line.
The regression it closes: createItemChecked calls SetParentLink AFTER
CreateItem has already committed item.created with a pre-link snapshot, then
re-reads the item for its response. Main's hand-called webhook dispatched that
re-read, so a consumer saw the parent and the post-link seq; under the drain
the frozen created row was all there was, with nothing to correct it.
created(pre-link) then updated(post-link) is a true history.
Placed in setParentLinkOnce, not in the shared setParentLinkTx:
UpdateItemWithParentLink reuses that core inside the item-update transaction
and already emits from the field diff, so the shared site would double-emit.
The snapshot comes from getItemTx, and the test enforces that rather than the
comment doing it alone — mutating the read to the pool's GetItem fails on the
seq assertion, because a different connection cannot see the uncommitted write
and would emit the pre-link row under a post-link event.
Mutations, 2/2: delete the emit (no event after linking); read the snapshot
from the pool (seq is the create's). Plus a control leg asserting the PARENT
emits nothing — the link does not write its row.
go test ./internal/... green.
* fix: codex round 5 — parent-only updates emit; the parent-emit claim is narrowed to the truth (TASK-2714)
Round 5 aimed at round 4's own fix and found two P1s in it. Four-for-four on
that angle now.
1. THE PARENT-ONLY UPDATE PATH STILL EMITTED NOTHING. SetParentLink's fix
covers its own transaction; UpdateItemWithParentLink writes the hierarchy
inside the ITEM-UPDATE transaction and emits from a snapshot DIFF — and a
parent write leaves nothing in a snapshot to diff, since items.parent_id is
legacy and untouched, the link lives in its own table, and seq/updated_at
are excluded as metadata. So a fields_patch carrying only `parent` mutated
the row and emitted zero events. The emitter now takes hierarchyChanged from
the caller, which knows what it wrote; the diff cannot see it and must not
have to. Covers set AND clear, with a control leg asserting a genuinely
empty update still emits nothing — without it the fix could be "always
emit", which would undo the disjoint-delta rule.
2. MY OWN ROUND-4 COMMENT AND TEST OVERCLAIMED. Both said the event carries a
"post-link snapshot"; the payload is the item ROW, and the parent EDGE is
not on it — IsUnparented is populated only by the local-first index
queries, so the test's is_unparented assertion passed VACUOUSLY against an
absent field. What the emit actually restores is the row change (a fresh
seq and updated_at), which is exactly what main's hand-called webhook
carried: it dispatched the handler's post-link re-read, the same scan. The
comment now says that, and the test asserts the ABSENCE so the next reader
cannot infer linkage data that has never been on this wire.
Third instance this unit of the same shape: a partial verification written
up as a complete one.
Also round 5, both comment-level:
- handlers_item_links.go said item-link mutations are silent in events/1. True
per link TYPE, not per handler: parent crosses the "writes the item's own
row" line and emits, blocks/blocked-by and implements do not. The comment
now states the criterion and names the consequence — this handler publishes
SSE for both kinds, so the SSE and events/1 pictures deliberately differ.
- EmitBulkHeaderEvent's guard said "a bulk operation that changed nothing is
not an event" while being an empty-LIST guard. Corrected in place with the
reason it stays: the webhook it replaced fired on the identical condition
with the identical count, so narrowing it is a wire change for a contract
version, not a fix.
Mutation, 1/1: drop the hierarchy force (the parent-only leg fails by name).
Verified applied before the result was read.
go test ./internal/... green.
* fix: codex round 6 — parent DETACH emits on every route (TASK-2714)
Round 6 aimed at round 5's fix and found two more in it. Five-for-five.
1. DETACH WAS SILENT ON TWO OF THREE ROUTES. Attach emitted from
SetParentLink and from the update path, but ClearParentLink (its own
transaction) and DeleteItemLink on a parent row (what DELETE /links/{id}
actually calls) wrote the item's row and emitted nothing. A consumer's
model would keep a parent the user had removed, with every attach route
observable — the worst shape for this kind of gap, because the wire looks
healthy. Routes are now enumerated in one test rather than sampled.
2. hierarchyChanged MEANT "PROVIDED", NOT "CHANGED". Clearing an
already-unparented item deletes zero rows; round 5's flag still forced
item.updated, putting an event on a public wire for a mutation that did not
happen. clearParentLinkTx now reports whether it removed a link and the flag
comes from that. The set branch stays unconditional — it is a
DELETE-then-INSERT and bumps the row either way.
IMPLEMENTS IS FLAGGED, NOT DECIDED. It bumps the same row (so the v1.6
mechanical criterion would include it) but it is a relationship-graph link (so
v1.5's silence would exclude it). The contract does not resolve that case, and
inventing an answer inside a delivery refactor is how a public wire acquires an
event nobody ruled on. Recorded at the call site and raised with the lead.
Mutations, 2/2: drop the parent-detach emit from DeleteItemLink (the route's
leg fails); treat provided as changed on the clear branch (the no-op leg fails
by name). The second mutation first failed to BUILD — `removed` then unused,
which is the compiler catching it and my grep reading the empty result as a
pass — so it was re-run with the variable kept alive. Fourth instrument
mis-report this unit; all four are on the record.
Gates: go test ./internal/... green, make lint 0 issues, make test-pg exit 0
on the pre-round-6 tip (re-run pending on the final tip).
* fix: codex round 7 — the batch delta matches the mutation (TASK-2714)
Round 7 returned no P1s; the parent-detach work from round 6 came back clean on
transaction scope, lock ordering, error paths and duplicate emissions.
- BULK DELTA REPORTED THE REQUEST, NOT THE COMMIT. bulkEventDelta echoed raw
request values while the mutation normalizes: bulkTagUpdate trims added tags
and skips ones that go empty, and the store's assignment SET clause gives a
NON-EMPTY id precedence over the clear flag (BUG-2566). So a request with
both an id and clear=true announced a clear while the row was assigned —
the delta describing the opposite of what committed. This is the one field
of the batch payload the drain cannot derive, so nothing downstream corrects
it: whatever it says is what a consumer believes. Now normalized the same
way, including untag matching RAW because the mutation removes by exact
match.
- THE implements COMMENT WAS WRONG, and it was mine from round 5: it listed
implements with the silent link types when implements DOES bump the source
row, exactly as parent does. Corrected in both places, and the case is
stated as UNRESOLVED rather than settled — the mechanical criterion (writes
the item's row) would have it emit, v1.5's relationship-link silence would
not, and deciding it inside a delivery refactor would put an event nobody
ruled on onto a public wire. With the lead.
Mutations, 2/2: give the clear flag precedence over a non-empty id (the
precedence leg fails, naming the row it would misdescribe); stop trimming
added tags (the trim leg fails). The first mutation initially failed to build
and was rewritten to compile before its result was believed.
Gates on the round-6 tip: go test ./internal/... green, make lint 0 issues,
make test-pg exit 0 / 3445 PASS / 0 FAIL with 13 of this unit's new test legs
verified present in the Postgres output. Re-run pending on the final tip.
* fix: codex round 8 — tag delta dedups, link SSE name derives (TASK-2714)
Two P2s, both small and both the same shape: a claim in a comment that the
code did not quite meet.
- THE TAG DELTA TRIMMED BUT DID NOT DEDUPE, while bulkTagUpdate does both — it
skips a tag already in its `seen` set. So tag ["foo", " foo "] added one tag
and advertised two, under a comment saying the delta is normalized "the same
way the mutation does". Round 7 fixed half of that sentence; this fixes the
other half.
- handlers_item_links.go PUBLISHED UNDER THE events.ItemUpdated LITERAL. The
wire value happens to match, which is exactly why it was worth changing: it
recreates the drift the central mapping exists to prevent, one rename away
from being wrong. The name now derives like every other SSE site, and the
comment separates the two facts a reader has to keep apart — the NAME
derives, the events/1 EVENT still does not exist for relationship links.
Mutation, 1/1: drop the `seen` check from the delta's tag loop (the dedup test
names the duplicated value).
go test ./internal/... green.
* fix: codex round 9 — untag delta dedups, version restore derives its SSE name (TASK-2714)
Both are the same shape as round 8's, one layer further out.
- THE UNTAG DELTA STILL ECHOED DUPLICATES. bulkTagUpdate builds a removal SET,
so ["foo","foo"] removes one tag; the delta advertised two. The two verbs
normalize DIFFERENTLY — tag trims and dedups, untag dedups but matches raw,
because removal is by exact string — and the delta now mirrors each side's
own rule rather than applying one of them to both.
- handlers_item_versions.go PUBLISHED A RAW "item_updated" STRING. A second
source of SSE vocabulary, and the harder kind to find: it does not even
reference the events package, so a grep for events.ItemUpdated misses it.
Now derived like every other site.
go test ./internal/... green.
Gates on the round-8 tip: make test-pg exit 0, 3447 PASS, 0 FAIL, with 150
lines of this unit's own test legs verified present in the Postgres output.
2638 lines
116 KiB
Go
2638 lines
116 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/fs"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"net/netip"
|
||
"net/url"
|
||
"os"
|
||
"runtime/debug"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||
"github.com/go-chi/cors"
|
||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||
|
||
"github.com/PerpetualSoftware/pad/internal/attachments"
|
||
"github.com/PerpetualSoftware/pad/internal/billing"
|
||
"github.com/PerpetualSoftware/pad/internal/collab"
|
||
"github.com/PerpetualSoftware/pad/internal/email"
|
||
"github.com/PerpetualSoftware/pad/internal/events"
|
||
"github.com/PerpetualSoftware/pad/internal/metrics"
|
||
"github.com/PerpetualSoftware/pad/internal/models"
|
||
"github.com/PerpetualSoftware/pad/internal/oauth"
|
||
"github.com/PerpetualSoftware/pad/internal/store"
|
||
"github.com/PerpetualSoftware/pad/internal/watchevents"
|
||
"github.com/PerpetualSoftware/pad/internal/webhooks"
|
||
)
|
||
|
||
type Server struct {
|
||
store *store.Store
|
||
router *chi.Mux
|
||
routerOnce sync.Once // ensures setupRouter runs once, after all config
|
||
httpServer *http.Server // underlying HTTP server (set during ListenAndServe)
|
||
webFS fs.FS // embedded web UI static files (optional)
|
||
events events.EventBus // real-time event bus (optional)
|
||
watchEvents watchevents.Bus // watch/nudge notification bus (optional, TASK-2533)
|
||
sessionPresence SessionPresence // live event-stream connections per user (optional, PLAN-2558 S1)
|
||
collab *collab.RoomManager // Yjs collab room manager (PLAN-1248); optional
|
||
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
|
||
email *email.Sender // transactional email sender (optional)
|
||
emailAPIKey string // Maileroo API key (used for unsubscribe HMAC)
|
||
emailEnvConfigured bool // email was wired from env vars (SetEmailSender); reconfigureEmail must not tear this down when platform settings clear the key
|
||
rateLimiters *RateLimiters // per-endpoint rate limiters
|
||
baseURL string // public base URL for generating links (e.g. invite URLs)
|
||
corsOrigins string // comma-separated CORS origins (empty = localhost defaults)
|
||
secureCookies bool // set Secure flag on cookies (for TLS deployments)
|
||
metrics *metrics.Metrics // Prometheus metrics (optional)
|
||
metricsToken string // shared bearer token for /metrics scrapes ("" = loopback-only)
|
||
trustedProxyCIDRs []*net.IPNet // CIDRs allowed to set X-Forwarded-For (nil = proxy headers untrusted)
|
||
ipChangeEnforceStrict bool // when true, revoke+reject sessions whose client IP OR User-Agent hash differs from the one recorded at session creation
|
||
sseMaxConnections int // global SSE connection limit (0 = unlimited)
|
||
sseMaxPerWorkspace int // per-workspace SSE connection limit (0 = unlimited)
|
||
cloudMode bool // true when running as Pad Cloud (PAD_CLOUD=true or PAD_MODE=cloud)
|
||
cloudSecrets []string // shared secrets for sidecar ↔ pad communication (supports rotation)
|
||
cloudSidecar CloudSidecar // reverse pad → pad-cloud client (e.g. Stripe cancel on account delete); nil = not configured
|
||
billingAvailable bool // true when PAD_BILLING_AVAILABLE=true — gates Stripe Checkout CTAs in the web UI (TASK-800)
|
||
version string // release version (e.g. "dev", "1.2.3")
|
||
commit string // git commit hash
|
||
buildTime string // build timestamp
|
||
twoFAChallengeSecret []byte // HMAC key for 2FA challenge tokens
|
||
|
||
// Attachments storage. Wired via SetAttachments at startup; nil-checked
|
||
// by handlers so a server constructed for a test that doesn't need
|
||
// uploads still compiles and serves every other endpoint.
|
||
attachments *attachments.Registry
|
||
attachmentMaxBytes int64 // per-file upload cap; 0 = use defaultAttachmentMaxBytes
|
||
|
||
// Image processor used by the upload handler to derive thumbnail
|
||
// variants (TASK-878) and by the editor's rotate / crop tools
|
||
// (TASK-879/880). Wired via SetImageProcessor; nil-checked by
|
||
// callers so a server without image processing — e.g. a self-host
|
||
// build that doesn't want the dependency — still serves every
|
||
// other endpoint and stores originals untouched.
|
||
imageProcessor attachments.Processor
|
||
|
||
// MCP Streamable HTTP transport (PLAN-943 TASK-950). Wired via
|
||
// SetMCPTransport at startup when the deployment is in cloud mode.
|
||
// nil on self-hosted deployments and on any cloud build that hasn't
|
||
// constructed the MCP server yet — registerMCPRoutes nil-checks so
|
||
// the routes don't mount in either case. See handlers_mcp.go.
|
||
mcpTransport http.Handler
|
||
mcpPublicURL string // canonical public URL of the MCP vhost (e.g. https://mcp.getpad.dev)
|
||
mcpAuthServerURL string // canonical URL of the OAuth auth server (e.g. https://app.getpad.dev), TASK-951
|
||
|
||
// MCP tool-surface descriptor source (PLAN-1888 / TASK-1891). Wired
|
||
// via SetToolSurfaceHandler at startup from mcp.ToolSurfaceJSON. The
|
||
// injection mirrors SetMCPTransport and exists for the same reason:
|
||
// internal/mcp imports internal/server (dispatch_http.go), so this
|
||
// package CANNOT import internal/mcp to build the catalog JSON
|
||
// itself. cmd/pad/main.go imports both and injects the serializer.
|
||
// nil → GET /api/v1/mcp/tool-surface returns 404 (handler not wired).
|
||
toolSurfaceJSON func() ([]byte, error)
|
||
|
||
// OAuth 2.1 authorization server (PLAN-943 TASK-1024 sub-PR B,
|
||
// HTTP handlers in TASK-1025 sub-PR C). Wired via SetOAuthServer
|
||
// at startup when the deployment is in cloud mode + has the
|
||
// fosite-backed server constructed. nil disables the OAuth
|
||
// surface — registerOAuthRoutes nil-checks so the routes don't
|
||
// mount on self-hosted deployments. See handlers_oauth.go.
|
||
oauthServer *oauth.Server
|
||
|
||
// claimSecret is the HMAC key for stateless 6-digit claim codes
|
||
// (PLAN-1519 / TASK-1521 / IDEA-1517 §4). Wired by SetClaimSecret
|
||
// at startup — production reuses the deployment's 32-byte
|
||
// encryption key (cfg.EncryptionKey) since both are server-
|
||
// stable secrets with equivalent rotation cadence. nil/short →
|
||
// /api/v1/oauth/claim returns 412 "claim_disabled" on every
|
||
// request, surfacing a clear misconfiguration signal rather than
|
||
// silently accepting forgeable codes.
|
||
claimSecret []byte
|
||
|
||
// oauthMetricsWired records whether wireOAuthMetricsObserver has
|
||
// already attached the active-tokens callback collector. Re-
|
||
// registering would panic via prometheus.MustRegister, so the flag
|
||
// guards the one-shot registration. The TTL observer side is
|
||
// idempotent (just a function-pointer set) and runs unconditionally.
|
||
oauthMetricsWired bool
|
||
|
||
// MCP audit log async writer (PLAN-943 TASK-960). Spawned by
|
||
// startMCPAuditWriter at startup when MCP is wired; shut down
|
||
// from Server.Stop. nil-safe: every audit-emitting code path
|
||
// nil-checks so MCP-less builds + tests that don't start the
|
||
// writer still work. See middleware_mcp_audit.go.
|
||
mcpAudit *mcpAuditWriter
|
||
|
||
// MCP session tracker (PLAN-943 TASK-1120). Replaces the naive
|
||
// +1/-1 active-sessions accounting from TASK-961. Wired by
|
||
// startMCPSessionTracker (called from SetMCPTransport in cloud
|
||
// mode); shut down from Server.Stop alongside the audit writer.
|
||
// nil-safe: trackMCPSession + the gauge-update path both
|
||
// nil-check so non-cloud builds + tests run without the tracker.
|
||
// See middleware_mcp_session.go.
|
||
mcpSessions *mcpSessionTracker
|
||
mcpSessionTTL time.Duration // 0 → defaultMCPSessionTTL
|
||
mcpSessionSweepInterval time.Duration // 0 → defaultMCPSessionSweepInterval
|
||
|
||
// storageInfoCache memoizes per-workspace storage usage summaries
|
||
// behind a short TTL (storageInfoTTL). Reduces DB load on the
|
||
// Settings → Storage page and quota-aware UI surfaces. Initialized
|
||
// in newServer; never nil so handlers can call get/set without
|
||
// guarding.
|
||
storageInfoCache *storageInfoCache
|
||
|
||
// copyItemFn indirects Store.CopyItemAcrossWorkspaces for the
|
||
// cross-workspace copy endpoint (PLAN-2357 / TASK-2365).
|
||
//
|
||
// It exists because DR-13 forbids the endpoint from ever transparently
|
||
// retrying a mutating copy — there is no idempotency key in v1, so a
|
||
// retry after a post-commit failure duplicates the item — and the only
|
||
// falsifiable way to assert "called exactly once" is to count the calls.
|
||
// Several tests also use it to inject the store's typed errors and to
|
||
// land a concurrent mutation deterministically between the handler's
|
||
// authorization and the store call. See handleCopyItem and
|
||
// TestCopyEndpoint_DoesNotRetryOnAmbiguousError.
|
||
//
|
||
// It is nil on every production path — nothing outside package server can
|
||
// set it, no constructor or setter assigns it, and only _test.go files do
|
||
// (Codex round 6: it is compiled into the binary, so "test-only" describes
|
||
// the convention, not a compiler-enforced guarantee).
|
||
copyItemFn func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error)
|
||
|
||
// importBundleMaxBytes caps a single workspace import bundle.
|
||
// 0 → defaultImportBundleMaxBytes (2 GiB). Set via
|
||
// SetImportBundleMaxBytes from cmd/pad/main.go using the
|
||
// PAD_IMPORT_BUNDLE_MAX_BYTES env var so operators with larger
|
||
// exports can opt in without recompiling.
|
||
importBundleMaxBytes int64
|
||
|
||
// importArtifactMaxBytes caps a single playbook/convention artifact
|
||
// import (POST /workspaces/{ws}/import-artifact). 0 →
|
||
// defaultImportArtifactMaxBytes (1 MiB). A single artifact is tiny;
|
||
// the cap is the first line of defense against an oversized body
|
||
// being materialized before the YAML-bomb guard runs. Set via
|
||
// SetImportArtifactMaxBytes from cmd/pad/main.go.
|
||
importArtifactMaxBytes int64
|
||
|
||
// orphanGC holds the periodic-sweep config + lifecycle for the
|
||
// attachment orphan garbage collector (TASK-886). Configured via
|
||
// SetOrphanGCConfig and started via StartOrphanGC. Stop() signals
|
||
// the loop to exit and waits for it via the bg WaitGroup.
|
||
orphanGC orphanGCConfig
|
||
|
||
// opLogGC holds the periodic-sweep config + lifecycle for the
|
||
// Yjs op-log prune sweeper (TASK-1309). Mirrors orphanGC's
|
||
// pattern. Configured via SetOpLogGCConfig + started via
|
||
// StartOpLogGC; Stop() signals the loop via stopOpLogGC.
|
||
opLogGC opLogGCConfig
|
||
|
||
// tokenReaper holds the periodic-sweep config + lifecycle for the
|
||
// short-lived-credential reaper (PLAN-1933 DR-5 / TASK-1936).
|
||
// Mirrors orphanGC/opLogGC. Configured via SetTokenReaperConfig +
|
||
// started via StartTokenReaper; Stop() signals the loop via
|
||
// stopTokenReaper.
|
||
tokenReaper tokenReaperConfig
|
||
|
||
// workspacePurge holds the periodic-sweep config + lifecycle for the
|
||
// soft-deleted-workspace hard-purge sweeper (TASK-1966 — the 30-day
|
||
// GDPR erasure SLA). Mirrors orphanGC. Configured via
|
||
// SetWorkspacePurgeConfig + started via StartWorkspacePurgeSweeper;
|
||
// Stop() signals the loop via stopWorkspacePurgeSweeper.
|
||
workspacePurge workspacePurgeConfig
|
||
|
||
// outboxDrain holds the periodic config + lifecycle for the SPEC-3 event
|
||
// outbox drain (TASK-2714). Mirrors orphanGC. Configured via
|
||
// SetOutboxDrainConfig + started via StartOutboxDrain; Stop() signals the
|
||
// loop via stopOutboxDrain.
|
||
outboxDrain outboxDrainConfig
|
||
|
||
// inFlightUploadHashes tracks content_hash values for uploads
|
||
// that have called AttachmentStore.Put but not yet inserted the
|
||
// attachments row. Without this, the orphan GC could delete a
|
||
// blob between Put and CreateAttachment, leaving a live row that
|
||
// references a missing blob (Codex P2 on PR #307 round 1).
|
||
//
|
||
// A plain map + mutex rather than sync.Map: counters need
|
||
// atomic-with-delete semantics (decrement-then-delete-if-zero
|
||
// must be one critical section, not two — sync.Map.CompareAndDelete
|
||
// addresses the entry but not the inc/dec interleaving). Codex
|
||
// P1 round 2 caught the prior sync.Map version racing on
|
||
// release-vs-reload of the same hash.
|
||
inFlightHashesMu sync.Mutex
|
||
inFlightHashes map[string]int64
|
||
|
||
// rowlessNoListerOnce gates the once-per-process notice that a
|
||
// registered attachment backend lacks the Lister capability, leaving
|
||
// the rowless-blob sweep (BUG-2406) inert for it. Logged rather than
|
||
// silently skipped so an operator can tell the leak class is
|
||
// unguarded on that backend; once, so a 24h-cadence sweep doesn't
|
||
// turn it into log spam.
|
||
rowlessNoListerOnce sync.Once
|
||
|
||
// rowlessPreDeleteHook, when non-nil, runs inside the rowless
|
||
// sweep's in-flight critical section immediately BEFORE the
|
||
// delete-time row re-check. Test seam only (injectedStageFailure
|
||
// precedent): it lets a test commit a row for the hash at exactly
|
||
// the point that distinguishes the batched subtraction from the
|
||
// re-check, making the TOCTOU leg deterministic.
|
||
rowlessPreDeleteHook func(hash string)
|
||
|
||
// bg tracks fire-and-forget goroutines spawned by request handlers
|
||
// (TouchUserActivity in middleware_auth, async email sends, etc.) so
|
||
// the server can drain them before shutdown / test cleanup. Without
|
||
// this, tests using t.TempDir() race the still-running goroutine's
|
||
// SQLite WAL write against TempDir RemoveAll, leaving "directory not
|
||
// empty" cleanup errors in CI. See BUG-842.
|
||
bg sync.WaitGroup
|
||
|
||
// First-run bootstrap token (TASK-1167 / PLAN-1166). When non-empty,
|
||
// handleBootstrap accepts the value via the X-Bootstrap-Token header
|
||
// from non-loopback peers (self-host mode only — cloud mode never
|
||
// loads or honors a token, D2/D10). Wired at startup via
|
||
// SetBootstrapToken; cleared by consumeBootstrapToken after the first
|
||
// admin is created.
|
||
//
|
||
// The mutex protects the token field AND the entire validate-token →
|
||
// check-UserCount → CreateUser → consume sequence in handleBootstrap.
|
||
// Two simultaneous valid-token requests with different emails would
|
||
// otherwise create two admins from one token (F5). Bootstrap happens
|
||
// once per install, so the contention window is irrelevant.
|
||
bootstrapMu sync.Mutex
|
||
bootstrapToken string
|
||
bootstrapTokenPath string
|
||
|
||
// bypassSetupToken, when true, allows the first-admin bootstrap POST to
|
||
// succeed from any IP without an X-Bootstrap-Token header — i.e. the
|
||
// /setup form on the web UI works directly, without the operator having
|
||
// to copy a token out of `docker logs`. Wired from PAD_BYPASS_SETUP_TOKEN
|
||
// at startup via SetBypassSetupToken (cmd/pad/main.go).
|
||
//
|
||
// Self-host only — cloud mode IGNORES this flag entirely (D2/D10 from
|
||
// the original logs-token design: cloud bootstrap stays loopback-only).
|
||
// The UserCount==0 gate in handleBootstrap is unchanged: once the first
|
||
// admin exists, the bootstrap endpoint returns 409 "already initialized"
|
||
// regardless of bypass. This matches the operator's mental model — the
|
||
// flag opens up the *first-run* surface, not registration in general.
|
||
//
|
||
// Operators on trusted networks (Unraid LAN, Tailscale-only deployments,
|
||
// homelabs behind a firewall) typically prefer this; operators with
|
||
// public exposure should leave it off and use the logs-token path.
|
||
bypassSetupToken bool
|
||
|
||
// restoreAckFault is a TEST SEAM (always nil in production). When non-nil,
|
||
// handleRestoreItemVersion's collab commit closure invokes it AFTER the restore
|
||
// transaction has durably committed; a non-nil return simulates a Postgres commit
|
||
// whose acknowledgement was lost at the connection boundary (the tx landed, but
|
||
// the driver surfaces an error), exercising BUG-2276 residual 1's commit-outcome
|
||
// reconciliation end-to-end through the real handler.
|
||
restoreAckFault func() error
|
||
|
||
// watchPredicatesLoadFault is a TEST SEAM (always nil in production,
|
||
// TASK-2533). When non-nil, loadWatchPredicates calls it before
|
||
// touching the store; a non-nil return short-circuits the real
|
||
// ListWatchesForUser call and is returned as the reload error —
|
||
// exercising GET /api/v1/events/stream's reval-tick error path
|
||
// (codex round 4: a watch-list reload failure must not also skip
|
||
// the identity/visibility refresh) deterministically, without
|
||
// needing to actually break the DB connection mid-test.
|
||
//
|
||
// atomic.Pointer, not a plain func field (codex round 5 finding 2):
|
||
// unlike restoreAckFault — set once, synchronously, before the single
|
||
// HTTP request that will read it, so goroutine-creation's own
|
||
// happens-before edge makes a plain field safe there — this seam is
|
||
// set by a test AFTER the SSE stream's background goroutine is
|
||
// already running and reading it on every reval tick. A plain field
|
||
// written from the test's goroutine while that goroutine reads it
|
||
// concurrently is a genuine, if timing-dependent, data race
|
||
// (verified: restoreAckFault's OWN usage doesn't share this flaw,
|
||
// since it's never touched after the goroutine that reads it starts,
|
||
// so it was intentionally left as a plain field rather than changed
|
||
// too).
|
||
watchPredicatesLoadFault atomic.Pointer[func() error]
|
||
|
||
// watchRevalTickOverride is a TEST SEAM (always nil in production,
|
||
// BUG-2570). When non-nil, GET /api/v1/events/stream selects reval
|
||
// ticks from this channel instead of the interval ticker, letting a
|
||
// test drive each revalidation tick explicitly. That is the only way
|
||
// to pin assertions to a SPECIFIC tick: with a free-running ticker,
|
||
// no test ordering can guarantee that an unwanted extra tick doesn't
|
||
// fire between two test steps — an extra SUCCESSFUL tick resets the
|
||
// visibility cache and reloads the watch list, masking exactly the
|
||
// reset-skipped-on-fault regression the reval-fault test guards, and
|
||
// enough extra FAULTING ticks clear the watch set (both observed as
|
||
// codex-round findings on BUG-2570's first fix attempt).
|
||
//
|
||
// Same atomic.Pointer rationale as watchPredicatesLoadFault above:
|
||
// read by the stream's background goroutine (once, at stream setup)
|
||
// while tests may write it. Tests must set it BEFORE connecting the
|
||
// stream they want to drive — a write after setup is not observed.
|
||
watchRevalTickOverride atomic.Pointer[chan time.Time]
|
||
}
|
||
|
||
// goAsync spawns fn in a goroutine that's tracked by s.bg, so Stop() can
|
||
// wait for in-flight background work to finish. Use this for any
|
||
// fire-and-forget work that touches the database, filesystem, or external
|
||
// services from inside a request handler — never bare `go func() {...}()`.
|
||
func (s *Server) goAsync(fn func()) {
|
||
s.bg.Add(1)
|
||
go func() {
|
||
defer s.bg.Done()
|
||
// Recover from panics in fn so a single bad background task
|
||
// (e.g. deriveThumbnails hitting a Go image-decoder panic on a
|
||
// crafted upload, or an email send) can't crash the whole
|
||
// single-binary server for every tenant. chi's Recoverer only
|
||
// covers request goroutines, not these detached ones. The
|
||
// deferred Done() above still fires because recover() keeps the
|
||
// goroutine from unwinding past this point.
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
slog.Error("background task panicked",
|
||
"panic", r,
|
||
"stack", string(debug.Stack()))
|
||
}
|
||
}()
|
||
fn()
|
||
}()
|
||
}
|
||
|
||
// recoverSweeper is the panic firewall for the long-running background
|
||
// sweeper loops (orphan GC, op-log GC, token reaper, workspace purge).
|
||
// Each of those manages its own s.bg.Add/Done + stop-channel lifecycle,
|
||
// so — unlike fire-and-forget work — they can't just route through
|
||
// goAsync without breaking their shutdown handling or double-counting
|
||
// s.bg. Instead each spawns `defer s.recoverSweeper("<name>")` as a
|
||
// deferred call INSIDE its goroutine (BUG-2071): a panic in the loop
|
||
// body is logged with a stack (matching goAsync's style) and unwinds
|
||
// cleanly, the goroutine's own deferred s.bg.Done() still fires because
|
||
// recover() stops the unwind here, and Stop() still returns. Without it a
|
||
// panic in any sweeper takes down the whole single-binary server for
|
||
// every tenant. Must be `defer`-called directly in the goroutine for
|
||
// recover() to catch the panic.
|
||
func (s *Server) recoverSweeper(name string) {
|
||
if r := recover(); r != nil {
|
||
slog.Error("background sweeper panicked",
|
||
"sweeper", name,
|
||
"panic", r,
|
||
"stack", string(debug.Stack()))
|
||
}
|
||
}
|
||
|
||
// Stop waits for all background goroutines started via goAsync to finish
|
||
// AND drains the rate-limiter cleanup goroutines spawned at construction
|
||
// time (BUG-851). Safe to call multiple times. Should be called before
|
||
// Store.Close() so in-flight DB writes don't race a closed connection
|
||
// (or worse, the SQLite -wal/-shm file removal in t.TempDir cleanup).
|
||
func (s *Server) Stop() {
|
||
// Signal long-running background loops (orphan GC, etc.) to exit.
|
||
// Each loop registers itself on s.bg, so the Wait() below blocks
|
||
// until they actually finish and any in-flight goroutines drain.
|
||
s.stopOrphanGC()
|
||
// Yjs op-log prune sweeper (TASK-1309). Same lifecycle pattern;
|
||
// signals BEFORE Wait() so the goroutine sees the close and exits.
|
||
s.stopOpLogGC()
|
||
// Short-lived-credential reaper (PLAN-1933 DR-5 / TASK-1936). Same
|
||
// lifecycle pattern; signal BEFORE Wait() so the goroutine exits.
|
||
s.stopTokenReaper()
|
||
// Soft-deleted-workspace hard-purge sweeper (TASK-1966). Same
|
||
// lifecycle pattern; signal BEFORE Wait() so the goroutine exits.
|
||
s.stopWorkspacePurgeSweeper()
|
||
// SPEC-3 event outbox drain (TASK-2714). Same lifecycle pattern; an
|
||
// in-flight delivery is tracked on s.bg and awaited below.
|
||
s.stopOutboxDrain()
|
||
// MCP audit writer / sweeper run on s.bg too. Signal first so
|
||
// the workers see the close BEFORE Wait() blocks; without the
|
||
// signal Wait would hang forever on the writer's blocking
|
||
// queue receive.
|
||
s.stopMCPAuditWriter()
|
||
// MCP session tracker (TASK-1120) runs its sweeper on s.bg too.
|
||
// Order with the audit writer doesn't matter — both are
|
||
// independent goroutines; we just need the close BEFORE Wait().
|
||
s.stopMCPSessionTracker()
|
||
// Close the collab room manager BEFORE bg.Wait() so any in-flight
|
||
// op-log GC sweep (TASK-1309) blocked on a per-item lock behind
|
||
// an active Join can drain. collab.Close() tears down the Joins
|
||
// (their WS readLoops return, runConn unwinds, itemLocks
|
||
// release), which unblocks the GC's per-item PruneItemOpLogIfDormantBefore
|
||
// call. Without this ordering, Stop() can deadlock: GC waits on
|
||
// itemLock; Join holds itemLock until WS closes; WS only closes
|
||
// when collab.Close() runs; collab.Close() only runs after
|
||
// bg.Wait(); bg.Wait() never returns because GC is stuck.
|
||
// Per Codex review of TASK-1309 [P2]. nil-safe: collab is optional.
|
||
if s.collab != nil {
|
||
s.collab.Close()
|
||
}
|
||
s.bg.Wait()
|
||
// Watch/nudge bus (BUG-2651). Closed AFTER bg.Wait() so a background
|
||
// producer cannot publish into a bus that is already tearing down; both
|
||
// implementations are safe if one does anyway (MemoryBus finds no
|
||
// subscribers, RedisBus finds a cancelled context and fails closed).
|
||
//
|
||
// This matters more than it did for MemoryBus, whose Close only dropped
|
||
// channels: RedisBus holds a receive goroutine and a Redis subscription
|
||
// from construction, so skipping it leaks both for the process's life
|
||
// (Codex round 1 P2). Closing also closes every subscriber channel, which
|
||
// is how a still-open SSE stream learns to unwind.
|
||
if s.watchEvents != nil {
|
||
s.watchEvents.Close()
|
||
}
|
||
s.rateLimiters.Stop() // nil-safe via the RateLimiters receiver guard
|
||
}
|
||
|
||
func New(s *store.Store) *Server {
|
||
rl := NewRateLimiters()
|
||
// PAD_DISABLE_RATE_LIMITS turns off ALL HTTP rate limiting when set to a
|
||
// truthy value. It exists ONLY for the E2E harness (BUG-2089): every
|
||
// Playwright test shares one loopback IP (127.0.0.1), so the auth limiter
|
||
// (5 logins/min/IP) trips the moment a spec logs in a couple of browser
|
||
// clients — collab-persistence.spec.ts logs in two per test. A nil
|
||
// rateLimiters makes RateLimit() a pass-through (see middleware_ratelimit.go
|
||
// line 379), and Stop() + the MCP path are already nil-safe. Never set this
|
||
// in production or self-host; it's an explicit opt-in so it can't flip on
|
||
// by accident.
|
||
if disabled, _ := strconv.ParseBool(os.Getenv("PAD_DISABLE_RATE_LIMITS")); disabled {
|
||
rl = nil
|
||
}
|
||
return &Server{
|
||
store: s,
|
||
rateLimiters: rl,
|
||
storageInfoCache: newStorageInfoCache(storageInfoTTL),
|
||
}
|
||
}
|
||
|
||
// Init2FASecret loads the 2FA challenge signing key from platform_settings.
|
||
// If no key exists (first run), a new random key is generated and persisted.
|
||
// This must be called before the server handles requests so that challenge
|
||
// tokens survive process restarts and work across multiple instances.
|
||
func (s *Server) Init2FASecret() error {
|
||
const settingKey = "2fa_challenge_secret"
|
||
|
||
existing, err := s.store.GetPlatformSetting(settingKey)
|
||
if err != nil {
|
||
return fmt.Errorf("load 2FA secret: %w", err)
|
||
}
|
||
|
||
if existing != "" {
|
||
decoded, err := base64.StdEncoding.DecodeString(existing)
|
||
if err != nil {
|
||
return fmt.Errorf("decode 2FA secret: %w", err)
|
||
}
|
||
s.twoFAChallengeSecret = decoded
|
||
return nil
|
||
}
|
||
|
||
// First run — generate and persist a new secret.
|
||
// Multiple instances may race here on a fresh database; after persisting,
|
||
// re-read the winning value so all instances converge on the same key.
|
||
secret, err := generateTwoFASecret()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
encoded := base64.StdEncoding.EncodeToString(secret)
|
||
if err := s.store.SetPlatformSetting(settingKey, encoded); err != nil {
|
||
return fmt.Errorf("persist 2FA secret: %w", err)
|
||
}
|
||
|
||
// Re-read to pick up whichever instance won the race (upsert may have
|
||
// been overwritten by a concurrent instance between our check and write).
|
||
final, err := s.store.GetPlatformSetting(settingKey)
|
||
if err != nil {
|
||
return fmt.Errorf("re-read 2FA secret: %w", err)
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(final)
|
||
if err != nil {
|
||
return fmt.Errorf("decode 2FA secret after re-read: %w", err)
|
||
}
|
||
s.twoFAChallengeSecret = decoded
|
||
slog.Info("initialized 2FA challenge signing key")
|
||
return nil
|
||
}
|
||
|
||
// SetCloudMode enables cloud mode with the shared sidecar secret(s).
|
||
// Accepts a comma-separated list of secrets for rotation support:
|
||
// "new-key,old-key" — both are accepted for INBOUND calls from pad-cloud.
|
||
// The OUTBOUND direction (pad → pad-cloud, see SetCloudSidecar) is
|
||
// configured separately via PAD_CLOUD_OUTBOUND_SECRET or derived from the
|
||
// last entry of this list — see cmd/pad/main.go for the resolution order.
|
||
func (s *Server) SetCloudMode(secret string) {
|
||
s.cloudMode = true
|
||
for _, k := range strings.Split(secret, ",") {
|
||
k = strings.TrimSpace(k)
|
||
if k != "" {
|
||
s.cloudSecrets = append(s.cloudSecrets, k)
|
||
}
|
||
}
|
||
// Propagate to the email sender so transactional emails carry the
|
||
// getpad.dev marketing footer (docs/brand.md §7) on Cloud installs.
|
||
// Self-hosted deployments leave cloudMode false on the sender, keeping
|
||
// outgoing mail neutral so operators can ship under their own brand.
|
||
if s.email != nil {
|
||
s.email.SetCloudMode(true)
|
||
}
|
||
}
|
||
|
||
// CloudSidecar is the reverse pad → pad-cloud client interface. Concrete
|
||
// implementation lives in internal/billing so server has no direct Stripe
|
||
// dependency. Kept as an interface so tests can inject fakes without
|
||
// spinning up a real HTTP server or touching Stripe.
|
||
type CloudSidecar interface {
|
||
// CancelCustomer asks pad-cloud to cancel every active Stripe subscription
|
||
// for customerID and then delete the Stripe customer object. Used by
|
||
// handleDeleteAccount to cascade account deletion through to Stripe billing
|
||
// (TASK-690).
|
||
//
|
||
// Failure contract: any non-nil error means the caller MUST abort the
|
||
// local delete. pad-cloud normalizes Stripe's "already gone" cases to a
|
||
// 200 on its side (see pad-cloud stripe.go isStripeAlreadyGone), so
|
||
// every error we see here is a real failure — transport, 4xx (ops
|
||
// misconfig), or 5xx (upstream breakage). Continuing after an error
|
||
// would wipe the user's StripeCustomerID while leaving the subscription
|
||
// billing, which is exactly the regression TASK-690 exists to prevent.
|
||
CancelCustomer(customerID string) error
|
||
|
||
// GetBillingMetrics fetches an aggregated Stripe-derived snapshot from
|
||
// pad-cloud's /admin/metrics/billing endpoint (active subs, MRR, ARR,
|
||
// churn, cancellations). Used by handleAdminBillingStats to power the
|
||
// admin Billing dashboard (TASK-827 / PLAN-825).
|
||
//
|
||
// Failure contract: returns an error on transport failure or non-200
|
||
// status. The admin handler treats any error as "degrade to local-only"
|
||
// and surfaces the distinction in its response via cloud_unreachable —
|
||
// it never propagates the upstream failure to the operator's browser.
|
||
GetBillingMetrics() (*billing.BillingMetricsResponse, error)
|
||
}
|
||
|
||
// SetCloudSidecar installs the reverse pad → pad-cloud client. Called from
|
||
// cmd/pad/main.go when PAD_CLOUD_SIDECAR_URL + PAD_CLOUD_SECRET are set.
|
||
// When unset, handleDeleteAccount skips the Stripe cancel step (self-hosted
|
||
// deploys that don't run a Stripe-backed sidecar have nothing to cascade).
|
||
func (s *Server) SetCloudSidecar(c CloudSidecar) {
|
||
s.cloudSidecar = c
|
||
}
|
||
|
||
// SetBillingAvailable marks this deployment as having Stripe Checkout wired
|
||
// up. Called from cmd/pad/main.go when PAD_BILLING_AVAILABLE=true is set.
|
||
// When false (the default), the session payload advertises billing_available=false
|
||
// so the web UI hides Stripe CTAs rather than dead-ending at a 503. TASK-800.
|
||
func (s *Server) SetBillingAvailable(v bool) {
|
||
s.billingAvailable = v
|
||
}
|
||
|
||
// IsCloud reports whether the server is running in cloud mode.
|
||
func (s *Server) IsCloud() bool {
|
||
return s.cloudMode
|
||
}
|
||
|
||
// SetVersion stores the build version info for the health endpoint.
|
||
func (s *Server) SetVersion(version, commit, buildTime string) {
|
||
s.version = version
|
||
s.commit = commit
|
||
s.buildTime = buildTime
|
||
}
|
||
|
||
// SetBaseURL sets the public base URL used for generating shareable links.
|
||
//
|
||
// If the supplied URL has an unspecified bind-all host ("0.0.0.0", "::",
|
||
// "[::]"), this logs a WARN: such a URL is the right thing to *bind* to
|
||
// but the wrong thing to *send* to a recipient (their browser cannot
|
||
// resolve 0.0.0.0 / :: as a connect target). Callers shipping email
|
||
// links from such a deployment should set PAD_URL or PUBLIC_URL to the
|
||
// real public hostname (e.g. https://app.getpad.dev). See BUG-899.
|
||
func (s *Server) SetBaseURL(rawURL string) {
|
||
s.baseURL = strings.TrimRight(rawURL, "/")
|
||
if s.baseURL == "" {
|
||
return
|
||
}
|
||
if u, err := url.Parse(s.baseURL); err == nil {
|
||
switch u.Hostname() {
|
||
case "", "0.0.0.0", "::":
|
||
slog.Warn("server base URL has an unspecified host; emailed links (password reset, invites, share links) will not be reachable. Set PAD_URL or PUBLIC_URL to the deployment's public URL (e.g. https://app.getpad.dev).", "base_url", s.baseURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
// specialUseTLDs is the (finite) set of reserved top-level names from the IANA
|
||
// Special-Use Domain Names registry + related RFCs that never resolve to a
|
||
// public web host, so an emailed link using one is undeliverable. Matched on
|
||
// the final label so example.com (public) is allowed while foo.example /
|
||
// foo.test / bar.internal / pad.home.arpa / x.onion / y.alt (non-public) are
|
||
// rejected. Sources: RFC 6761 (localhost/invalid/test/example), RFC 6762
|
||
// (local), RFC 8375 (home.arpa) + the "arpa" infrastructure TLD, RFC 7686
|
||
// (onion), RFC 9476 (alt), and ICANN-reserved "internal".
|
||
var specialUseTLDs = map[string]bool{
|
||
"localhost": true, "local": true, "internal": true,
|
||
"invalid": true, "test": true, "example": true, "arpa": true,
|
||
"onion": true, "alt": true,
|
||
}
|
||
|
||
// hasUsableBaseURL reports whether s.baseURL is a URL a verification-email
|
||
// recipient on the public internet can actually reach. It supersets the
|
||
// unreachable-host warning in SetBaseURL (BUG-899): the bind-all hosts
|
||
// 0.0.0.0 / :: are the right thing to bind() to but the wrong thing to email,
|
||
// and so is every other host an external recipient can't resolve or route to.
|
||
//
|
||
// This gates the cloud self-serve signup path (PLAN-1933 DR-6): creating an
|
||
// UNVERIFIED user whose only way out of the write-lock is an emailed link we
|
||
// can't deliver would strand them permanently. So "usable" is conservative —
|
||
// anything not clearly a public web endpoint disqualifies self-serve signup:
|
||
//
|
||
// - scheme must be http/https (a browser can't follow ftp://, file://, …);
|
||
// - no query/fragment (the link is built by concatenation, so either would
|
||
// push the /verify-email/<token> route into the query/fragment);
|
||
// - a present port must be a valid TCP port (1–65535);
|
||
// - the host must be a valid public DNS FQDN, NOT a literal IP. A real cloud
|
||
// verification endpoint is a hostname (Pad Cloud is app.getpad.dev);
|
||
// bare-IP base URLs aren't used for emailed links, and exhaustively
|
||
// enumerating every non-public IP range (loopback / private / CGNAT /
|
||
// TEST-NET / 6to4 / benchmarking / reserved / … across IPv4 and IPv6) is a
|
||
// losing game — so we require a hostname and fail closed on any IP literal.
|
||
//
|
||
// No usable base URL → no self-serve signup (registration stays closed rather
|
||
// than minting a write-locked user). Self-host and admin/invitation signup are
|
||
// unaffected either way.
|
||
func (s *Server) hasUsableBaseURL() bool {
|
||
if s.baseURL == "" {
|
||
return false
|
||
}
|
||
u, err := url.Parse(s.baseURL)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return false
|
||
}
|
||
// The verification link is built by concatenation (baseURL +
|
||
// "/verify-email/" + token), so a base URL carrying a query or fragment
|
||
// would push the route into the query/fragment and break the link.
|
||
if u.RawQuery != "" || u.Fragment != "" {
|
||
return false
|
||
}
|
||
host := u.Hostname()
|
||
if host == "" {
|
||
return false
|
||
}
|
||
// A present port must be a valid TCP port (1–65535); url.Parse accepts
|
||
// out-of-range numeric ports that no client can actually connect to.
|
||
if p := u.Port(); p != "" {
|
||
n, perr := strconv.Atoi(p)
|
||
if perr != nil || n < 1 || n > 65535 {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Reject literal IPs outright — a usable public verification endpoint is a
|
||
// DNS hostname, and "is this IP publicly reachable" is not decidable from a
|
||
// finite denylist. Fail closed on any IP.
|
||
if _, aerr := netip.ParseAddr(host); aerr == nil {
|
||
return false
|
||
}
|
||
|
||
// The host must be a syntactically-valid, multi-label public FQDN (rejects
|
||
// malformed hosts like ".com", "foo..com", "-a.com" and special-use TLDs).
|
||
return isPublicDNSName(host)
|
||
}
|
||
|
||
// isPublicDNSName reports whether host is a syntactically-valid public FQDN
|
||
// (RFC 1123 labels) whose TLD is neither a special-use reserved name nor
|
||
// all-numeric. Empty labels, over-length labels, and invalid characters are
|
||
// rejected. Assumes host is not an IP literal (that's handled before this is
|
||
// called). Punycode/IDN TLDs (xn--…) are accepted since they aren't all-digit.
|
||
func isPublicDNSName(host string) bool {
|
||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||
if host == "" || len(host) > 253 {
|
||
return false
|
||
}
|
||
labels := strings.Split(host, ".")
|
||
if len(labels) < 2 {
|
||
return false
|
||
}
|
||
for _, l := range labels {
|
||
if !isValidDNSLabel(l) {
|
||
return false
|
||
}
|
||
}
|
||
tld := labels[len(labels)-1]
|
||
if specialUseTLDs[tld] || isAllDigits(tld) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isValidDNSLabel reports whether l is a valid RFC 1123 hostname label:
|
||
// 1–63 chars of [a-z0-9-], not starting or ending with a hyphen.
|
||
func isValidDNSLabel(l string) bool {
|
||
if len(l) == 0 || len(l) > 63 {
|
||
return false
|
||
}
|
||
if l[0] == '-' || l[len(l)-1] == '-' {
|
||
return false
|
||
}
|
||
for i := 0; i < len(l); i++ {
|
||
c := l[i]
|
||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isAllDigits reports whether s is non-empty and entirely ASCII digits. A
|
||
// public TLD is never all-numeric (RFC 3696), so an all-digit final label
|
||
// signals a malformed host rather than a reachable name.
|
||
func isAllDigits(s string) bool {
|
||
if s == "" {
|
||
return false
|
||
}
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] < '0' || s[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// emailConfigured reports whether this instance can actually SEND an emailed
|
||
// link — a sender is wired AND the public base URL is usable. This is the
|
||
// DR-6 gate for cloud email self-registration: the sender-only check (s.email
|
||
// != nil) is insufficient because link generation also needs a reachable
|
||
// public base URL (see handleRegister's verification email + BUG-899).
|
||
func (s *Server) emailConfigured() bool {
|
||
return s.email != nil && s.hasUsableBaseURL()
|
||
}
|
||
|
||
// SetEventBus attaches an event bus for real-time SSE streaming.
|
||
func (s *Server) SetEventBus(bus events.EventBus) {
|
||
s.events = bus
|
||
}
|
||
|
||
// SetWatchEventsBus attaches the watch/nudge notification bus consumed by
|
||
// GET /api/v1/events/stream (TASK-2533). Nil-checked by every producer and
|
||
// by the stream handler, so a server constructed without one (e.g. a test
|
||
// that doesn't exercise watches) still serves every other endpoint.
|
||
func (s *Server) SetWatchEventsBus(bus watchevents.Bus) {
|
||
s.watchEvents = bus
|
||
}
|
||
|
||
// SetSessionPresence attaches the live-session registry read by
|
||
// GET /api/v1/sessions and written by GET /api/v1/events/stream
|
||
// (PLAN-2558 S1). Nil-checked at both ends, so a server constructed
|
||
// without one still streams events — it just can't answer "who is
|
||
// listening?", and says so with a 503 rather than an empty list (see
|
||
// handleListSessions).
|
||
func (s *Server) SetSessionPresence(p SessionPresence) {
|
||
s.sessionPresence = p
|
||
}
|
||
|
||
// SetCollabRoomManager attaches a Yjs collab RoomManager (PLAN-1248).
|
||
// When set, the /api/v1/collab/{itemID} WebSocket endpoint hands new
|
||
// connections to the manager for op-log replay + fan-out. When nil,
|
||
// the endpoint exists but answers 503 — that's intentional so a
|
||
// self-host build that wants the editor without collab can leave
|
||
// this unwired without surfacing surprise behaviour.
|
||
func (s *Server) SetCollabRoomManager(rm *collab.RoomManager) {
|
||
s.collab = rm
|
||
}
|
||
|
||
// SetWebhookDispatcher attaches a webhook dispatcher for outgoing
|
||
// notifications. Delivery goroutines are routed through s.goAsync so they're
|
||
// tracked on s.bg — Server.Stop() waits for in-flight deliveries (closing the
|
||
// BUG-842 shutdown race where a detached delivery writes to a closed store)
|
||
// and inherits goAsync's panic recovery (BUG-2011).
|
||
func (s *Server) SetWebhookDispatcher(d *webhooks.Dispatcher) {
|
||
if d != nil {
|
||
d.SetSpawn(s.goAsync)
|
||
}
|
||
s.webhooks = d
|
||
}
|
||
|
||
// SetEmailSender attaches a transactional email sender.
|
||
// The apiKey is stored separately for deriving the unsubscribe HMAC secret.
|
||
//
|
||
// If the server is already in cloud mode when this is called (i.e.
|
||
// SetCloudMode ran before email config arrived from main.go), propagate
|
||
// the flag so the new sender adds the getpad.dev marketing footer to
|
||
// outgoing emails. Without this, the cloud-mode flag would silently
|
||
// fail to take effect when callers wired email and cloud mode in
|
||
// either order.
|
||
func (s *Server) SetEmailSender(e *email.Sender, apiKey ...string) {
|
||
s.email = e
|
||
// A sender wired here comes from an out-of-band source (env vars at startup).
|
||
// Mark it so reconfigureEmail leaves it in place when platform settings carry
|
||
// no key — env is the deployment baseline, not something the admin UI disables.
|
||
s.emailEnvConfigured = e != nil
|
||
if len(apiKey) > 0 {
|
||
s.emailAPIKey = apiKey[0]
|
||
}
|
||
if s.cloudMode && s.email != nil {
|
||
s.email.SetCloudMode(true)
|
||
}
|
||
}
|
||
|
||
// SetCORSOrigins configures allowed CORS origins (comma-separated).
|
||
func (s *Server) SetCORSOrigins(origins string) {
|
||
s.corsOrigins = origins
|
||
}
|
||
|
||
// SetAttachments wires the attachment storage Registry that the upload
|
||
// and download handlers use. Pass maxBytes = 0 to keep the
|
||
// defaultAttachmentMaxBytes ceiling (25 MiB).
|
||
func (s *Server) SetAttachments(reg *attachments.Registry, maxBytes int64) {
|
||
s.attachments = reg
|
||
s.attachmentMaxBytes = maxBytes
|
||
}
|
||
|
||
// SetImageProcessor wires the image processor that the upload handler
|
||
// uses to derive thumbnail variants (TASK-878). Optional — without it
|
||
// uploads still succeed but no thumbnails are generated; the
|
||
// download handler's variant fallback path returns the original blob.
|
||
// The capabilities endpoint reflects whichever processor is wired.
|
||
func (s *Server) SetImageProcessor(p attachments.Processor) {
|
||
s.imageProcessor = p
|
||
}
|
||
|
||
// markUploadInFlight increments the in-flight counter for a content
|
||
// hash. Returns a release func the caller MUST defer; the release
|
||
// decrements and removes the entry once it hits zero. Used by the
|
||
// upload handler to fence Put + CreateAttachment against orphan-GC
|
||
// blob deletions of the same hash.
|
||
//
|
||
// Increment + map-store + decrement + delete all run under one
|
||
// mutex so a concurrent uploadInFlight call can't observe a stale
|
||
// "0" between the last release-decrement and the next-upload
|
||
// increment. The earlier sync.Map version split increment from
|
||
// LoadOrStore-then-atomic-add and missed that window (Codex P1 on
|
||
// PR #307 round 2).
|
||
func (s *Server) markUploadInFlight(hash string) func() {
|
||
s.inFlightHashesMu.Lock()
|
||
if s.inFlightHashes == nil {
|
||
s.inFlightHashes = make(map[string]int64)
|
||
}
|
||
s.inFlightHashes[hash]++
|
||
s.inFlightHashesMu.Unlock()
|
||
return func() {
|
||
s.inFlightHashesMu.Lock()
|
||
defer s.inFlightHashesMu.Unlock()
|
||
s.inFlightHashes[hash]--
|
||
if s.inFlightHashes[hash] <= 0 {
|
||
delete(s.inFlightHashes, hash)
|
||
}
|
||
}
|
||
}
|
||
|
||
// uploadInFlight reports whether any upload is currently materializing
|
||
// a blob with the given hash. The orphan GC consults this before
|
||
// deleting a blob — if an upload just finished Put but hasn't
|
||
// inserted the row yet, GC must NOT reclaim the blob.
|
||
func (s *Server) uploadInFlight(hash string) bool {
|
||
s.inFlightHashesMu.Lock()
|
||
defer s.inFlightHashesMu.Unlock()
|
||
return s.inFlightHashes[hash] > 0
|
||
}
|
||
|
||
// SetImportBundleMaxBytes overrides the default 2 GiB cap on a
|
||
// single workspace import bundle. Set to 0 to fall back to the
|
||
// default. Wired from PAD_IMPORT_BUNDLE_MAX_BYTES in cmd/pad/main.go
|
||
// so operators with workspaces over 2 GiB can opt in without
|
||
// recompiling. Larger caps trade memory headroom (one blob in
|
||
// flight at a time, ≤25 MiB) for a longer import wall-clock.
|
||
func (s *Server) SetImportBundleMaxBytes(n int64) {
|
||
s.importBundleMaxBytes = n
|
||
}
|
||
|
||
// SetImportArtifactMaxBytes overrides the default 1 MiB cap on a single
|
||
// playbook/convention artifact import. Set to 0 to fall back to the
|
||
// default. Wired from PAD_IMPORT_ARTIFACT_MAX_BYTES in cmd/pad/main.go.
|
||
func (s *Server) SetImportArtifactMaxBytes(n int64) {
|
||
s.importArtifactMaxBytes = n
|
||
}
|
||
|
||
// SetSecureCookies enables the Secure flag on all cookies.
|
||
func (s *Server) SetSecureCookies(secure bool) {
|
||
s.secureCookies = secure
|
||
}
|
||
|
||
// SetMetrics attaches Prometheus metrics to the server.
|
||
// Must be called before the first request is served.
|
||
//
|
||
// Side effect (TASK-961): when both metrics AND the OAuth server are
|
||
// wired, this also attaches the OAuth-active-tokens callback collector
|
||
// and the revocation TTL observer. Order-independent — both
|
||
// SetMetrics and SetOAuthServer call wireOAuthMetricsObserver, which
|
||
// no-ops until both prerequisites are present.
|
||
func (s *Server) SetMetrics(m *metrics.Metrics) {
|
||
s.metrics = m
|
||
s.wireOAuthMetricsObserver()
|
||
}
|
||
|
||
// wireOAuthMetricsObserver attaches the OAuth metrics that need both
|
||
// the metrics registry AND the OAuth server: the active-tokens
|
||
// callback collector (reads via the store) and the per-revocation
|
||
// TTL observer (fires from internal/oauth/storage.go on every
|
||
// access-token family revocation).
|
||
//
|
||
// Idempotent — re-registering the same collector would panic via
|
||
// prometheus.MustRegister, so we guard with a flag. Setting the
|
||
// observer multiple times is harmless (just replaces the function
|
||
// pointer).
|
||
//
|
||
// Why this lives on Server rather than in cmd/pad: it composes two
|
||
// optional Server fields whose set-order isn't guaranteed by the
|
||
// boot sequence, and centralizing the wiring here keeps the cmd/pad
|
||
// startup path declarative ("set X, set Y") without an explicit
|
||
// "now wire the cross-cut" call.
|
||
func (s *Server) wireOAuthMetricsObserver() {
|
||
if s.metrics == nil || s.oauthServer == nil {
|
||
return
|
||
}
|
||
if !s.oauthMetricsWired {
|
||
s.metrics.RegisterOAuthActiveTokensCollector(s.store.CountActiveOAuthAccessTokens)
|
||
s.oauthMetricsWired = true
|
||
}
|
||
s.oauthServer.Storage().SetRevocationObserver(func(kind string, ttl time.Duration) {
|
||
s.metrics.OAuthTokenRevocationsTotal.WithLabelValues(kind).Inc()
|
||
s.metrics.OAuthTokenTTLSeconds.Observe(ttl.Seconds())
|
||
})
|
||
}
|
||
|
||
// SetMetricsToken configures the static bearer token required to scrape
|
||
// /metrics. When empty (the default), /metrics is exposed only to loopback
|
||
// callers so a self-hosted Prometheus on the same host keeps working
|
||
// without config — but LAN/internet scrapes are refused. A non-empty
|
||
// token requires "Authorization: Bearer <token>" regardless of source.
|
||
func (s *Server) SetMetricsToken(token string) {
|
||
s.metricsToken = strings.TrimSpace(token)
|
||
}
|
||
|
||
// metricsAuth gates the /metrics endpoint. See SetMetricsToken for the
|
||
// policy. Uses constant-time comparison to avoid leaking the configured
|
||
// token via response timing.
|
||
func (s *Server) metricsAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if s.metricsToken == "" {
|
||
// No token configured → loopback-only access.
|
||
if !requestIsLoopback(r) {
|
||
writeError(w, http.StatusForbidden, "forbidden",
|
||
"/metrics is restricted to loopback when PAD_METRICS_TOKEN is unset")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
const prefix = "Bearer "
|
||
authHeader := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(authHeader, prefix) {
|
||
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
|
||
writeError(w, http.StatusUnauthorized, "unauthorized",
|
||
"Missing Bearer token for /metrics")
|
||
return
|
||
}
|
||
given := strings.TrimSpace(strings.TrimPrefix(authHeader, prefix))
|
||
if subtle.ConstantTimeCompare([]byte(given), []byte(s.metricsToken)) != 1 {
|
||
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
|
||
writeError(w, http.StatusUnauthorized, "unauthorized",
|
||
"Invalid Bearer token for /metrics")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// SetSSELimits configures global and per-workspace SSE connection limits.
|
||
// A value of 0 means unlimited.
|
||
func (s *Server) SetSSELimits(global, perWorkspace int) {
|
||
s.sseMaxConnections = global
|
||
s.sseMaxPerWorkspace = perWorkspace
|
||
}
|
||
|
||
// SetTrustedProxies configures which direct TCP peers are allowed to set
|
||
// X-Real-IP / X-Forwarded-For on incoming requests. Accepts a comma-
|
||
// separated list of CIDRs or bare IPs (e.g. "10.0.0.0/8, 172.16.0.0/12").
|
||
// When empty (the default), proxy headers are ignored entirely — the
|
||
// actual TCP peer address is used for rate limiting, the bootstrap
|
||
// loopback check, and audit logging.
|
||
func (s *Server) SetTrustedProxies(spec string) {
|
||
s.trustedProxyCIDRs = ParseTrustedProxyCIDRs(spec)
|
||
}
|
||
|
||
// SetIPChangeEnforce controls how the auth middleware reacts when a
|
||
// session's binding (client IP OR User-Agent hash) changes mid-lifetime:
|
||
// - mode == "strict": revoke the session and reject the request (the token
|
||
// is treated as possibly stolen). Covers BOTH the IP and the UA signal —
|
||
// one flag arms the whole session-binding enforcement.
|
||
// - anything else (default): log to the audit log, update the stored IP,
|
||
// and let the request through. Strict mode breaks legitimate mobility
|
||
// (mobile roaming, VPN toggles for IP; browser/WebView updates for UA) so
|
||
// it is opt-in for high-sensitivity deployments via the
|
||
// PAD_IP_CHANGE_ENFORCE env var. See handleSessionIPChange /
|
||
// handleSessionUAChange for the per-signal semantics.
|
||
func (s *Server) SetIPChangeEnforce(mode string) {
|
||
s.ipChangeEnforceStrict = strings.EqualFold(strings.TrimSpace(mode), "strict")
|
||
}
|
||
|
||
// reconfigureEmail reads email settings from the platform_settings table
|
||
// and updates (or creates) the email sender. Called after admin settings change.
|
||
func (s *Server) reconfigureEmail() {
|
||
apiKey, _ := s.store.GetPlatformSetting(settingMailerooAPIKey)
|
||
fromAddr, _ := s.store.GetPlatformSetting(settingEmailFrom)
|
||
fromName, _ := s.store.GetPlatformSetting(settingEmailFromName)
|
||
|
||
if apiKey == "" {
|
||
// No platform-settings key. If email was wired from env vars, leave that
|
||
// sender in place — env is the deployment baseline. Otherwise the admin
|
||
// cleared the only email config (e.g. selecting provider "None"), so tear
|
||
// down the live sender: clearing the DB key alone left the running process
|
||
// sending mail until restart (BUG-1890).
|
||
if !s.emailEnvConfigured {
|
||
s.email = nil
|
||
s.emailAPIKey = ""
|
||
}
|
||
return
|
||
}
|
||
|
||
s.emailAPIKey = apiKey
|
||
if s.email == nil {
|
||
// Create a new sender from platform settings
|
||
s.email = email.NewSender(apiKey, fromAddr, fromName, s.baseURL)
|
||
} else {
|
||
// Update existing sender
|
||
s.email.Configure(apiKey, fromAddr, fromName, s.baseURL)
|
||
}
|
||
// Propagate cloud mode whichever way email was wired — see SetEmailSender
|
||
// for the matching note. Configure() preserves cloudMode on existing
|
||
// senders since SetCloudMode is independent; this branch covers the
|
||
// fresh-NewSender path.
|
||
if s.cloudMode {
|
||
s.email.SetCloudMode(true)
|
||
}
|
||
}
|
||
|
||
// InitEmailFromSettings loads email config from platform settings on startup,
|
||
// merging with any env-var-based sender that was already attached.
|
||
func (s *Server) InitEmailFromSettings() {
|
||
s.reconfigureEmail()
|
||
}
|
||
|
||
func (s *Server) setupRouter() {
|
||
r := chi.NewRouter()
|
||
|
||
// Infrastructure middleware (applies to all routes including /metrics)
|
||
// CapturePeerAddr MUST run before TrustedProxyRealIP so downstream code
|
||
// that needs to verify the real TCP peer (e.g. the bootstrap loopback
|
||
// check) can read the untampered value from request context even on
|
||
// deployments with a trusted reverse proxy in front.
|
||
r.Use(CapturePeerAddr)
|
||
// RealIP is gated on PAD_TRUSTED_PROXIES. When unset (the default), proxy
|
||
// headers are ignored and the real TCP peer address is used everywhere.
|
||
// This prevents X-Forwarded-For spoofing from bypassing rate limits, the
|
||
// bootstrap loopback check, or audit logs on direct-exposed deployments.
|
||
r.Use(TrustedProxyRealIP(s.trustedProxyCIDRs))
|
||
r.Use(chimiddleware.RequestID)
|
||
r.Use(StructuredLogger)
|
||
if s.metrics != nil {
|
||
r.Use(MetricsMiddleware(s.metrics))
|
||
}
|
||
r.Use(chimiddleware.Recoverer)
|
||
|
||
// Security headers (applies to all routes)
|
||
r.Use(SecurityHeaders)
|
||
if s.secureCookies {
|
||
r.Use(StrictTransportSecurity)
|
||
}
|
||
|
||
// MCP Streamable HTTP transport + OAuth discovery endpoints
|
||
// (PLAN-943 TASK-950). Mounted outside the standard /api/v1
|
||
// auth-required group because:
|
||
//
|
||
// - /mcp uses Bearer auth via its own MCPBearerAuth middleware,
|
||
// producing the spec-shape 401 + WWW-Authenticate that MCP
|
||
// clients expect (the API-stack 401 envelope is JSON-only and
|
||
// would fail Claude Desktop's discovery handshake).
|
||
// - /.well-known/oauth-protected-resource and
|
||
// /.well-known/oauth-authorization-server are public discovery
|
||
// documents (RFC 9728 / RFC 8414); routing them through
|
||
// TokenAuth+SessionAuth+RequireAuth would 401 unauth probes.
|
||
//
|
||
// No-op when SetMCPTransport hasn't been called or cloud mode is
|
||
// off — see registerMCPRoutes for the gating.
|
||
s.registerMCPRoutes(r)
|
||
|
||
// OAuth 2.1 authorization-server flow endpoints (PLAN-943
|
||
// TASK-1025 sub-PR C). /oauth/{register,authorize,token,
|
||
// authorize/decide} mounted alongside /mcp + /.well-known/*,
|
||
// outside /api/v1's auth-required group. CSRF middleware runs
|
||
// only on /api/* paths so /oauth/* is naturally exempt; the
|
||
// consent-decision endpoint adds its own form-token check
|
||
// using the existing __Host-pad_csrf cookie.
|
||
//
|
||
// SessionAuth runs in this group so /oauth/authorize can detect
|
||
// whether the user is logged in via the __Host-pad_session
|
||
// cookie. SessionAuth falls through gracefully when no cookie
|
||
// is present (handlers see currentUser(r)==nil and redirect to
|
||
// /login). RequireAuth is intentionally NOT used — /oauth/authorize
|
||
// must be reachable anonymously to trigger the login redirect.
|
||
//
|
||
// RateLimit gates /oauth/register specifically (per Codex review
|
||
// #372 round 2 — the DCR endpoint is open by RFC 7591 design,
|
||
// but unlimited writes to oauth_clients are an obvious DoS
|
||
// surface). The middleware short-circuits other /oauth/* paths
|
||
// because they're either session-bound or PKCE-bound; explicit
|
||
// per-endpoint limits arrive with TASK-959.
|
||
//
|
||
// No-op when SetOAuthServer hasn't been called or cloud mode is off.
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.requireCloudMode)
|
||
r.Use(s.SessionAuth)
|
||
r.Use(s.RateLimit)
|
||
s.registerOAuthRoutes(r)
|
||
})
|
||
|
||
// Prometheus scrape endpoint — exempt from the standard auth/CSRF stack
|
||
// (Prometheus can't present a session cookie or pass a CSRF header), but
|
||
// gated by a dedicated static bearer token. Without the gate, any
|
||
// unauthenticated caller on the network can read workspace counts, API
|
||
// usage patterns, and — via label enumeration — user/workspace IDs.
|
||
//
|
||
// The gate runs in three layers:
|
||
// 1. No PAD_METRICS_TOKEN → endpoint is open ONLY to loopback. Safe
|
||
// default for self-hosters running Prometheus on the same box.
|
||
// 2. PAD_METRICS_TOKEN set → "Authorization: Bearer <token>" required.
|
||
// Compared in constant time; empty/missing header → 401.
|
||
// 3. In either case the SecurityHeaders / rate-limit / logging chain
|
||
// already wraps this group from the outer r.Use() calls above.
|
||
if s.metrics != nil {
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.metricsAuth)
|
||
r.Handle("/metrics", promhttp.HandlerFor(s.metrics.Registry, promhttp.HandlerOpts{}))
|
||
})
|
||
}
|
||
|
||
// All other routes — full middleware stack
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(cors.Handler(cors.Options{
|
||
AllowedOrigins: parseCORSOrigins(s.corsOrigins),
|
||
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
|
||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Share-Password", "X-Bootstrap-Token"},
|
||
// Credentials flag is gated on an operator explicitly listing
|
||
// PAD_CORS_ORIGINS. The CLI uses Bearer tokens so the default
|
||
// "no CORS_ORIGINS set" path doesn't need credential sharing;
|
||
// leaving it off by default prevents cross-origin fetches from
|
||
// a browser on a different site from piggy-backing cookies
|
||
// on the victim's session.
|
||
AllowCredentials: corsAllowCredentials(s.corsOrigins),
|
||
MaxAge: 300,
|
||
}))
|
||
r.Use(s.TokenAuth)
|
||
r.Use(s.SessionAuth)
|
||
r.Use(s.RateLimit)
|
||
r.Use(s.CSRFProtect)
|
||
r.Use(s.RequireAuth)
|
||
// PLAN-1933 DR-4: block content-mutating requests from an
|
||
// authenticated cloud user whose email is unverified. Mounted
|
||
// AFTER RequireAuth so currentUser is already resolved; a no-op
|
||
// on self-host and for verified / unauthenticated callers. The
|
||
// method gate here covers the /api/v1 surface (session + PAT);
|
||
// the collab GET-upgrade, the OAuth-provider flow, and the MCP
|
||
// write path are gated at their own out-of-band mounts.
|
||
r.Use(s.RequireVerifiedEmail)
|
||
r.Use(jsonContentType)
|
||
|
||
// SSE endpoint (outside jsonContentType middleware — but inherits auth)
|
||
r.Get("/api/v1/events", s.handleSSE)
|
||
|
||
// User-scoped watch/nudge event stream (TASK-2533, DOC-2479).
|
||
// Unlike /api/v1/events above, this is NOT workspace-scoped — a
|
||
// caller's watches and addressed pushes can span
|
||
// every workspace they belong to. Lives alongside the other SSE
|
||
// endpoint for the same "outside jsonContentType, inherits auth"
|
||
// reason.
|
||
r.Get("/api/v1/events/stream", s.handleWatchEventsStream)
|
||
|
||
// Live-session presence (PLAN-2558 S1) — the READ side of the
|
||
// registry the stream above writes. Mounted here beside the
|
||
// endpoint it reports on rather than in the /api/v1 Route block
|
||
// below: the two are one feature, and a reader asking "what
|
||
// fills this list?" should find the answer on the adjacent
|
||
// line. Self-scoped; see handleListSessions on why there is
|
||
// deliberately no admin view.
|
||
r.Get("/api/v1/sessions", s.handleListSessions)
|
||
|
||
// WebSocket endpoint for Yjs-based collaborative editing on a
|
||
// single item (PLAN-1248). Lives outside jsonContentType for
|
||
// the same reason as SSE: the response is a WS upgrade, not
|
||
// JSON. Inherits the auth middleware chain — handleCollab
|
||
// then re-checks workspace access keyed on the item's
|
||
// workspace ID (the URL only carries itemID).
|
||
r.Get("/api/v1/collab/{itemID}", s.handleCollab)
|
||
|
||
// API routes
|
||
r.Route("/api/v1", func(r chi.Router) {
|
||
r.Get("/health", s.handleHealth)
|
||
r.Get("/health/live", s.handleHealthLive)
|
||
r.Get("/health/ready", s.handleHealthReady)
|
||
r.Get("/plan-limits", s.handleGetPlanLimits) // Public: billing page reads plan limits
|
||
r.Get("/unsubscribe", s.handleUnsubscribe) // Public: email opt-out (HMAC-signed)
|
||
|
||
// Server capabilities — public so the editor can fetch it
|
||
// pre-login and gate per-format rotate / crop UI on the
|
||
// processor's reach (TASK-878). The response is static for
|
||
// the lifetime of the binary; clients can cache freely.
|
||
r.Get("/server/capabilities", s.handleServerCapabilities)
|
||
|
||
// Auth endpoints (exempt from auth middleware)
|
||
r.Route("/auth", func(r chi.Router) {
|
||
r.Get("/session", s.handleSessionCheck)
|
||
r.Post("/bootstrap", s.handleBootstrap)
|
||
r.Post("/register", s.handleRegister)
|
||
r.Get("/check-username", s.handleCheckUsername)
|
||
r.Post("/login", s.handleLogin)
|
||
r.Post("/logout", s.handleLogout)
|
||
r.Get("/me", s.handleGetCurrentUser)
|
||
r.Patch("/me", s.handleUpdateCurrentUser)
|
||
|
||
// Password reset
|
||
r.Post("/forgot-password", s.handleForgotPassword)
|
||
r.Post("/reset-password", s.handleResetPassword)
|
||
// Localhost-only recovery escape hatch (self-host, non-cloud).
|
||
r.Post("/local-reset", s.handleLocalReset)
|
||
|
||
// Email verification (PLAN-1933 Wave 3b). Both are
|
||
// enumeration-safe and rate-limited (middleware_ratelimit.go
|
||
// reuses the PasswordReset bucket) and are already in
|
||
// RequireVerifiedEmail's exempt list so an unverified user
|
||
// can reach them to clear their own unverified state.
|
||
r.Post("/verify-email", s.handleVerifyEmail)
|
||
r.Post("/resend-verification", s.handleResendVerification)
|
||
|
||
// Two-factor authentication
|
||
r.Post("/2fa/setup", s.handleTOTPSetup)
|
||
r.Post("/2fa/verify", s.handleTOTPVerify)
|
||
r.Post("/2fa/disable", s.handleTOTPDisable)
|
||
r.Post("/2fa/login-verify", s.handleTOTPLoginVerify)
|
||
|
||
// Account management (GDPR)
|
||
r.Post("/delete-account", s.handleDeleteAccount)
|
||
r.Get("/export", s.handleExportAccount)
|
||
|
||
// User-scoped API tokens
|
||
r.Get("/tokens", s.handleListUserTokens)
|
||
r.Post("/tokens", s.handleCreateUserToken)
|
||
r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken)
|
||
r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken)
|
||
|
||
// Cloud: OAuth login/linking (called by pad-cloud sidecar, protected by cloud secret)
|
||
r.Post("/oauth-login", s.handleOAuthLogin)
|
||
r.Post("/oauth-link", s.handleOAuthLink)
|
||
r.Post("/oauth-unlink", s.handleOAuthUnlink)
|
||
|
||
// CLI browser-based auth flow
|
||
r.Post("/cli/sessions", s.handleCreateCLIAuthSession)
|
||
r.Get("/cli/sessions/{code}", s.handlePollCLIAuthSession)
|
||
r.Post("/cli/sessions/{code}/approve", s.handleApproveCLIAuthSession)
|
||
})
|
||
|
||
// Admin endpoints (admin-only, handlers check role internally)
|
||
r.Route("/admin", func(r chi.Router) {
|
||
r.Get("/settings", s.handleGetPlatformSettings)
|
||
r.Patch("/settings", s.handleUpdatePlatformSettings)
|
||
r.Post("/test-email", s.handleTestEmail)
|
||
|
||
// Cloud sidecar endpoints — only exist in cloud mode. requireCloudMode
|
||
// returns 404 outside cloud mode so a self-hosted deployment doesn't
|
||
// expose "Cloud mode not configured" to unauthenticated probes.
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.requireCloudMode)
|
||
r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins
|
||
r.Post("/stripe-customer-id", s.handleSetStripeCustomerID) // Cloud: sidecar stores Stripe customer ID after checkout
|
||
r.Get("/user-by-customer", s.handleGetUserByCustomerID) // Cloud: sidecar looks up user by Stripe customer ID
|
||
r.Post("/stripe-event-processed", s.handleStripeEventProcessed) // Cloud: sidecar webhook idempotency (TASK-696)
|
||
r.Post("/stripe-event-unmark", s.handleStripeEventUnmark) // Cloud: sidecar handler-failure rollback (TASK-736)
|
||
r.Post("/payment-failed", s.handlePaymentFailed) // Cloud: sidecar forwards invoice.payment_failed to trigger email (TASK-712)
|
||
|
||
// Admin Billing dashboard data (TASK-827 / PLAN-825). Proxies
|
||
// pad-cloud's /admin/metrics/billing for Stripe-derived stats
|
||
// (active subs, MRR, ARR, churn) and merges with local
|
||
// users-table aggregates (customers_by_plan, new_signups_30d).
|
||
// Always returns 200; degraded states (sidecar unreachable,
|
||
// Stripe not configured) are surfaced as flags in the body.
|
||
r.Get("/billing-stats", s.handleAdminBillingStats)
|
||
})
|
||
|
||
// User management
|
||
r.Get("/users", s.handleAdminListUsers)
|
||
r.Get("/users/{userID}", s.handleAdminGetUser)
|
||
r.Patch("/users/{userID}", s.handleAdminUpdateUser)
|
||
r.Post("/users/{userID}/reset-password", s.handleAdminResetPassword)
|
||
r.Get("/users/{userID}/workspaces", s.handleAdminGetUserWorkspaces)
|
||
r.Get("/users/{userID}/detail", s.handleAdminGetUserDetail)
|
||
r.Get("/users/{userID}/activity", s.handleAdminGetUserActivity)
|
||
r.Get("/users/{userID}/metrics", s.handleAdminGetUserMetrics)
|
||
r.Post("/users/{userID}/disable", s.handleAdminDisableUser)
|
||
r.Post("/users/{userID}/enable", s.handleAdminEnableUser)
|
||
r.Post("/users/{userID}/verify-email", s.handleAdminVerifyEmail)
|
||
|
||
// Invitations
|
||
r.Get("/invitations", s.handleAdminListInvitations)
|
||
r.Post("/invitations/{invID}/resend", s.handleAdminResendInvitation)
|
||
r.Delete("/invitations/{invID}", s.handleAdminDeleteInvitation)
|
||
|
||
// Plan limits
|
||
r.Get("/limits", s.handleAdminGetLimits)
|
||
r.Patch("/limits", s.handleAdminUpdateLimits)
|
||
|
||
// Platform stats
|
||
r.Get("/stats", s.handleAdminStats)
|
||
|
||
// MCP audit log — admin-only full-table view (TASK-960).
|
||
// Powers /console/admin/mcp-audit. Per-connection
|
||
// drilldown that users see for their own connections
|
||
// lives at /api/v1/connected-apps/{id}/audit (registered
|
||
// outside the admin group so non-admin users can read
|
||
// their own).
|
||
r.Get("/mcp-audit", s.handleAdminMCPAudit)
|
||
})
|
||
|
||
// Audit log (admin-only)
|
||
r.Get("/audit-log", s.handleAuditLog)
|
||
|
||
// MCP per-connection audit (TASK-960). Owner-only via the
|
||
// store query (user_id is one of the WHERE clauses);
|
||
// returns the requesting user's own MCP activity for one
|
||
// connection. The handler runs inside the standard
|
||
// /api/v1 auth-required group, so unauthenticated callers
|
||
// 401 here just like every other API endpoint.
|
||
r.Get("/connected-apps/{id}/audit", s.handleMCPConnectionAudit)
|
||
|
||
// Connected-apps management (TASK-954). Lists every
|
||
// active OAuth grant chain the user has authorized
|
||
// (Claude Desktop, Cursor, …) and lets them revoke one.
|
||
// Cloud-mode-gated because OAuth is a cloud-only
|
||
// surface — self-hosted deployments would always see
|
||
// an empty list.
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.requireCloudMode)
|
||
r.Get("/connected-apps", s.handleListConnectedApps)
|
||
r.Delete("/connected-apps/{id}", s.handleRevokeConnectedApp)
|
||
// PLAN-1519 / TASK-1524 / IDEA-1517 §3: mutation
|
||
// endpoints for the connections-page UI. Per-field
|
||
// patches rather than a general PATCH for cleaner
|
||
// error envelopes + audit shape.
|
||
r.Patch("/connected-apps/{id}/name", s.handleRenameConnectedApp)
|
||
r.Patch("/connected-apps/{id}/flags", s.handleUpdateConnectedAppFlags)
|
||
r.Post("/connected-apps/{id}/workspaces", s.handleAddConnectedAppWorkspace)
|
||
r.Delete("/connected-apps/{id}/workspaces/{slug}", s.handleRemoveConnectedAppWorkspace)
|
||
})
|
||
|
||
// Templates
|
||
r.Get("/templates", s.handleListTemplates)
|
||
|
||
// Convention Library
|
||
r.Get("/convention-library", s.handleConventionLibrary)
|
||
|
||
// Playbook Library
|
||
r.Get("/playbook-library", s.handlePlaybookLibrary)
|
||
|
||
// Single library entry by title (conventions first, then playbooks).
|
||
// TASK-1561 / PLAN-1560.
|
||
r.Get("/library/entry", s.handleLibraryEntry)
|
||
|
||
// URL import — fetch a remote page and return markdown.
|
||
// Side-effect-free; the client decides what to do with the
|
||
// markdown. See PLAN-1467 / TASK-1472 / internal/urlimport.
|
||
r.Post("/import/url", s.handleImportURL)
|
||
|
||
// Invitations (outside workspace scope)
|
||
r.Post("/invitations/{code}/accept", s.handleAcceptInvitation)
|
||
|
||
// Non-consuming invitation preview (BUG-1934). Public/pre-auth
|
||
// (exempted in isPublicAPIPath) so the logged-out /join page can
|
||
// prefill the invited email read-only and pick register-vs-login
|
||
// mode. Always HTTP 200 + rate limited (see middleware_ratelimit.go)
|
||
// so it can't be used to enumerate invite codes.
|
||
r.Get("/invitations/{code}/preview", s.handlePreviewInvitation)
|
||
|
||
// OAuth client public-info (PLAN-943 TASK-1027 sub-PR E).
|
||
// Read-only consent-screen support for OAuth clients
|
||
// registered via /oauth/register. Auth-required (inherits
|
||
// RequireAuth from the parent group); cloud-mode-gated so
|
||
// self-hosted deployments without an OAuth server don't
|
||
// expose a hollow endpoint. Returns four non-sensitive
|
||
// fields (client_id, client_name, logo_uri, redirect_uris)
|
||
// — see handlers_oauth_clients.go for the full leak-surface
|
||
// rationale.
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.requireCloudMode)
|
||
r.Get("/oauth/clients/{id}/public-info", s.handleOAuthClientPublicInfo)
|
||
})
|
||
|
||
// Share link resolution (outside workspace scope, no auth required)
|
||
r.Get("/s/{token}", s.handleResolveShareLink)
|
||
// Share-link asset bytes (BUG-2389 2b / TASK-2637): rendered image
|
||
// VARIANTS for attachments embedded in the shared content. Same
|
||
// public/no-auth group; protected links gate on a short-lived
|
||
// signed ref minted by handleResolveShareLink. Originals and
|
||
// file downloads are out of scope by authorization.
|
||
r.Get("/s/{token}/attachments/{attachmentID}", s.handleGetShareLinkAttachment)
|
||
|
||
// Claim-code redemption (PLAN-1519 / TASK-1521 / IDEA-1517 §4).
|
||
// POST /api/v1/oauth/claim with body {workspace, code} grants
|
||
// the calling OAuth connection access to one workspace via a
|
||
// stateless 6-digit HMAC code the user generated in the web
|
||
// UI's "Connect project" modal. Auth: standard /api/v1 chain
|
||
// (TokenAuth + RequireAuth); the handler itself short-circuits
|
||
// the side effect when the caller isn't an OAuth grant (PAT /
|
||
// CLI session) and 412s when the claim secret isn't wired.
|
||
r.Post("/oauth/claim", s.handleOAuthClaim)
|
||
|
||
// Workspaces
|
||
r.Route("/workspaces", func(r chi.Router) {
|
||
r.Get("/", s.handleListWorkspaces)
|
||
r.Post("/", s.handleCreateWorkspace)
|
||
r.Post("/import", s.handleImportWorkspace)
|
||
r.Put("/reorder", s.handleReorderWorkspaces)
|
||
|
||
// Soft-delete recovery (PLAN-1969 / TASK-1970). Both live
|
||
// OUTSIDE the /{slug} RequireWorkspaceAccess subrouter
|
||
// because that middleware resolves only LIVE workspaces
|
||
// (deleted_at IS NULL) and would 404 a soft-deleted one
|
||
// before the handler ran. The static "/deleted" segment is
|
||
// registered before the /{slug} param route so chi matches
|
||
// it exactly (static beats param); it lists the caller's own
|
||
// deleted-but-restorable workspaces. "/{slug}/restore"
|
||
// resolves the soft-deleted row itself and enforces
|
||
// owner-only authz inside the handler.
|
||
r.Get("/deleted", s.handleListDeletedWorkspaces)
|
||
r.Post("/{slug}/restore", s.handleRestoreWorkspace)
|
||
|
||
r.Route("/{slug}", func(r chi.Router) {
|
||
r.Use(s.RequireWorkspaceAccess)
|
||
|
||
r.Get("/", s.handleGetWorkspace)
|
||
r.Patch("/", s.handleUpdateWorkspace)
|
||
r.Delete("/", s.handleDeleteWorkspace)
|
||
r.Get("/export", s.handleExportWorkspace)
|
||
// Import a single playbook/convention artifact (Markdown
|
||
// + YAML frontmatter) into this workspace. Editor+ gate
|
||
// is enforced inside the handler against the destination
|
||
// collection.
|
||
r.Post("/import-artifact", s.handleImportArtifact)
|
||
|
||
// Activity (workspace level)
|
||
r.Get("/activity", s.handleListWorkspaceActivity)
|
||
|
||
// Claim-code generation + smart suppression (PLAN-1519
|
||
// / TASK-1525 / IDEA-1517 §4). Inherits
|
||
// RequireWorkspaceAccess so any member can pull a code
|
||
// for any workspace they belong to — membership IS
|
||
// the consent. See handlers_claim_code.go.
|
||
r.Get("/claim-code", s.handleWorkspaceClaimCode)
|
||
|
||
// Documents (v1 — will be replaced by items in Phase 2)
|
||
r.Route("/documents", func(r chi.Router) {
|
||
r.Get("/", s.handleListDocuments)
|
||
r.Post("/", s.handleCreateDocument)
|
||
|
||
r.Route("/{docID}", func(r chi.Router) {
|
||
r.Get("/", s.handleGetDocument)
|
||
r.Patch("/", s.handleUpdateDocument)
|
||
r.Delete("/", s.handleDeleteDocument)
|
||
r.Post("/restore", s.handleRestoreDocument)
|
||
|
||
// Versions
|
||
r.Get("/versions", s.handleListVersions)
|
||
r.Get("/versions/{versionID}", s.handleGetVersion)
|
||
|
||
// Activity (document level)
|
||
r.Get("/activity", s.handleListDocumentActivity)
|
||
})
|
||
})
|
||
|
||
// Collections (v2)
|
||
r.Route("/collections", func(r chi.Router) {
|
||
r.Get("/", s.handleListCollections)
|
||
r.Post("/", s.handleCreateCollection)
|
||
r.Route("/{collSlug}", func(r chi.Router) {
|
||
r.Get("/", s.handleGetCollection)
|
||
r.Patch("/", s.handleUpdateCollection)
|
||
r.Delete("/", s.handleDeleteCollection)
|
||
// Items within collection
|
||
r.Get("/items", s.handleListCollectionItems)
|
||
r.Post("/items", s.handleCreateItem)
|
||
// Pairs with /items-index — server-side checkbox
|
||
// progress so the collection page can render
|
||
// list/board/table progress badges without
|
||
// fetching item content (TASK-1349).
|
||
r.Get("/checkbox-progress", s.handleCollectionCheckboxProgress)
|
||
// Child-item completion progress for any collection
|
||
// (BUG-1509). Same visibility/guest-grant semantics
|
||
// as /plans-progress but collection-generic.
|
||
r.Get("/child-progress", s.handleCollectionChildrenProgress)
|
||
// Collection grants
|
||
r.Get("/grants", s.handleListCollectionGrants)
|
||
r.Post("/grants", s.handleCreateCollectionGrant)
|
||
r.Delete("/grants/{grantID}", s.handleDeleteCollectionGrant)
|
||
r.Get("/share-links", s.handleListCollectionShareLinks)
|
||
r.Post("/share-links", s.handleCreateCollectionShareLink)
|
||
// Saved views within collection
|
||
r.Get("/views", s.handleListViews)
|
||
r.Post("/views", s.handleCreateView)
|
||
r.Route("/views/{viewID}", func(r chi.Router) {
|
||
r.Patch("/", s.handleUpdateView)
|
||
r.Delete("/", s.handleDeleteView)
|
||
})
|
||
})
|
||
})
|
||
|
||
// Plans progress
|
||
r.Get("/plans-progress", s.handlePlansProgress)
|
||
|
||
// Skinny-projection cross-collection items list for the
|
||
// local-first read model bootstrap (PLAN-1343 / TASK-1344).
|
||
// Lives at workspace level — sibling to /plans-progress
|
||
// and /starred — so the path can't ever collide with an
|
||
// item slug under /items/{itemSlug}.
|
||
r.Get("/items-index", s.handleListItemsIndex)
|
||
|
||
// Delta-fetch sibling of /items-index: returns rows
|
||
// where seq > since, including tombstones, so a
|
||
// local-first read-model client can resume without
|
||
// re-downloading the whole index (PLAN-1343 / TASK-1354).
|
||
r.Get("/items-changes", s.handleListItemsChanges)
|
||
|
||
// User grants (all grants for a specific user in this workspace)
|
||
r.Get("/users/{userID}/grants", s.handleListUserGrants)
|
||
|
||
// Starred items
|
||
r.Get("/starred", s.handleListStarredItems)
|
||
|
||
// Distinct tags across the workspace (with item counts)
|
||
r.Get("/tags", s.handleListTags)
|
||
|
||
// Items (cross-collection, v2)
|
||
r.Get("/items", s.handleListItems)
|
||
// Bulk mutation (TASK-1668). Static segment must be
|
||
// registered before the /items/{itemSlug} param route
|
||
// so "bulk" isn't captured as an item slug.
|
||
r.Post("/items/bulk", s.handleBulkItems)
|
||
r.Route("/items/{itemSlug}", func(r chi.Router) {
|
||
r.Get("/", s.handleGetItem)
|
||
r.Patch("/", s.handleUpdateItem)
|
||
r.Delete("/", s.handleDeleteItem)
|
||
r.Post("/restore", s.handleRestoreItem)
|
||
r.Post("/move", s.handleMoveItem)
|
||
// Cross-workspace copy PREFLIGHT (PLAN-2357 /
|
||
// TASK-2364). Reports what a copy into another
|
||
// workspace would carry, drop and need, and
|
||
// leaves no trace a copy would have left — see
|
||
// handlers_items_copy_preflight.go for the exact
|
||
// scope of that guarantee. POST because the
|
||
// request carries a body (destination + override
|
||
// map), not because it mutates. The mutating
|
||
// sibling lands at /copy in TASK-2365.
|
||
r.Post("/copy/preflight", s.handleCopyItemPreflight)
|
||
// Cross-workspace copy, the MUTATION (PLAN-2357 /
|
||
// TASK-2365). Same request shape as the preflight
|
||
// above; with archive_source it is the move. Post-
|
||
// commit fanout is asymmetric — see
|
||
// handlers_items_copy.go. Registered after the more
|
||
// specific /copy/preflight, though chi's trie makes
|
||
// the order immaterial.
|
||
r.Post("/copy", s.handleCopyItem)
|
||
// Export a single playbook/convention item as a
|
||
// portable artifact (Markdown + YAML frontmatter).
|
||
// Gated by per-item visibility, not the workspace-
|
||
// export owner gate — a viewer who can see the item
|
||
// may export it.
|
||
r.Get("/export", s.handleExportItemArtifact)
|
||
r.Get("/versions", s.handleListItemVersions)
|
||
r.Get("/versions/{versionID}", s.handleGetItemVersion)
|
||
r.Post("/versions/{versionID}/restore", s.handleRestoreItemVersion)
|
||
r.Get("/activity", s.handleListItemActivity)
|
||
r.Get("/links", s.handleGetItemLinks)
|
||
r.Post("/links", s.handleCreateItemLink)
|
||
r.Get("/comments", s.handleListComments)
|
||
r.Post("/comments", s.handleCreateComment)
|
||
r.Get("/timeline", s.handleListItemTimeline)
|
||
r.Get("/children", s.handleGetItemChildren)
|
||
r.Get("/progress", s.handleGetItemProgress)
|
||
r.Get("/backlinks", s.handleGetItemBacklinks)
|
||
r.Get("/tasks", s.handleGetItemChildren) // deprecated alias
|
||
r.Get("/grants", s.handleListItemGrants)
|
||
r.Post("/grants", s.handleCreateItemGrant)
|
||
r.Delete("/grants/{grantID}", s.handleDeleteItemGrant)
|
||
r.Get("/share-links", s.handleListItemShareLinks)
|
||
r.Post("/share-links", s.handleCreateItemShareLink)
|
||
// Stars
|
||
r.Get("/star", s.handleGetItemStarStatus)
|
||
r.Post("/star", s.handleStarItem)
|
||
r.Delete("/star", s.handleUnstarItem)
|
||
// Watches (TASK-2533): durable per-item subscriptions
|
||
// for the padd event-stream / plugin-monitor nudge
|
||
// pipeline. `pad watch <ref>` / `pad watch remove <ref>`.
|
||
r.Post("/watch", s.handleCreateWatch)
|
||
r.Delete("/watch", s.handleDeleteWatch)
|
||
// Push (IDEA-2544 Phase 1): transient, self-addressed
|
||
// human→harness dispatch over the SAME watch-events
|
||
// bus/stream — no durable row, see handlePushToItem's
|
||
// doc comment. `pad push <ref> -m "message"`.
|
||
r.Post("/push", s.handlePushToItem)
|
||
})
|
||
|
||
// Links (v2)
|
||
r.Delete("/links/{linkID}", s.handleDeleteItemLink)
|
||
|
||
// Share links (workspace-scoped management)
|
||
r.Delete("/share-links/{linkID}", s.handleDeleteShareLink)
|
||
r.Get("/share-links/{linkID}/views", s.handleShareLinkViews)
|
||
|
||
// Comments (v2)
|
||
r.Route("/comments/{commentID}", func(r chi.Router) {
|
||
r.Patch("/", s.handleUpdateComment)
|
||
r.Delete("/", s.handleDeleteComment)
|
||
r.Post("/replies", s.handleCreateReply)
|
||
r.Post("/reactions", s.handleAddReaction)
|
||
r.Delete("/reactions/{emoji}", s.handleRemoveReaction)
|
||
})
|
||
|
||
// Role Board (cross-collection role-based view)
|
||
r.Get("/roles/board", s.handleRoleBoard)
|
||
r.Put("/roles/board/reorder", s.handleRoleBoardReorder)
|
||
r.Put("/roles/board/lane-order", s.handleRoleBoardLaneReorder)
|
||
|
||
// Agent Roles
|
||
r.Route("/agent-roles", func(r chi.Router) {
|
||
r.Get("/", s.handleListAgentRoles)
|
||
r.Post("/", s.handleCreateAgentRole)
|
||
r.Route("/{roleID}", func(r chi.Router) {
|
||
r.Get("/", s.handleGetAgentRole)
|
||
r.Patch("/", s.handleUpdateAgentRole)
|
||
r.Delete("/", s.handleDeleteAgentRole)
|
||
})
|
||
})
|
||
|
||
// Attachments
|
||
// POST /attachments — upload (TASK-871)
|
||
// GET /attachments/{attachmentID} — serve blob (TASK-872, supports ?variant=)
|
||
// HEAD /attachments/{attachmentID} — metadata only (TASK-877 file-chip enrichment)
|
||
// POST /attachments/{attachmentID}/transform — server-side rotate/crop (TASK-879/880)
|
||
//
|
||
// chi does not auto-route HEAD to the GET handler, so the
|
||
// editor's HEAD probe for size + MIME has to be registered
|
||
// explicitly. The handler short-circuits the streaming
|
||
// path on HEAD; http.ServeContent already strips the body
|
||
// on the seekable path.
|
||
r.Post("/attachments", s.handleUploadAttachment)
|
||
r.Get("/attachments", s.handleListWorkspaceAttachments)
|
||
r.Get("/attachments/{attachmentID}", s.handleGetAttachment)
|
||
r.Head("/attachments/{attachmentID}", s.handleGetAttachment)
|
||
r.Post("/attachments/{attachmentID}/transform", s.handleTransformAttachment)
|
||
r.Delete("/attachments/{attachmentID}", s.handleDeleteWorkspaceAttachment)
|
||
|
||
// Storage usage summary for Settings → Storage and other
|
||
// quota-aware UI surfaces (TASK-881). Cached behind a
|
||
// short TTL — see handleGetWorkspaceStorageUsage.
|
||
r.Get("/storage/usage", s.handleGetWorkspaceStorageUsage)
|
||
|
||
// Webhooks
|
||
r.Route("/webhooks", func(r chi.Router) {
|
||
r.Get("/", s.handleListWebhooks)
|
||
r.Post("/", s.handleCreateWebhook)
|
||
r.Route("/{webhookID}", func(r chi.Router) {
|
||
r.Delete("/", s.handleDeleteWebhook)
|
||
r.Post("/test", s.handleTestWebhook)
|
||
})
|
||
})
|
||
|
||
// API Tokens
|
||
r.Route("/tokens", func(r chi.Router) {
|
||
r.Get("/", s.handleListTokens)
|
||
r.Post("/", s.handleCreateToken)
|
||
r.Delete("/{tokenID}", s.handleDeleteToken)
|
||
})
|
||
|
||
// Members
|
||
r.Route("/members", func(r chi.Router) {
|
||
r.Get("/", s.handleListMembers)
|
||
r.Post("/invite", s.handleInviteMember)
|
||
r.Delete("/invitations/{invID}", s.handleCancelInvitation)
|
||
r.Delete("/{userID}", s.handleRemoveMember)
|
||
r.Patch("/{userID}", s.handleUpdateMemberRole)
|
||
r.Get("/{userID}/collection-access", s.handleGetMemberCollectionAccess)
|
||
r.Put("/{userID}/collection-access", s.handleSetMemberCollectionAccess)
|
||
})
|
||
|
||
// Me — current user's effective workspace context (role,
|
||
// collection access, grants). Open to any principal admitted
|
||
// by RequireWorkspaceAccess (members + guests).
|
||
r.Get("/me", s.handleGetMe)
|
||
|
||
// Dashboard (v2)
|
||
r.Get("/dashboard", s.handleGetDashboard)
|
||
|
||
// Workspace graph — {nodes, edges} for the 3D
|
||
// graph view (PLAN-1730 / TASK-1731). Active
|
||
// items by default; ?include_terminal=true for
|
||
// the full history.
|
||
r.Get("/graph", s.handleGetWorkspaceGraph)
|
||
|
||
// Project report — windowed throughput/flow/status
|
||
// stats (PLAN-1628 / TASK-1630).
|
||
r.Get("/report", s.handleGetReport)
|
||
// Per-user Insights layout prefs (PLAN-1628 / TASK-1634).
|
||
r.Get("/report/layout", s.handleGetReportLayout)
|
||
r.Put("/report/layout", s.handleSaveReportLayout)
|
||
|
||
// Project intelligence reads — next/standup/changelog
|
||
// (PLAN-1888 / TASK-1894). Mirror `pad project
|
||
// next|standup|changelog` (cmd/pad/main.go) — KEEP IN
|
||
// SYNC, see handlers_project_intel.go's doc comments.
|
||
// The MCP HTTP transport's dispatchProjectNext/Standup/
|
||
// Changelog (internal/mcp/dispatch_http_project.go)
|
||
// proxy directly to these three handlers (TASK-1916),
|
||
// so they need no separate sync-keeping.
|
||
r.Get("/next", s.handleGetProjectNext)
|
||
r.Get("/standup", s.handleGetProjectStandup)
|
||
r.Get("/changelog", s.handleGetProjectChangelog)
|
||
|
||
// Agent bootstrap (PLAN-1377 / TASK-1379) — single
|
||
// round-trip that returns workspace + user +
|
||
// collections + always-on conventions + roles +
|
||
// playbook metadata + dashboard + recent activity.
|
||
// Replaces the four /pad context-loading calls the
|
||
// skill used to make. Same shape via the MCP
|
||
// surfaces in TASK-1380.
|
||
r.Get("/agent/bootstrap", s.handleGetBootstrap)
|
||
|
||
// Playbook surface (PLAN-1377 / TASK-1382) — list /
|
||
// show / run for first-class invokable procedures.
|
||
// run is side-effect-free: it parses args per the
|
||
// playbook's declared spec and returns the body +
|
||
// bound args. The agent (skill or MCP-driven)
|
||
// executes the body; the server does not.
|
||
r.Get("/playbooks", s.handleListPlaybooks)
|
||
r.Get("/playbooks/{ref}", s.handleShowPlaybook)
|
||
r.Post("/playbooks/{ref}/run", s.handleRunPlaybook)
|
||
|
||
// Incremental sync — returns items changed since a timestamp
|
||
r.Get("/changes", s.handleGetChanges)
|
||
})
|
||
})
|
||
|
||
// Search
|
||
r.Get("/search", s.handleSearch)
|
||
|
||
// My watches (TASK-2533), cross-workspace — mirrors
|
||
// /auth/tokens' shape for a user-scoped-not-workspace-scoped
|
||
// resource. `pad watch list`. Create/delete are per-item and
|
||
// live under /workspaces/{ws}/items/{itemSlug}/watch instead
|
||
// (they need the item's workspace context to resolve the
|
||
// ref/slug the CLI's positional arg names).
|
||
r.Get("/watches", s.handleListWatches)
|
||
|
||
// MCP tool-surface descriptor (PLAN-1888 / TASK-1891). Serves
|
||
// the catalog JSON (the nine env.Catalog tools + per-action
|
||
// read_only flags) for the browser-side WebMCP layer to build
|
||
// tool descriptors. Inside the authed group so it inherits
|
||
// TokenAuth/SessionAuth/CSRFProtect/RequireAuth — same-origin
|
||
// session/token only, NOT the bearer-gated /mcp infra path.
|
||
// The handler nil-checks toolSurfaceJSON: 404 when the
|
||
// serializer hasn't been injected (mirrors the SetMCPTransport
|
||
// gating). Exposes only catalog descriptors — no route table,
|
||
// handler internals, or other server state.
|
||
r.Get("/mcp/tool-surface", s.handleMCPToolSurface)
|
||
})
|
||
|
||
// Cross-workspace wiki-link resolver (IDEA-1492). Resolves
|
||
// `[[workspace::REF]]` links emitted by the markdown renderer to
|
||
// the canonical item URL via a 302 redirect. Lives outside /api/v1
|
||
// because rendered HTML hrefs target user-facing paths, not API
|
||
// endpoints. Registered at the outer group level so chi matches
|
||
// these URLs ahead of the catch-all SPA handler. ACL check matches
|
||
// existing workspace-access semantics — 404 (not 403) on no-access
|
||
// so we don't leak whether a workspace exists.
|
||
//
|
||
// URL shape: `/-/r/{workspace}/{ref}` — the leading `-/r/` prefix
|
||
// is structurally impossible to collide with any user-namespace
|
||
// URL because username slugs require a leading letter (slugify
|
||
// rule), so no existing or future page route under
|
||
// /{username}/... can shadow this resolver, and no collection
|
||
// slug under /{u}/{ws}/{coll}/... can intercept it
|
||
// (slug grammar also requires letter-led). This replaces the
|
||
// earlier `/{username}/{workspace}/ref/{ref}` shape that risked
|
||
// collision with collection slugs named "ref" on pre-existing
|
||
// data (Codex round-2 P1.4 — picked Option B over a migration
|
||
// because the feature is unshipped, the new shape is more
|
||
// defensive, and the only cost is a frontend emit-shape change).
|
||
r.Get("/-/r/{workspace}/{ref}", s.handleResolveCrossWorkspaceRef)
|
||
}) // end r.Group (full middleware stack)
|
||
|
||
s.router = r
|
||
}
|
||
|
||
// SetWebUI sets the embedded web UI filesystem for serving the SPA.
|
||
func (s *Server) SetWebUI(fsys fs.FS) {
|
||
s.webFS = fsys
|
||
s.ensureRouter()
|
||
s.router.Handle("/*", s.spaHandler())
|
||
}
|
||
|
||
func (s *Server) spaHandler() http.Handler {
|
||
fileServer := http.FileServer(http.FS(s.webFS))
|
||
indexHTML, err := fs.ReadFile(s.webFS, "index.html")
|
||
if err != nil {
|
||
// Embedded web UI is missing — fail fast instead of silently
|
||
// serving blank HTML to every request. This indicates a broken
|
||
// build, so the server should refuse to start.
|
||
panic(fmt.Sprintf("spaHandler: failed to read embedded index.html: %v", err))
|
||
}
|
||
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
path := r.URL.Path
|
||
if strings.HasPrefix(path, "/api/") {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
cleanPath := strings.TrimPrefix(path, "/")
|
||
if cleanPath != "" {
|
||
if _, err := fs.Stat(s.webFS, cleanPath); err == nil {
|
||
if strings.Contains(path, "/immutable/") {
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
} else {
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
}
|
||
fileServer.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Generate per-request nonce for inline script CSP
|
||
nonce := generateCSPNonce()
|
||
|
||
// Inject nonce into inline <script> tags (SvelteKit bootstrap)
|
||
html := bytes.Replace(indexHTML, []byte("<script>"), []byte(fmt.Sprintf(`<script nonce="%s">`, nonce)), -1)
|
||
|
||
// Set nonce-based CSP (overrides the strict default from SecurityHeaders).
|
||
// - 'nonce-<N>' authorizes the SvelteKit bootstrap <script> we inject below.
|
||
// - 'strict-dynamic' lets that trusted script dynamically import() the
|
||
// SvelteKit runtime chunks without listing every build-hashed path. In
|
||
// browsers that honor CSP L3, 'strict-dynamic' supersedes the 'self'
|
||
// host-list, so an XSS gap that injects <script src="//evil.com"> is
|
||
// rejected even though 'self' is present. 'self' stays as a fallback
|
||
// for older browsers that don't implement strict-dynamic.
|
||
// - script-src-attr 'none' blocks inline event handlers regardless of the
|
||
// script-src nonce — per CSP spec, event attributes bypass script-src.
|
||
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
||
"default-src 'self'; script-src 'self' 'nonce-%s' 'strict-dynamic'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'",
|
||
nonce))
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write(html)
|
||
})
|
||
}
|
||
|
||
// ensureRouter lazily initializes the router on first use, so all Set*
|
||
// configuration is applied before the middleware chain is built.
|
||
func (s *Server) ensureRouter() {
|
||
s.routerOnce.Do(func() {
|
||
s.setupRouter()
|
||
})
|
||
}
|
||
|
||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
s.ensureRouter()
|
||
s.router.ServeHTTP(w, r)
|
||
}
|
||
|
||
// httpIdleTimeout caps the keep-alive idle window on every HTTP
|
||
// connection. Tracked here (not in handlers_events.go) because it
|
||
// applies to ALL connections, not just SSE — but it has a hard
|
||
// invariant relationship with sseKeepaliveInterval: an idle SSE
|
||
// stream is kept alive by periodic comment writes, and those must
|
||
// land more frequently than IdleTimeout or the connection will be
|
||
// closed mid-stream by the http.Server. The guard in
|
||
// handlers_events.go's init() enforces 3 × sseKeepaliveInterval <
|
||
// httpIdleTimeout so we tolerate one or two missed/dropped writes
|
||
// (network blip, scheduler hiccup) before tripping the deadline.
|
||
const httpIdleTimeout = 120 * time.Second
|
||
|
||
func (s *Server) ListenAndServe(addr string) error {
|
||
s.ensureRouter()
|
||
|
||
s.httpServer = &http.Server{
|
||
Addr: addr,
|
||
Handler: s.router,
|
||
ReadTimeout: 15 * time.Second,
|
||
ReadHeaderTimeout: 5 * time.Second,
|
||
IdleTimeout: httpIdleTimeout,
|
||
// Cap total header bytes (default 1 MB) to 64 KB — well above any
|
||
// legitimate request (cookies, auth, content-type, a few CSRF/CORS
|
||
// headers) and tight enough to cheaply reject header-flood DoS.
|
||
MaxHeaderBytes: 64 * 1024,
|
||
// WriteTimeout left at 0 — SSE connections are long-lived.
|
||
// Non-SSE handlers should use per-request context deadlines.
|
||
}
|
||
|
||
slog.Info("Pad server listening", "addr", addr)
|
||
return s.httpServer.ListenAndServe()
|
||
}
|
||
|
||
// Shutdown gracefully drains in-flight requests and stops the HTTP server.
|
||
// The provided context controls how long to wait for active connections.
|
||
func (s *Server) Shutdown(ctx context.Context) error {
|
||
if s.httpServer == nil {
|
||
return nil
|
||
}
|
||
return s.httpServer.Shutdown(ctx)
|
||
}
|
||
|
||
// Handler returns the configured HTTP handler (router).
|
||
// Useful for testing with httptest.NewServer.
|
||
func (s *Server) Handler() http.Handler {
|
||
s.ensureRouter()
|
||
return s.router
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
func jsonContentType(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if len(r.URL.Path) >= 7 && r.URL.Path[:7] == "/api/v1" {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||
w.WriteHeader(status)
|
||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||
slog.Error("failed to encode JSON response", "error", err)
|
||
}
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||
writeJSON(w, status, map[string]interface{}{
|
||
"error": map[string]string{
|
||
"code": code,
|
||
"message": message,
|
||
},
|
||
})
|
||
}
|
||
|
||
// writeInternalError logs the real error server-side and sends a generic
|
||
// message to the client. This prevents leaking SQL errors, file paths,
|
||
// and other internal details.
|
||
func writeInternalError(w http.ResponseWriter, err error) {
|
||
slog.Error("internal server error", "error", err)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "An internal error occurred")
|
||
}
|
||
|
||
// defaultJSONBodyLimit is the default cap applied to JSON request bodies
|
||
// by decodeJSON. Every /api/* POST/PATCH is comfortably small in practice
|
||
// (items, collections, auth payloads — all well under 100 KB), so the
|
||
// 2 MB cap is several orders of magnitude above real traffic while still
|
||
// cheap to hold in memory per request. Callers who legitimately need
|
||
// more — bulk imports — should call decodeJSONWithLimit explicitly.
|
||
const defaultJSONBodyLimit = 2 << 20 // 2 MiB
|
||
|
||
// decodeJSON reads and unmarshals the JSON body into v. Wraps the body in
|
||
// http.MaxBytesReader so an attacker can't exhaust memory by POSTing a
|
||
// multi-GB JSON blob — without this, json.NewDecoder.Decode happily
|
||
// streams the whole body into a single allocation.
|
||
func decodeJSON(r *http.Request, v interface{}) error {
|
||
return decodeJSONWithLimit(r, v, defaultJSONBodyLimit)
|
||
}
|
||
|
||
// decodeJSONWithLimit is the size-configurable variant. Use this for
|
||
// endpoints that accept large payloads (e.g. bulk-import) where the
|
||
// default cap is too small — but always pass an explicit cap, never
|
||
// remove the wrapper.
|
||
func decodeJSONWithLimit(r *http.Request, v interface{}, maxBytes int64) error {
|
||
// http.MaxBytesReader.Close() is a no-op; the decoder leaves r.Body at
|
||
// EOF anyway. Setting this here also lets the server return a 413
|
||
// automatically via the error we wrap below.
|
||
if r.Body != nil {
|
||
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||
return fmt.Errorf("invalid JSON: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// getWorkspaceID resolves workspace slug/ID from the request.
|
||
// If RequireWorkspaceAccess already resolved the workspace, reads from context.
|
||
// Otherwise falls back to direct resolution (for unauthenticated paths).
|
||
func (s *Server) getWorkspaceID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
// Fast path: already resolved by RequireWorkspaceAccess middleware
|
||
if wsID, ok := r.Context().Value(ctxResolvedWorkspaceID).(string); ok && wsID != "" {
|
||
return wsID, true
|
||
}
|
||
|
||
// Slow path: resolve directly (should rarely happen — only for routes
|
||
// that don't go through RequireWorkspaceAccess)
|
||
slugOrID := chi.URLParam(r, "slug")
|
||
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return "", false
|
||
}
|
||
if ws == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
||
return "", false
|
||
}
|
||
return ws.ID, true
|
||
}
|
||
|
||
// getWorkspace returns the full workspace object resolved by middleware.
|
||
// Falls back to direct resolution for routes without RequireWorkspaceAccess.
|
||
func (s *Server) getWorkspace(w http.ResponseWriter, r *http.Request) (*models.Workspace, bool) {
|
||
// Fast path: use middleware-resolved ID
|
||
if wsID, ok := r.Context().Value(ctxResolvedWorkspaceID).(string); ok && wsID != "" {
|
||
ws, err := s.store.GetWorkspaceByID(wsID)
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return nil, false
|
||
}
|
||
if ws != nil {
|
||
return ws, true
|
||
}
|
||
}
|
||
|
||
// Slow path: resolve from URL param
|
||
slugOrID := chi.URLParam(r, "slug")
|
||
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return nil, false
|
||
}
|
||
if ws == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
||
return nil, false
|
||
}
|
||
return ws, true
|
||
}
|
||
|
||
// visibleCollectionIDs returns the set of collection IDs the current user can
|
||
// see in the given workspace. Returns nil if the user has "all" access (no
|
||
// filtering needed), or a non-nil slice for "specific" access. Unauthenticated
|
||
// users (fresh install) always get nil (all access), as do platform admins —
|
||
// but ONLY over a cookie session. Bearer-authed admins (CLI / PAT / MCP —
|
||
// detected via isBearerAuth) fall through to the store lookup and are scoped
|
||
// to their actual membership, matching RequireWorkspaceAccess's admin-bypass
|
||
// suppression for bearer auth (BUG-1616/1617) and reportVisibleCollections'
|
||
// gate (handlers_reports.go). Fixes BUG-1917 — this was the last of the
|
||
// bearer-gate consumers (buildDashboardResponse, handleListItems, the graph
|
||
// handler, handleCreateItem's collection-visibility check, and every other
|
||
// direct caller) still granting a bearer admin an unrestricted view.
|
||
func (s *Server) visibleCollectionIDs(r *http.Request, workspaceID string) ([]string, error) {
|
||
user := currentUser(r)
|
||
if user == nil || (user.Role == "admin" && !isBearerAuth(r)) {
|
||
return nil, nil // No filtering for admins (cookie session) or unauthenticated
|
||
}
|
||
return s.store.VisibleCollectionIDs(workspaceID, user.ID)
|
||
}
|
||
|
||
// requireCollectionFullyVisible checks that the collection is visible to the
|
||
// requesting user under FULL-collection-access semantics (BUG-1920 —
|
||
// codex R2 follow-up). This is deliberately STRICTER than
|
||
// handleGetCollection's inline visibleCollectionIDs + isCollectionVisible
|
||
// check: VisibleCollectionIDs (workspace_members.go) intentionally folds in
|
||
// collections that are visible ONLY via an item-level grant, "so the
|
||
// collection appears in navigation" — item-level filtering is left to the
|
||
// handlers. That nav-lenient shape is correct for handleGetCollection
|
||
// (viewing collection metadata), but WRONG here: this helper's only callers
|
||
// are the four handlers that mint or list a collection-wide share link or
|
||
// grant, where passing would hand out (or reveal) access to the ENTIRE
|
||
// collection — an item grant on a single item inside it must NOT qualify.
|
||
//
|
||
// Mirrors reportVisibleCollections' fullCollIDs narrowing
|
||
// (handlers_reports.go): when the caller holds any item-level grants, the
|
||
// acceptable set narrows from the nav-lenient VisibleCollectionIDs set to
|
||
// the full-access-only set (guestResourceFilter's fullCollIDs — collection
|
||
// grants + member_collection_access + system collections, excluding
|
||
// item-grant-only collections).
|
||
//
|
||
// Writes a 404 and returns false if not visible; callers should invoke this
|
||
// immediately after resolving a collection by slug/ID.
|
||
func (s *Server) requireCollectionFullyVisible(w http.ResponseWriter, r *http.Request, workspaceID string, coll *models.Collection) bool {
|
||
visible, err := s.checkCollectionFullyVisible(r, workspaceID, coll.ID)
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return false
|
||
}
|
||
if !visible {
|
||
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// requireItemVisible checks that the item's collection is visible to the
|
||
// requesting user. For guests with item-level grants, also verifies that the
|
||
// specific item is granted (not just the collection). Writes a 404 and returns
|
||
// false if not. Callers should invoke this immediately after resolving an item
|
||
// by slug/ID.
|
||
//
|
||
// Thin shim over checkItemVisible — see that helper for the rules. This
|
||
// wrapper exists for the legacy call-sites that already hold a *http.Request
|
||
// pre-populated by RequireWorkspaceAccess; new callers without that middleware
|
||
// (e.g. handlers_ref_resolver.go) should use checkItemVisible directly with
|
||
// a manually-derived role.
|
||
func (s *Server) requireItemVisible(w http.ResponseWriter, r *http.Request, workspaceID string, item *models.Item) bool {
|
||
visible, err := s.checkItemVisible(workspaceID, item, currentUser(r), workspaceRole(r), isBearerAuth(r))
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return false
|
||
}
|
||
if !visible {
|
||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// checkItemVisible is the context-free visibility decision. Returns (true,
|
||
// nil) when the (user, role) pair can see `item` under the same rules
|
||
// `requireItemVisible` enforces. Centralizes the rule set so the
|
||
// resolver route (IDEA-1492) and the middleware-gated handlers can't drift.
|
||
//
|
||
// Inputs:
|
||
//
|
||
// - workspaceID — the resolved workspace's UUID.
|
||
// - item — already-loaded item (so the helper doesn't re-resolve and
|
||
// accidentally apply a different lookup path).
|
||
// - user — currentUser(r) at the call site; nil for unauthenticated.
|
||
// - role — workspaceRole(r) at the call site, or the role derived
|
||
// manually by callers operating outside RequireWorkspaceAccess.
|
||
// - isBearer — isBearerAuth(r) at the call site (BUG-1918). Narrows
|
||
// rule 3 below the same way BUG-1616/1617 narrowed the analogous
|
||
// bypasses in visibleCollectionIDs, resolverWorkspaceRole, and
|
||
// guestResourceFilterCore: a platform admin's global read access is
|
||
// a cookie-session / web-UI affordance only. A bearer-borne admin
|
||
// (CLI / PAT / MCP) who is a restricted workspace member must fall
|
||
// through to the same per-collection filter every other member
|
||
// faces — otherwise BUG-1917's list-level scoping is bypassable by
|
||
// guessing a ref and hitting the single-item endpoints directly.
|
||
//
|
||
// Rules (in order):
|
||
//
|
||
// 1. Tokenized-nil-user bypass: when currentUser == nil AND role is one
|
||
// of the synthesized-by-middleware roles ("owner" for fresh-install,
|
||
// "editor" for legacy workspace-scoped API tokens),
|
||
// RequireWorkspaceAccess has already authorized the request — there
|
||
// is no per-user filter to apply, and the user-nil rejection at
|
||
// rule 2 would false-404 these callers. The bypass is SCOPED TO
|
||
// user == nil — real authenticated members with role "owner" /
|
||
// "editor" must still fall through to the per-collection filter,
|
||
// otherwise a restricted editor member would bypass their own
|
||
// collection_access="specific" gate (Codex round-3 regression of
|
||
// the round-2 P1.1 fix).
|
||
// 2. nil user past rule 1 → not visible. Anonymous viewers without a
|
||
// tokenized role have no item-read access (share links own the
|
||
// public-read surface via /s/{token}).
|
||
// 3. Admin user via cookie session (user.Role == "admin" && !isBearer)
|
||
// → always visible. Bearer-borne admins fall through to rule 4.
|
||
// 4. Otherwise: replay the guestResourceFilterCore + member-collection-
|
||
// access logic that requireItemVisible used to inline, with a system-
|
||
// collections union added to the item-grants branch (Codex round-2
|
||
// P1.2 — restricted members with conventions/playbooks access plus
|
||
// an unrelated item grant previously 404'd on system-collection items
|
||
// because the item-grants branch only checked direct grants + the
|
||
// member's explicit collection-access list).
|
||
func (s *Server) checkItemVisible(workspaceID string, item *models.Item, user *models.User, role string, isBearer bool) (bool, error) {
|
||
return s.checkItemVisibleQ(s.store.Q(), workspaceID, item, user, role, isBearer)
|
||
}
|
||
|
||
// checkItemVisibleQ is checkItemVisible parameterized over its executor, so
|
||
// the cross-workspace copy's attachment authorizer can run the same
|
||
// visibility rule on the copy transaction's own connection instead of the
|
||
// pool while the transaction holds both workspace advisory locks
|
||
// (BUG-2409). Every other caller goes through the pool wrapper above; the
|
||
// decision logic is identical by construction — one body, two executors.
|
||
func (s *Server) checkItemVisibleQ(q store.Queryer, workspaceID string, item *models.Item, user *models.User, role string, isBearer bool) (bool, error) {
|
||
// Tokenized-nil-user bypass. RequireWorkspaceAccess synthesizes
|
||
// "owner" on fresh installs (UserCount == 0, currentUser == nil) and
|
||
// "editor" for legacy workspace-scoped API tokens (currentUser ==
|
||
// nil but tokenWorkspaceID matches). Both are authorized by the
|
||
// middleware already. Real authenticated users with these roles
|
||
// (workspace owners, member.Role=="editor", …) must NOT short-circuit
|
||
// here — they have to walk the per-collection filter so
|
||
// collection_access="specific" + member_collection_access actually
|
||
// gates them (Codex round-3 — the round-2 fix dropped the
|
||
// `user == nil` qualifier and accidentally disabled the gate for
|
||
// every real editor too).
|
||
if user == nil && (role == "owner" || role == "editor") {
|
||
return true, nil
|
||
}
|
||
if user == nil {
|
||
return false, nil
|
||
}
|
||
// Admin sees everything, but only for cookie-session auth (matches
|
||
// visibleCollectionIDs's nil-filter shape). Bearer-borne admins
|
||
// (BUG-1918) fall through to the same per-collection filter every
|
||
// other member faces.
|
||
if user.Role == "admin" && !isBearer {
|
||
return true, nil
|
||
}
|
||
|
||
// Visibility filter: nil = unrestricted; non-nil = restricted to the slice.
|
||
visibleIDs, err := s.store.VisibleCollectionIDsQ(q, workspaceID, user.ID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !isCollectionVisible(item.CollectionID, visibleIDs) {
|
||
return false, nil
|
||
}
|
||
|
||
// Replay guestResourceFilterCore's logic without the *http.Request
|
||
// dependency. Member-with-all-access short-circuits to "no item-level
|
||
// filter"; guests + restricted members get the grant filter.
|
||
if role != "guest" {
|
||
member, err := s.store.GetWorkspaceMemberQ(q, workspaceID, user.ID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if member != nil && (member.CollectionAccess == "all" || member.CollectionAccess == "") {
|
||
// Full collection access — visibleIDs filter already passed.
|
||
return true, nil
|
||
}
|
||
}
|
||
|
||
grantCollIDs, grantedItemIDs, err := s.store.GuestVisibleResourcesQ(q, workspaceID, user.ID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if len(grantedItemIDs) == 0 {
|
||
// No item-level grants in play. visibleCollectionIDs already
|
||
// determined the collection is reachable; visibility stands.
|
||
return true, nil
|
||
}
|
||
|
||
// Item-level grants are active. The item is visible when:
|
||
// a) the collection itself has a full grant (any item passes), OR
|
||
// b) for restricted members: the collection is in member_collection_access
|
||
// (the member's explicit collection-access list), OR
|
||
// c) the item's collection is a system collection — restricted
|
||
// members always retain access to system collections (conventions,
|
||
// playbooks, …); pre-round-2 this branch missed the system-
|
||
// collections union that guestResourceFilterCore performed, so a
|
||
// restricted member with an item grant in a non-system collection
|
||
// was 404'd on a system-collection item they were entitled to see.
|
||
// d) the specific item is in the granted-items list.
|
||
for _, id := range grantCollIDs {
|
||
if id == item.CollectionID {
|
||
return true, nil
|
||
}
|
||
}
|
||
if role != "guest" {
|
||
// member_collection_access path — restricted members see their
|
||
// explicit collection-access list as full grants alongside any
|
||
// item-level grants.
|
||
memberColls, err := s.store.GetMemberCollectionAccessQ(q, workspaceID, user.ID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, id := range memberColls {
|
||
if id == item.CollectionID {
|
||
return true, nil
|
||
}
|
||
}
|
||
// System-collections union — mirror guestResourceFilterCore's
|
||
// pre-round-2 behavior. ListSystemCollectionIDs is a workspace-
|
||
// scoped lookup (no per-user filter), so the same call is correct
|
||
// for every restricted member in the workspace.
|
||
sysColls, err := s.store.ListSystemCollectionIDsQ(q, workspaceID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, id := range sysColls {
|
||
if id == item.CollectionID {
|
||
return true, nil
|
||
}
|
||
}
|
||
}
|
||
for _, id := range grantedItemIDs {
|
||
if id == item.ID {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// isItemVisibleToGuest checks if an item is visible given grant-based access,
|
||
// considering both full-collection grants and individual item grants.
|
||
// When fullCollIDs and grantedItemIDs are both nil, always returns true (no grant filtering).
|
||
func (s *Server) isItemVisibleToGuest(r *http.Request, workspaceID string, item *models.Item, fullCollIDs, grantedItemIDs []string) bool {
|
||
if fullCollIDs == nil && grantedItemIDs == nil {
|
||
return true
|
||
}
|
||
// Full collection grant covers all items in the collection
|
||
for _, id := range fullCollIDs {
|
||
if id == item.CollectionID {
|
||
return true
|
||
}
|
||
}
|
||
// Otherwise, the specific item must be in the granted items list
|
||
for _, id := range grantedItemIDs {
|
||
if id == item.ID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// guestResourceFilter returns the full-collection IDs and granted item IDs for
|
||
// the current user if they need item-level grant filtering. Returns nil/nil for:
|
||
// - unauthenticated users
|
||
// - admin users
|
||
// - members with "all" collection access (grants should merge, not replace)
|
||
// For guests: returns direct collection grants as fullCollIDs + item grants.
|
||
// For restricted members: returns member_collection_access + system collections
|
||
// + direct collection grants as fullCollIDs, plus item grants as grantedItemIDs.
|
||
// This ensures item grants are additive to the member's existing access.
|
||
func (s *Server) guestResourceFilter(r *http.Request, workspaceID string) (fullCollIDs, grantedItemIDs []string, err error) {
|
||
return s.guestResourceFilterCore(r, workspaceID, false)
|
||
}
|
||
|
||
// guestResourceFilterIncludeDeletedItems is the delta-sync variant
|
||
// of guestResourceFilter. It uses GuestVisibleResourcesIncludeDeleted
|
||
// under the hood so soft-deleted granted items still surface in the
|
||
// resulting ID set. Used by /items-changes (TASK-1354) so a guest /
|
||
// restricted member with an item-level grant still receives the
|
||
// `deleted:true` row when their granted item is soft-deleted —
|
||
// without this variant the grant ID vanishes before the delta
|
||
// query runs and the client keeps the stale entry forever (Codex
|
||
// review of TASK-1354 round 1 [P1]).
|
||
func (s *Server) guestResourceFilterIncludeDeletedItems(r *http.Request, workspaceID string) (fullCollIDs, grantedItemIDs []string, err error) {
|
||
return s.guestResourceFilterCore(r, workspaceID, true)
|
||
}
|
||
|
||
// guestResourceFilterCore is the request-scoped wrapper around
|
||
// Store.ResolveBacklinksVisibility. Delegates the role-determination
|
||
// + merge logic to the store helper so cross-workspace backlinks
|
||
// callers can reuse the same code path without a request context
|
||
// (PLAN-1593 / TASK-1597).
|
||
//
|
||
// The wrapper still exists for two reasons:
|
||
// - Admin bypass uses currentUser(r).Role rather than re-fetching
|
||
// the user (saves one DB roundtrip per request on the hot path).
|
||
// - The signature `(r *http.Request, workspaceID, includeDeleted) →
|
||
// (fullCollIDs, grantedItemIDs, err)` is established across many
|
||
// handlers — keeping it stable avoids a sprawling refactor.
|
||
//
|
||
// Admin bypass policy (BUG-1617 — companion to BUG-1616): the
|
||
// short-circuit only fires for cookie session auth. Bearer-borne
|
||
// admins (CLI / PAT / MCP — detected via isBearerAuth) fall through
|
||
// to the store helper which runs the regular member/grants pipeline
|
||
// against the platform-admin's actual workspace_members row. Without
|
||
// this, an admin's MCP token could pass RequireWorkspaceAccess's
|
||
// bearer gate (BUG-1616) on the URL's workspace but still see
|
||
// unrestricted visibility filters in any downstream backlinks /
|
||
// activity / delta-sync query that ran for the same workspace.
|
||
//
|
||
// The includeDeletedItems flag swaps the underlying grant query.
|
||
func (s *Server) guestResourceFilterCore(r *http.Request, workspaceID string, includeDeletedItems bool) (fullCollIDs, grantedItemIDs []string, err error) {
|
||
return s.guestResourceFilterCoreQ(s.store.Q(), r, workspaceID, includeDeletedItems)
|
||
}
|
||
|
||
// guestResourceFilterCoreQ is guestResourceFilterCore parameterized over its
|
||
// executor — the cross-workspace copy's attachment authorizer runs it on the
|
||
// copy transaction's connection (BUG-2409); everything else uses the pool
|
||
// wrapper above.
|
||
func (s *Server) guestResourceFilterCoreQ(q store.Queryer, r *http.Request, workspaceID string, includeDeletedItems bool) (fullCollIDs, grantedItemIDs []string, err error) {
|
||
user := currentUser(r)
|
||
if user == nil {
|
||
return nil, nil, nil
|
||
}
|
||
authIsBearer := isBearerAuth(r)
|
||
if user.Role == "admin" && !authIsBearer {
|
||
return nil, nil, nil
|
||
}
|
||
// Delegate to the request-independent helper. The store-side
|
||
// helper duplicates the admin check via GetUser, but for the
|
||
// request hot path we short-circuit above (cookie admin only)
|
||
// so the duplicate lookup never fires for the common case.
|
||
return s.store.ResolveBacklinksVisibilityQ(q, user.ID, workspaceID, includeDeletedItems, authIsBearer)
|
||
}
|
||
|
||
// isCollectionVisible checks if a collection ID is in the visible set.
|
||
// If visibleIDs is nil, all collections are visible.
|
||
func isCollectionVisible(collectionID string, visibleIDs []string) bool {
|
||
if visibleIDs == nil {
|
||
return true
|
||
}
|
||
for _, id := range visibleIDs {
|
||
if id == collectionID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// filterUserGrantsForCaller narrows collGrants/itemGrants — the TARGET
|
||
// user's grants, already loaded by the caller — down to what the CALLER can
|
||
// see. Only meaningful when caller != target; handleListUserGrants skips
|
||
// calling this for self-queries (a user can always see their own grants).
|
||
//
|
||
// BUG-1928: handleListUserGrants returned the target's raw grants
|
||
// (including collection_id/item_id) to any workspace owner unconditionally.
|
||
// A restricted owner (collection_access="specific") could enumerate
|
||
// hidden-resource IDs this way — the disclosure half of the primitive
|
||
// BUG-1923's handlers fixed the action half of (know-the-ID → operate-on-it).
|
||
//
|
||
// Reuses the existing guestResourceFilter/isCollectionVisible/
|
||
// isItemVisibleToGuest helpers rather than a bespoke visibility pass:
|
||
// guestResourceFilter's fullCollIDs is already the STRICT full-access set
|
||
// (member_collection_access ∪ system collections ∪ direct collection
|
||
// grants, excluding item-grant-only collections) — the same strict set
|
||
// requireCollectionFullyVisible narrows to — so collection grants are
|
||
// filtered directly against it with no extra narrowing step.
|
||
//
|
||
// Filtering is pure ID-set membership: no parent collection/item lookup is
|
||
// needed to decide visibility, so a grant on a soft- (or even hard-)
|
||
// deleted parent is filtered the same as any other grant, matching #798's
|
||
// "still revocable/inspectable" precedent for grants on archived resources.
|
||
// The one exception is item grants, which only carry an item_id — those are
|
||
// resolved to their collection_id via a single bulk GetItemCollectionRefs
|
||
// call (state-agnostic; no deleted_at filter) rather than N per-grant
|
||
// lookups.
|
||
func (s *Server) filterUserGrantsForCaller(r *http.Request, workspaceID string, collGrants []models.CollectionGrant, itemGrants []models.ItemGrant) ([]models.CollectionGrant, []models.ItemGrant, error) {
|
||
fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, workspaceID)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if fullCollIDs == nil && grantedItemIDs == nil {
|
||
// Unrestricted caller (admin/cookie session, or a member with
|
||
// full collection access) — no filtering, and no further store
|
||
// calls needed.
|
||
return collGrants, itemGrants, nil
|
||
}
|
||
|
||
filteredColl := make([]models.CollectionGrant, 0, len(collGrants))
|
||
for _, g := range collGrants {
|
||
if isCollectionVisible(g.CollectionID, fullCollIDs) {
|
||
filteredColl = append(filteredColl, g)
|
||
}
|
||
}
|
||
|
||
filteredItem := make([]models.ItemGrant, 0, len(itemGrants))
|
||
if len(itemGrants) > 0 {
|
||
itemIDs := make([]string, len(itemGrants))
|
||
for i, g := range itemGrants {
|
||
itemIDs[i] = g.ItemID
|
||
}
|
||
refs, err := s.store.GetItemCollectionRefs(workspaceID, itemIDs)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
collByItem := make(map[string]string, len(refs))
|
||
for _, ref := range refs {
|
||
collByItem[ref.ID] = ref.CollectionID
|
||
}
|
||
for _, g := range itemGrants {
|
||
collID, ok := collByItem[g.ItemID]
|
||
if !ok {
|
||
// item_grants.item_id is ON DELETE CASCADE, so a grant
|
||
// row can't outlive its item — this should be
|
||
// unreachable. Exclude defensively rather than show a
|
||
// grant with no resolvable parent.
|
||
continue
|
||
}
|
||
item := &models.Item{ID: g.ItemID, CollectionID: collID}
|
||
if s.isItemVisibleToGuest(r, workspaceID, item, fullCollIDs, grantedItemIDs) {
|
||
filteredItem = append(filteredItem, g)
|
||
}
|
||
}
|
||
}
|
||
|
||
return filteredColl, filteredItem, nil
|
||
}
|
||
|
||
// requireEditPermission checks if the user has edit access to the given item.
|
||
// For regular members (editor/owner), this uses the standard role check.
|
||
// For members with insufficient roles (e.g., viewers), it falls back to
|
||
// grant-based permissions so grants can override the base role.
|
||
// For guests, it resolves the effective permission from grants directly.
|
||
// Returns true if the request should continue, false if it was rejected with a 403.
|
||
//
|
||
// NEVER call this with a workspace ID other than the one the current
|
||
// request's URL resolved to. The `workspaceID` parameter makes it look
|
||
// reusable for a second workspace; it is not. The editor/owner fast path
|
||
// below reads workspaceRole(r), which RequireWorkspaceAccess populates only
|
||
// for the URL's workspace, so passing workspace B's ID applies workspace A's
|
||
// role — privilege escalation. Use AuthorizeCrossWorkspaceEdit
|
||
// (authz_cross_workspace.go) for any other workspace; it also checks the
|
||
// OAuth/MCP consent allow-list, which this helper does not (DR-10 of
|
||
// PLAN-2357).
|
||
func (s *Server) requireEditPermission(w http.ResponseWriter, r *http.Request, workspaceID string, itemID, collectionID string) bool {
|
||
role := workspaceRole(r)
|
||
|
||
// Editors and owners always have edit access
|
||
if role != "guest" && requireRole(r, "editor") {
|
||
return true
|
||
}
|
||
|
||
// For guests and members with insufficient role (e.g., viewers),
|
||
// check grant-based permissions as an override.
|
||
user := currentUser(r)
|
||
if user == nil {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
|
||
return false
|
||
}
|
||
|
||
perm, err := s.store.ResolveUserPermission(workspaceID, user.ID, itemID, collectionID)
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return false
|
||
}
|
||
if permissionLevel(perm) < permissionLevel("edit") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// resolveWorkspace resolves a workspace by slug or UUID, scoped to the
|
||
// authenticated user's accessible workspaces when a user context is present.
|
||
// Returns nil (not an error) if no workspace is found.
|
||
func (s *Server) resolveWorkspace(slugOrID string, user *models.User) (*models.Workspace, error) {
|
||
// 1. Is it a UUID? Try resolving by ID first, then fall back to slug.
|
||
// A workspace slug could be UUID-shaped (e.g. imported data), so we
|
||
// can't short-circuit here.
|
||
if isUUID(slugOrID) {
|
||
ws, err := s.store.GetWorkspaceByID(slugOrID)
|
||
if ws != nil || err != nil {
|
||
return ws, err
|
||
}
|
||
// Not found by ID — fall through to slug-based resolution
|
||
}
|
||
|
||
// 2. No authenticated user — fall back to global slug lookup
|
||
// (fresh install, or pre-auth paths)
|
||
if user == nil {
|
||
return s.store.GetWorkspaceBySlug(slugOrID)
|
||
}
|
||
|
||
// 3. Admin users — global slug lookup (admins can see all workspaces)
|
||
if user.Role == "admin" {
|
||
return s.store.GetWorkspaceBySlug(slugOrID)
|
||
}
|
||
|
||
// 4. Auth-scoped slug resolution: find workspaces where user is owner or member
|
||
workspaces, err := s.store.GetWorkspacesBySlugForUser(slugOrID, user.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(workspaces) == 1 {
|
||
return &workspaces[0], nil
|
||
}
|
||
if len(workspaces) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// Ambiguous: multiple workspaces match — this should be rare.
|
||
// For now, return the first one. The 409 disambiguation is only needed
|
||
// when we actually have per-owner slug uniqueness (after the unique
|
||
// constraint is changed). Currently slugs are globally unique.
|
||
return &workspaces[0], nil
|
||
}
|
||
|
||
// isUUID is defined in handlers_items.go
|
||
|
||
// getWorkspaceDocument resolves workspace slug and document ID from URL params.
|
||
func (s *Server) getWorkspaceDocument(w http.ResponseWriter, r *http.Request) (string, *models.Document, bool) {
|
||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||
if !ok {
|
||
return "", nil, false
|
||
}
|
||
|
||
docID := chi.URLParam(r, "docID")
|
||
doc, err := s.store.GetDocument(docID)
|
||
if err != nil {
|
||
writeInternalError(w, err)
|
||
return "", nil, false
|
||
}
|
||
if doc == nil || doc.WorkspaceID != workspaceID {
|
||
writeError(w, http.StatusNotFound, "not_found", "Document not found")
|
||
return "", nil, false
|
||
}
|
||
return workspaceID, doc, true
|
||
}
|