Resource identity redesign: workspace/config slugs, run numbers, invite notifications#150
Merged
Conversation
…ted overrides The by-email invite flow previously stored an address but delivered nothing (the "an email is sent" docs were inaccurate — this server has no email transport). Make it actually notify existing users, and add the seam a wrapping binary (the hosted edition) needs to replace the handler with one that also sends email — including to users who don't yet have an account. Invite notifications - Extract the create sequence into `pub async fn create_invite(...) -> Result<InviteOutcome>`: member-dedupe, resolve the email to an account, and on a hit create the invite + an in-app notification (MemberInvited) in one transaction. An email with no account returns InviteOutcome::UnknownEmail and creates nothing. - send_invite now returns a uniform 200 InviteSent for both the created and unknown-email cases, so the response can't be used to probe whether an account exists. Member/pending-invite conflicts still 409 (that membership is already visible to callers with InviteMembers). - create_invite / InviteOutcome / CreatedInvite are re-exported so a wrapping binary can reuse the exact logic and add email on top. - Fix the docstrings that claimed an email was sent. Module-exclusion seam - Add BuiltinModule + CustomRoutes::exclude(). private_routes/public_routes now mount each built-in module unless excluded, so a wrapper can drop a built-in endpoint and mount its own via add_private_routes without an axum route-collision panic. Codes, token generation, accept/decline, cancel are unchanged. No email, SMTP, or NATS in this crate — delivery stays a deployment concern. Tests: create_invite mapping + uniform-response guarantees (unit); exclude() frees a built-in path for a replacement, verified by booting a server that excludes Invites and mounts a custom invite route (integration, ignored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…URLs
Give workspaces a human-readable slug and use it as the URL identity, so
nested routes read `/workspaces/acme-corp/connections/{id}` instead of
`/workspaces/{uuid}/connections/{id}`. The slug is only the public URL key;
the workspace UUID stays the internal key for authorization and every
workspace-scoped query, so tenant isolation is unchanged.
This matches GitHub's model — the scoped parent stays in the path even for
globally-unique child ids (e.g. check runs), and the parent is a name, not an
opaque id.
WorkspaceSlug newtype (nvisy-postgres::types)
- Validated: lowercase `[a-z0-9]` with single internal dashes, 3-32 chars.
Invariant enforced on construction; the DB CHECK mirrors it exactly.
- `derive()` slugifies a display name (via the `slug` crate); infallible
conversions via derive_more; validating FromStr/TryFrom stay explicit.
- `with_numeric_suffix` for collision disambiguation.
Schema / query
- Add `slug TEXT NOT NULL` + named UNIQUE / format / length constraints;
`WorkspaceSlug` in the model; constraint enum + pg_workspace error mapping.
- `find_workspace_by_slug`; `create_workspace_with_unique_slug` retries `-2`,
`-3`, … on a unique violation — race-safe (driven by the DB constraint, not
a check-then-insert).
WorkspaceContext extractor
- Resolves the `{workspaceSlug}` segment to the addressed Workspace (404 on an
unknown slug) and documents the OpenAPI path parameter. Handlers receive the
resolved workspace and pass `workspace.id` downstream.
Routes / handlers
- Every workspace-nested route now uses `{workspaceSlug}`; child path-param
structs drop their redundant `workspace_id` field (the extractor owns it).
The public `/invites/code/{inviteCode}/` routes stay top-level.
- create/update workspace accept an optional explicit slug (derived from the
display name when omitted); slug is mutable. Fix the display-name validator
(was 3-100) to match the DB's 3-32.
- Workspace responses expose `slug`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…esponses Make the slug the single workspace identity and remove the workspace UUID from the API surface. Since every workspace-addressing route already takes the slug, the UUID in response bodies was an unusable, un-addressable token — dead weight that implied an id-lookup capability the API doesn't offer. - Replace `workspaceId` with `workspaceSlug` in every response body (connections, pipelines, policies, contexts, files, webhooks, activities, invites, and the workspace itself). Handlers thread the resolved `workspace.slug` into the response constructors. Resource-owned ids stay — they remain URL-addressable. - Make the slug immutable: it is set once at creation (explicit or derived) and cannot be changed. Removed from the UpdateWorkspace request DTO and the ORM changeset, so no path — API or internal — can mutate the slug column. An immutable slug is a stable, rename-proof reference, which is exactly what a separate UUID would otherwise provide, so exposing the UUID adds nothing. - Drop the invite_sent_tests unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the slug model to workspace-owned config resources and address runs by
a per-parent sequential number, completing the GitHub-style identity scheme:
named things get an immutable slug, high-volume append-only runs get a number.
Generalize the slug type
- Rename WorkspaceSlug -> Slug (one reusable, validated newtype). Workspace
slugs stay globally unique; child slugs are unique per workspace, enforced by
each table's constraint rather than the type.
Config resources: connections, pipelines, policies, contexts
- Add `slug TEXT NOT NULL` + UNIQUE(workspace_id, slug) + format/length checks;
the slug is immutable (absent from every Update path).
- Routes address these by `{xSlug}`; handlers resolve the slug within the
workspace already resolved by WorkspaceContext.
- The slug is a required create field; a duplicate surfaces as 409 (no silent
suffixing). Responses are slug-only — no surrogate UUID in the body.
Runs -> per-parent numbers
- Pipeline and connection runs are addressed as
`/pipelines/{slug}/runs/{runNumber}` and `/connections/{slug}/runs/{runNumber}`
(plus detections/redactions), reusing the gap-free run_number already assigned
by a DB trigger. The flat `/pipeline-runs/{id}` / `/connection-runs/{id}`
routes are dropped; the run response drops its UUID and its parent UUID.
- Workspace-wide aggregate lists move to `/pipelines/runs/` and
`/connections/runs/`, joining the owning parent so each run carries its
parent's slug.
OpenAPI: declare the tag set that handlers actually use (Pipelines, Pipeline
Runs, Policies, Contexts, Notifications, Health) and align "API Tokens" so no
declared category is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reworks how workspace-owned resources are identified in the API, adopting a GitHub-shaped model — human slugs for named things, per-parent sequential numbers for high-volume append-only runs — and fixes the by-email invite flow along the way. Four commits, reviewable in order.
1. Invite by email → in-app notifications + hosted override seam
The by-email invite flow previously stored an address and delivered nothing. Now:
create_invite(): resolves the email to an existing account and, on a hit, creates the invite and an in-appMemberInvitednotification in one transaction; an unknown email is a no-op.send_invitereturns a uniform 200 for known and unknown emails — no account-existence oracle. Member / pending conflicts still 409.BuiltinModule+CustomRoutes::exclude(): the general OSS→hosted override seam, so the hosted edition can replace an endpoint (e.g. to also send email) without an axum route-collision panic.2–3. Workspaces addressed by immutable slug, UUID off the surface
slug(derived from the display name or supplied). Every nested route is/workspaces/{workspaceSlug}/…, resolved by aWorkspaceContextextractor.workspaceId(and the workspace's own UUID) are removed from response bodies — with no endpoint accepting a workspace UUID, exposing it was dead weight. The immutable slug is the stable, usable identity in both URL and body.4. Config slugs + per-parent run numbers
UNIQUE(workspace_id, slug)), required at creation, duplicate → 409 (no silent suffixing). Addressed by{xSlug}; responses slug-only.{runNumber}, nested under the parent —/pipelines/{slug}/runs/{runNumber}(+ detections/redactions),/connections/{slug}/runs/{runNumber}— reusing the gap-freerun_numberalready assigned DB-side. Flat/pipeline-runs/{id}routes dropped. Aggregate lists move to/pipelines/runs/and/connections/runs/.Slugis one generic validated newtype; scope (global vs per-workspace) is a DB constraint, not a type.OpenAPI hygiene
Declared tag set aligned to the tags handlers actually use (no empty categories;
API Tokensfixed).Verification
cargo check/clippy -D warnings/fmt/rustdoc -D warnings/ full test suite — all clean./pipelines/runs/and/pipelines/{slug}/…).{xId}routes, zero UUID leaks in response bodies,runNumberis an integer param.🤖 Generated with Claude Code