8000 Full API (JSON and GraphQL) by mwoods79 · Pull Request #113 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
Merged
8000
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
2126d99
feat(api): add CORS support via corsica
mwoods79 Apr 25, 2026
103b12d
test: add HuddlzWeb.ApiCase template for API tests
mwoods79 Apr 25, 2026
f399c50
feat(accounts): add User.public_profile read action
mwoods79 Apr 25, 2026
ec43992
feat(api): add Api.AuthController register endpoint
mwoods79 Apr 25, 2026
7bec52d
feat(api): add Api.AuthController sign_in endpoint
mwoods79 Apr 25, 2026
34b402c
feat(api): add Api.AuthController me endpoint
mwoods79 Apr 25, 2026
01cebb3
feat(api): add Api.AuthController sign_out endpoint
mwoods79 Apr 25, 2026
33c9d74
feat(api): add Api.AuthController password_reset endpoint
mwoods79 Apr 25, 2026
577f16d
feat(accounts): add ApiKey resource and api_key strategy
mwoods79 Apr 25, 2026
31a7827
feat(api): wire ApiKey.Plug into :graphql pipeline
mwoods79 Apr 25, 2026
5f3818f
feat(api): add api_keys create endpoint
mwoods79 Apr 25, 2026
360c239
feat(api): add api_keys list endpoint
mwoods79 Apr 25, 2026
e65628f
feat(api): add api_keys delete endpoint
mwoods79 Apr 25, 2026
d9d362b
feat(api): expose Huddl.upcoming via GraphQL and JSON:API
mwoods79 Apr 25, 2026
df66a28
feat(api): expose Huddl.past via GraphQL and JSON:API
mwoods79 Apr 25, 2026
59f9ca0
feat(api): expose Huddl.by_group via GraphQL and JSON:API
mwoods79 Apr 25, 2026
49f8c52
refactor(huddl)!: reshape rsvp action and expose as mutation
mwoods79 Apr 25, 2026
b245735
test(geocoding): silence GeocodingStub warning spam
mwoods79 Apr 25, 2026
6d4338f
refactor(huddl)!: reshape cancel_rsvp action and expose
mwoods79 Apr 25, 2026
7735c92
feat(api): expose Huddl.destroy via GraphQL and JSON:API
mwoods79 Apr 25, 2026
50b014d
feat(api): expose Group resource via GraphQL and JSON:API
mwoods79 Apr 25, 2026
f9e11c4
feat(api): expose Group.search via GraphQL and JSON:API
mwoods79 Apr 25, 2026
af87819
refactor(group)!: reshape get_by_owner as my_groups
mwoods79 Apr 25, 2026
43a7224
feat(api): expose Group.create_group mutation
mwoods79 Apr 25, 2026
6696a8f
feat(api): expose Group.update_details mutation
mwoods79 Apr 25, 2026
6da8937
feat(api): expose Group.destroy via GraphQL and JSON:API
mwoods79 Apr 25, 2026
8ce553c
feat(api): expose GroupMember.get_by_group
mwoods79 Apr 25, 2026
70f90fe
refactor(group_member)!: reshape get_by_user as my_memberships
mwoods79 Apr 25, 2026
40a233e
refactor(group_member)!: reshape join_group and expose mutation
mwoods79 Apr 25, 2026
2fabe9c
feat(api): expose GroupMember.leave_group
mwoods79 Apr 25, 2026
44fc156
feat(api): expose GroupMember.add_member
mwoods79 Apr 25, 2026
c847387
feat(api): expose GroupMember.remove_member mutation
mwoods79 Apr 25, 2026
06cc63d
feat(api): expose HuddlAttendee.by_huddl
mwoods79 Apr 25, 2026
e0731e7
refactor(huddl_attendee)!: reshape by_user as my_rsvps
mwoods79 Apr 25, 2026
edb0384
refactor(huddl_attendee)!: reshape check_rsvp action
mwoods79 Apr 25, 2026
80f55d8
feat(api): expose GroupLocation.by_group
mwoods79 Apr 25, 2026
5e3b1a5
feat(api): expose GroupLocation create/update/destroy
mwoods79 Apr 25, 2026
e95b40d
test(api): add sensitive-field introspection audit
mwoods79 Apr 25, 2026
0337245
feat(storage): add Storage.Changes.PersistUpload + :upload actions
mwoods79 Apr 25, 2026
ee0eeab
feat(api): expose HuddlImage and GroupImage upload via API
mwoods79 Apr 25, 2026
b7c899c
feat(api): expose User.me GraphQL query
mwoods79 Apr 25, 2026
a827e4b
feat(api): expose User update mutations via GraphQL
mwoods79 Apr 25, 2026
9dbf743
test(api): end-to-end happy-path scenario
mwoods79 Apr 25, 2026
6409dc8
test(api): refine sensitive-field audit for User type
mwoods79 Apr 25, 2026
8a4f3ec
docs(api): catalog deliberately deferred follow-ups
mwoods79 Apr 25, 2026
df8f31f
fix(group)!: prevent owner_id spoofing in create_group
mwoods79 Apr 25, 2026
7fa2ede
fix(api): mark user-facing attributes public
mwoods79 Apr 25, 2026
aec8a73
fix(api): clamp expires_in_days on api_keys create
mwoods79 Apr 25, 2026
ae354d5
fix(test): make sensitive-fields audit fail loudly on new user types
mwoods79 Apr 25, 2026
80a53bb
fix(test): drop residual owner_id from LiveView test maps
mwoods79 Apr 27, 2026
7fec7fb
fix(group)!: honor caller-supplied slug on create_group
mwoods79 Apr 27, 2026
bfe7828
feat(group)!: cascade group destroy through dependents
mwoods79 Apr 27, 2026
22bcf88
feat(api): expose ProfilePicture upload via JSON:API + GraphQL
mwoods79 Apr 27, 2026
42d3ccb
feat(api): expose remove_member via JSON:API
mwoods79 Apr 27, 2026
ce88df3
test(api): exercise multipart image upload end-to-end via HTTP
mwoods79 Apr 27, 2026
3fc9667
fix(api): map storage atom errors to friendly messages
mwoods79 Apr 27, 2026
3337cad
docs(api): close out followups addressed in shippability sweep
mwoods79 Apr 27, 2026
2370ea6
test(api): drop dead setup in JSON:API rsvp test
mwoods79 Apr 27, 2026
82c78e1
docs(api): record three deferred items from review
mwoods79 Apr 27, 2026
7eb5a58
fix(api): default CORS_ORIGINS to PHX_HOST
mwoods79 Apr 27, 2026
45eb586
fix(api): skip ApiKey plug when actor already set
mwoods79 Apr 27, 2026
dacab33
refactor(api): test Cors matching against explicit origins
mwoods79 Apr 27, 2026
a511410
fix(api): require user on api keys, cascade delete, add index
mwoods79 Apr 27, 2026
42012b6
fix(api): error when upload is missing parent_id
mwoods79 Apr 27, 2026
51c710a
docs(api): note flaky profile_picture.feature step
mwoods79 Apr 27, 2026
5a29ff8
test: keep geocoding stub honest with the contract
mwoods79 Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/dev.exs
< 10BC8 /div>
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ config :huddlz, Huddlz.Repo,

# Adapters (compile-time - modules must exist at compile)
config :huddlz, :storage, adapter: Huddlz.Storage.Local

# CORS — allow all origins in development
config :huddlz, :cors_origins, :all
9 changes: 9 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,13 @@ if config_env() == :prod do
config :huddlz, :storage,
bucket: required!("BUCKET_NAME"),
endpoint: required!("AWS_ENDPOINT_URL_S3")

cors_origins =
"CORS_ORIGINS"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should read this from PHX_HOST if possible.

|> optional("#{scheme}://#{host}")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))

config :huddlz, :cors_origins, cors_origins
end
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ config :huddlz, :geocoding, adapter: Huddlz.MockGeocoding
config :huddlz, :places, adapter: Huddlz.MockPlaces
config :huddlz, geocoding_req_plug: {Req.Test, Huddlz.Geocoding.Google}
config :huddlz, Huddlz.Repo, pool: Ecto.Adapters.SQL.Sandbox

# CORS — allow all origins so tests don't have to configure them
config :huddlz, :cors_origins, :all
213 changes: 213 additions & 0 deletions docs/api-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# API follow-ups

Things consciously deferred while landing the JSON:API + GraphQL surface on
`feat/api`. Each entry captures *why* it was skipped and *what to do* when
it's time to come back to it, so we don't have to reconstruct the context.

> **Status as of latest sweep:**
> Items addressed in the "make `feat/api` shippable" pass:
> the slug ergonomics fix, group destroy cascade, ProfilePicture upload
> exposure, the GroupMember.remove_member JSON:API route, the real
> multipart HTTP test, and the image-upload error mapping. Removed from
> this doc. The remaining items below are genuinely deferred.

## Pagination sweep

**Status:** deferred.
**Why:** Adding `pagination keyset?: true` to a read action changes the
response shape — the result becomes a `KeysetPageOf<Type>` wrapper instead
of a bare list, so any test or call site that pattern-matches on
`{:ok, [list]}` breaks. The actions that ship paginated already (`Huddl.search`,
`Huddl.by_group`) needed their tests written around `results { ... }`
unwrapping. Sweeping the rest at the same time would have multiplied
breakage well beyond the API change.
**To do:**

- Add `pagination keyset?: true, offset?: true, countable: true, required?: false`
to: `Huddl.upcoming`, `Huddl.past`, `Group.read`, `Group.search`,
`GroupMember.get_by_group`, `GroupMember.get_by_user`,
`HuddlAttendee.by_huddl`, `HuddlAttendee.by_user`,
`GroupLocation.by_group`.
- Update each action's tests to wrap result-set assertions in `results { … }`
(GraphQL) or expect `meta.page` envelopes (JSON:API).
- Add a per-resource `default_limit` (20) and a max so large lists don't
fall back to "stream the whole table".

## OpenAPI / GraphiQL prose polish

**Status:** deferred.
**Why:** Descriptions exist on actions and arguments but they were written
for resource-internal callers. The phrasing reads awkwardly when surfaced
to API consumers via Swagger UI or GraphiQL. This is a writing task more
than a code task.
**To do:**

- Walk through `/api/json/swaggerui` and rewrite descriptions on each
exposed action to address API consumers ("Create a huddl" not
"Create a huddl with the given attributes via the create action").
- Add `@deprecated` directives on the legacy `:create` actions for
`HuddlImage` / `GroupImage` (they're now superseded by `:upload` for
API use; the manual-create actions should stay for internal callers
that pre-compute storage paths).
- Add example bodies to the OpenAPI spec via `OpenApiSpex` example
schemas.

## User update mutations use `id:` arg

**Status:** functional but un-ergonomic. Attempted fix in the
shippable-API sweep; deferred again after the AshGraphql/Ash 3.x
interaction proved more involved than expected.
**Why:** `updateDisplayName(id: $id, input: { displayName: $name })`
takes the user id explicitly even though the policy already
restricts the action to `^actor(:id)`. The client can pass any id
and the policy rejects mismatches; passing the id is noise.

The clean fix — `update :update_display_name, :update_display_name,
read_action: :me, identity: false` — produces an Ecto error at run
time:

> `update_all` does not allow subqueries in `from`

because `:me` filters on `^actor(:id)`, which Ash's atomic update
path turns into a subquery wrapping the update statement. Postgres
rejects that. Setting `require_atomic? false` on the update action
didn't bypass the path either.

**To do:**

- Investigate AshGraphql 1.9's mutation pipeline and figure out
whether there's a `read_action`-compatible shape that doesn't go
through bulk update (a `before_action` lookup, or a custom
resolver, or an Ash patch).
- Alternative: build a generic action wrapper (`update_self`) that
takes only the input fields, loads the actor explicitly, and
delegates to the underlying update. Expose that action as the
mutation. More code than the `read_action` shortcut but works
around the atomic-update constraint.

## API key strategy: scopes, rotation, audit

**Status:** v1 ships with full-permission keys and manual revocation.
**Why:** Sufficient for a single first-party SPA / one trusted
machine consumer. Adding scopes / fine-grained permissions before
there's a second consumer is premature.
**To do (when needed):**

- Per-key scopes: `read_only`, `events:write`, `groups:admin`. Store
on the `ApiKey` resource as an array of atoms; check via a custom
policy.
- Rotation: a `rotate_api_key` mutation that creates a new key and
marks the old one for deletion in N days.
- Audit log: record every API-key authentication event so a
compromised key can be traced.

## Rate limiting

**Status:** none.
**Why:** No threat model yet — only first-party clients in flight.
**To do:**

- Add `:hammer` (Redis-backed in prod, ETS in dev) plug in front of
`/api/auth/*` first (brute-force protection on register and
sign_in).
- Per-user-or-key buckets on `/api/json/*` and `/gql`. Start
permissive — the goal is to stop runaway scripts, not to throttle
legitimate use.

## RemoveMemberByIds membership-existence leak

**Status:** known, acceptable for v1.
**Why:** `Huddlz.Communities.GroupMember.Actions.RemoveMemberByIds`
does `Ash.read_one(authorize?: false)` on `(group_id, user_id)` and
then dispatches `:remove_member` with the actor. The destroy is the
real auth gate, so unauthorized callers can't change state — but the
response shape distinguishes "membership not found" (404) from
"found but forbidden" (403), letting any authenticated actor probe
membership pairs. Group membership in a public group isn't very
secret; private groups would leak.
**To do:**

- Fold into the API-key scopes work above. Once a `groups:admin`
scope exists, the lookup can be filtered to "groups owned by the
actor" and the probe closes naturally.
- Cheaper interim fix: collapse 404 and 403 to a single 404 response
shape so the failure modes are indistinguishable.

## User-FK cascade asymmetry

**Status:** deferred.
**Why:** Migration `20260427030225_cascade_destroy_groups_and_huddls`
cascades `groups → group_members / group_images / huddlz` and
`huddlz → huddl_images / huddl_attendees`, but the three FKs back to
`users` are **not** cascaded: `group_members.user_id`,
`huddl_attendees.user_id`, `huddlz.creator_id`. There's no
delete-account flow today, so this isn't blocking.
**To do (when delete-account ships):**

- Follow-up migration. `group_members` and `huddl_attendees` are
pure join rows — `on_delete: :delete_all` is fine.
- `huddlz.creator_id` needs a product call **before** the FK
changes: either soft-delete and anonymize the creator, or
transfer ownership to the group owner. Don't `:delete_all` huddlz
when their creator deletes their account.

## sign_out path doesn't apply to API-key auth

**Status:** functional but UX-confusing.
**Why:** `DELETE /api/auth/sign_out` only revokes JWT bearers
(it calls `TokenResource.Actions.revoke/2`). An actor authenticated
via an API key gets the same generic
`{"error": "Authentication required"}` 401 as an unauthenticated
caller, with no hint that the real revoke endpoint is
`DELETE /api/auth/api_keys/:id`.
**To do (when there's a real API-key consumer):**

- Detect the auth method on the conn and either
(a) return a 400 with a pointer to `/api/auth/api_keys/:id`, or
(b) destroy the active API key when sign_out is called with one.
(a) is simpler and probably better — sign_out and key revocation
are conceptually different.

## Cucumber `.feature` for the happy path

**Status:** end-to-end test ships as a single ExUnit case
(`test/huddlz_web/api/end_to_end_test.exs`).
**Why:** A Cucumber `.feature` file is the more readable format
for product/QA review, but writing the step definitions for a
multi-bearer, multi-protocol flow (JSON:API + GraphQL with token
swaps) is its own project.
**To do:**

- Port `end_to_end_test.exs` to a `.feature` file with step
definitions. Probably easier once we have a small step library
for `Given I am authenticated as ...` / `When I POST ... with body ...` /
`Then the response is ...`.

## Flaky `profile_picture.feature` member-avatar step

**Status:** intermittent failure observed during the PR-113 review
sweep.
**Why:** The Cucumber scenario "Profile picture appears in group
members section" (`test/features/profile_picture.feature:45`) fails
the `Then I should see the member avatar with image` step under some
seeds — the selector `main img[src*='_thumb.jpg']` finds nothing.
Re-running the same file with `--seed 0` is green, so it's a timing
or fixture-ordering flake rather than a bug in the assertion. Looks
unrelated to feat/api code, but it surfaces while the suite is hot
on the branch.
**To do:**

- Reproduce with a sticky seed: bisect via `--seed <n>` to find a
failing one, then capture the rendered HTML at the failing step.
- Likely culprits: profile-picture upload running through a Mox
expectation that another async test consumed first, or
`current_profile_picture_url` aggregate not having loaded by the
time the Liveview renders. The `Mox.stub_with` setup in
`test/features/support/hooks.exs` is the place to verify.
- Once root-caused, either pin the scenario's stubs explicitly or
await the avatar render in the step definition before asserting.

---

When picking one of these up, tag the commit message with the section
heading so we can crosswalk back to this doc.
5 changes: 4 additions & 1 deletion lib/huddlz/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ defmodule Huddlz.Accounts do
"""

use Ash.Domain,
otp_app: :huddlz
otp_app: :huddlz,
extensions: [AshJsonApi.Domain]

resources do
resource Huddlz.Accounts.Token
Expand All @@ -28,5 +29,7 @@ defmodule Huddlz.Accounts do
define :delete_profile_picture, action: :destroy
define :soft_delete_profile_picture, action: :soft_delete
end

resource Huddlz.Accounts.ApiKey
end
end
89 changes: 89 additions & 0 deletions lib/huddlz/accounts/api_key.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
defmodule Huddlz.Accounts.ApiKey do
@moduledoc """
API keys for machine-to-machine authentication.

Plaintext keys are returned only at create time via the
`plaintext_api_key` metadata; only the hash is persisted.
"""

use Ash.Resource,
otp_app: :huddlz,
domain: Huddlz.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]

postgres do
table "api_keys"
repo Huddlz.Repo

references do
reference :user, on_delete: :delete
end

custom_indexes do
index [:user_id]
end
end

actions do
defaults [:read, :destroy]

create :create do
primary? true
accept [:expires_at]

change relate_actor(:user)

change {AshAuthentication.Strategy.ApiKey.GenerateApiKey,
prefix: :huddlz, hash: :api_key_hash}
end
end

policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end

policy action(:create) do
description "Authenticated users can create API keys for themselves"
authorize_if actor_present()
end

policy action(:read) do
description "Users can read their own API keys"
authorize_if expr(user_id == ^actor(:id))
end

policy action(:destroy) do
description "Users can revoke their own API keys"
authorize_if expr(user_id == ^actor(:id))
end
end

attributes do
uuid_primary_key :id

attribute :api_key_hash, :binary do
allow_nil? false
sensitive? true
end

attribute :expires_at, :utc_datetime_usec do
allow_nil? false
end
end

relationships do
belongs_to :user, Huddlz.Accounts.User do
allow_nil? false
end
end

calculations do
calculate :valid, :boolean, expr(expires_at > now())
end

identities do
identity :unique_api_key, [:api_key_hash]
end
end
Loading
Loading
0