···1919 (Module, Function, Args), denoting a callback function to be invoked by after
2020 a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a
2121 simple usage of this.
2222+- `Atex.OAuth.Permission` module for creating
2323+ [AT Protocol permission](https://atproto.com/specs/permission) strings for
2424+ OAuth.
22252326### Changed
2427
+809
lib/atex/oauth/permission.ex
···11+defmodule Atex.OAuth.Permission do
22+ use TypedStruct
33+ import Kernel, except: [to_string: 1]
44+55+ @type t_tuple() :: {
66+ resource :: String.t(),
77+ positional :: String.t() | nil,
88+ parameters :: list({String.t(), String.t()})
99+ }
1010+1111+ @typep as_string() :: {:as_string, boolean()}
1212+ @type account_attr() :: :email | :repo
1313+ @type account_action() :: :read | :manage
1414+ @type account_opt() ::
1515+ {:attr, account_attr()} | {:action, account_action()} | as_string()
1616+1717+ @type repo_opt() ::
1818+ {:create, boolean()} | {:update, boolean()} | {:delete, boolean()} | as_string()
1919+2020+ @type rpc_opt() :: {:aud, String.t()} | {:inherit_aud, boolean()} | as_string()
2121+2222+ @type include_opt() :: {:aud, String.t()} | as_string()
2323+2424+ typedstruct enforce: true do
2525+ field :resource, String.t()
2626+ field :positional, String.t() | nil
2727+ # like a Keyword list but with a string instead of an atom
2828+ field :parameters, list({String.t(), String.t()}), enforce: false, default: []
2929+ end
3030+3131+ @doc """
3232+ Creates a new permission struct from a permission scope string.
3333+3434+ Parses an AT Protocol OAuth permission scope string and returns a structured
3535+ representation. Permission strings follow the format
3636+ `resource:positional?key=value&key2=value2`
3737+3838+ The positional parameter is resource-specific and may be omitted in some cases
3939+ (e.g., collection for `repo`, lxm for `rpc`, attr for `account`/`identity`,
4040+ accept for `blob`).
4141+4242+ See the [AT Protocol
4343+ documentation](https://atproto.com/specs/permission#scope-string-syntax) for
4444+ the full syntax and rules for permission scope strings.
4545+4646+ ## Parameters
4747+ - `string` - A permission scope string (e.g., "repo:app.example.profile")
4848+4949+ Returns `{:ok, permission}` if a valid scope string was given, otherwise it
5050+ will return `{:error, reason}`.
5151+5252+ ## Examples
5353+5454+ # Simple with just a positional
5555+ iex> Atex.OAuth.Permission.new("repo:app.example.profile")
5656+ {:ok, %Atex.OAuth.Permission{
5757+ resource: "repo",
5858+ positional: "app.example.profile",
5959+ parameters: []
6060+ }}
6161+6262+ # With parameters
6363+ iex> Atex.OAuth.Permission.new("repo?collection=app.example.profile&collection=app.example.post")
6464+ {:ok, %Atex.OAuth.Permission{
6565+ resource: "repo",
6666+ positional: nil,
6767+ parameters: [
6868+ {"collection", "app.example.profile"},
6969+ {"collection", "app.example.post"}
7070+ ]
7171+ }}
7272+7373+ # Positional with parameters
7474+ iex> Atex.OAuth.Permission.new("rpc:app.example.moderation.createReport?aud=*")
7575+ {:ok, %Atex.OAuth.Permission{
7676+ resource: "rpc",
7777+ positional: "app.example.moderation.createReport",
7878+ parameters: [{"aud", "*"}]
7979+ }}
8080+8181+ iex> Atex.OAuth.Permission.new("blob:*/*")
8282+ {:ok, %Atex.OAuth.Permission{
8383+ resource: "blob",
8484+ positional: "*/*",
8585+ parameters: []
8686+ }}
8787+8888+ # Invalid: resource without positional or parameters
8989+ iex> Atex.OAuth.Permission.new("resource")
9090+ {:error, :missing_positional_or_parameters}
9191+9292+ """
9393+ @spec new(String.t()) :: {:ok, t()} | {:error, reason :: atom()}
9494+ def new(string) do
9595+ case parse(string) do
9696+ {:ok, {resource, positional, parameters}} ->
9797+ {:ok, %__MODULE__{resource: resource, positional: positional, parameters: parameters}}
9898+9999+ err ->
100100+ err
101101+ end
102102+ end
103103+104104+ @doc """
105105+ Parses an AT Protocol permission scope string into its components.
106106+107107+ Returns a tuple containing the resource name, optional positional parameter,
108108+ and a list of key-value parameter pairs. This is a lower-level function
109109+ compared to `new/1`, returning the raw components instead of a struct.
110110+111111+ ## Parameters
112112+ - `string` - A permission scope string following the format
113113+ `resource:positional?key=value&key2=value2`
114114+115115+ Returns `{:ok, {resource, positional, parameters}}` if a valid scope string
116116+ was given, otherwise it will return `{:error, reason}`.
117117+118118+ ## Examples
119119+120120+ # Simple with just a positional
121121+ iex> Atex.OAuth.Permission.parse("repo:app.example.profile")
122122+ {:ok, {"repo", "app.example.profile", []}}
123123+124124+ # With parameters
125125+ iex> Atex.OAuth.Permission.parse("repo?collection=app.example.profile&collection=app.example.post")
126126+ {:ok, {
127127+ "repo",
128128+ nil,
129129+ [
130130+ {"collection", "app.example.profile"},
131131+ {"collection", "app.example.post"}
132132+ ]
133133+ }}
134134+135135+ # Positional with parameters
136136+ iex> Atex.OAuth.Permission.parse("rpc:app.example.moderation.createReport?aud=*")
137137+ {:ok, {"rpc", "app.example.moderation.createReport", [{"aud", "*"}]}}
138138+139139+ iex> Atex.OAuth.Permission.parse("blob:*/*")
140140+ {:ok, {"blob", "*/*", []}}
141141+142142+ # Invalid: resource without positional or parameters
143143+ iex> Atex.OAuth.Permission.parse("resource")
144144+ {:error, :missing_positional_or_parameters}
145145+146146+ """
147147+ @spec parse(String.t()) ::
148148+ {:ok, t_tuple()}
149149+ | {:error, reason :: atom()}
150150+ def parse(string) do
151151+ case String.split(string, "?", parts: 2) do
152152+ [resource_part] ->
153153+ parse_resource_and_positional(resource_part)
154154+155155+ # Empty parameter string is treated as absent
156156+ [resource_part, ""] ->
157157+ parse_resource_and_positional(resource_part)
158158+159159+ [resource_part, params_part] ->
160160+ params_part
161161+ |> parse_parameters()
162162+ |> then(&parse_resource_and_positional(resource_part, &1))
163163+ end
164164+ end
165165+166166+ @spec parse_resource_and_positional(String.t(), list({String.t(), String.t()})) ::
167167+ {:ok, t_tuple()} | {:error, reason :: atom()}
168168+ defp parse_resource_and_positional(resource_part, parameters \\ []) do
169169+ case String.split(resource_part, ":", parts: 2) do
170170+ [resource_name, positional] ->
171171+ {:ok, {resource_name, positional, parameters}}
172172+173173+ [resource_name] ->
174174+ if parameters == [] do
175175+ {:error, :missing_positional_or_parameters}
176176+ else
177177+ {:ok, {resource_name, nil, parameters}}
178178+ end
179179+ end
180180+ end
181181+182182+ @spec parse_parameters(String.t()) :: list({String.t(), String.t()})
183183+ defp parse_parameters(params_string) do
184184+ params_string
185185+ |> String.split("&")
186186+ |> Enum.map(fn param ->
187187+ case String.split(param, "=", parts: 2) do
188188+ [key, value] -> {key, URI.decode(value)}
189189+ [key] -> {key, ""}
190190+ end
191191+ end)
192192+ end
193193+194194+ @doc """
195195+ Converts a permission struct back into its scope string representation.
196196+197197+ This is the inverse operation of `new/1`, converting a structured permission
198198+ back into the AT Protocol OAuth scope string format. The resulting string
199199+ can be used directly as an OAuth scope parameter.
200200+201201+ Values in `parameters` are automatically URL-encoded as needed (e.g., `#` becomes `%23`).
202202+203203+ ## Parameters
204204+ - `struct` - An `%Atex.OAuth.Permission{}` struct
205205+206206+ Returns a permission scope string.
207207+208208+ ## Examples
209209+210210+ # Simple with just a positional
211211+ iex> perm = %Atex.OAuth.Permission{
212212+ ...> resource: "repo",
213213+ ...> positional: "app.example.profile",
214214+ ...> parameters: []
215215+ ...> }
216216+ iex> Atex.OAuth.Permission.to_string(perm)
217217+ "repo:app.example.profile"
218218+219219+ # With parameters
220220+ iex> perm = %Atex.OAuth.Permission{
221221+ ...> resource: "repo",
222222+ ...> positional: nil,
223223+ ...> parameters: [
224224+ ...> {"collection", "app.example.profile"},
225225+ ...> {"collection", "app.example.post"}
226226+ ...> ]
227227+ ...> }
228228+ iex> Atex.OAuth.Permission.to_string(perm)
229229+ "repo?collection=app.example.profile&collection=app.example.post"
230230+231231+ # Positional with parameters
232232+ iex> perm = %Atex.OAuth.Permission{
233233+ ...> resource: "rpc",
234234+ ...> positional: "app.example.moderation.createReport",
235235+ ...> parameters: [{"aud", "*"}]
236236+ ...> }
237237+ iex> Atex.OAuth.Permission.to_string(perm)
238238+ "rpc:app.example.moderation.createReport?aud=*"
239239+240240+ iex> perm = %Atex.OAuth.Permission{
241241+ ...> resource: "blob",
242242+ ...> positional: "*/*",
243243+ ...> parameters: []
244244+ ...> }
245245+ iex> Atex.OAuth.Permission.to_string(perm)
246246+ "blob:*/*"
247247+248248+ # Works via String.Chars protocol
249249+ iex> perm = %Atex.OAuth.Permission{
250250+ ...> resource: "account",
251251+ ...> positional: "email",
252252+ ...> parameters: []
253253+ ...> }
254254+ iex> to_string(perm)
255255+ "account:email"
256256+257257+ """
258258+ @spec to_string(t()) :: String.t()
259259+ def to_string(%__MODULE__{} = struct) do
260260+ positional_part = if struct.positional, do: ":#{struct.positional}", else: ""
261261+ parameters_part = stringify_parameters(struct.parameters)
262262+263263+ struct.resource <> positional_part <> parameters_part
264264+ end
265265+266266+ @spec stringify_parameters(list({String.t(), String.t()})) :: String.t()
267267+ defp stringify_parameters([]), do: ""
268268+269269+ defp stringify_parameters(params) do
270270+ params
271271+ |> Enum.map(fn {key, value} -> "#{key}=#{encode_param_value(value)}" end)
272272+ |> Enum.join("&")
273273+ |> then(&"?#{&1}")
274274+ end
275275+276276+ # Encode parameter values for OAuth scope strings
277277+ # Preserves unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) and common scope characters (*, :, /)
278278+ # Encodes reserved characters like # as %23
279279+ @spec encode_param_value(String.t()) :: String.t()
280280+ defp encode_param_value(value) do
281281+ URI.encode(value, fn char ->
282282+ URI.char_unreserved?(char) or char in [?*, ?:, ?/]
283283+ end)
284284+ end
285285+286286+ @doc """
287287+ Creates an account permission for controlling PDS account hosting details.
288288+289289+ Controls access to private account information such as email address and
290290+ repository import capabilities. These permissions cannot be included in
291291+ permission sets and must be requested directly by client apps.
292292+293293+ See the [AT Protocol documentation](https://atproto.com/specs/permission#account)
294294+ for more information.
295295+296296+ ## Options
297297+ - `:attr` (required) - A component of account configuration. Must be `:email`
298298+ or `:repo`.
299299+ - `:action` (optional) - Degree of control. Can be `:read` or `:manage`.
300300+ Defaults to `:read`.
301301+ - `:as_string` (optional) - If `true` (default), returns a scope string,
302302+ otherwise returns a Permission struct.
303303+304304+ If `:as_string` is true a scope string is returned, otherwise the underlying
305305+ Permission struct is returned.
306306+307307+ ## Examples
308308+309309+ # Read account email (default action, as string)
310310+ iex> Atex.OAuth.Permission.account(attr: :email)
311311+ "account:email"
312312+313313+ # Read account email (as struct)
314314+ iex> Atex.OAuth.Permission.account(attr: :email, as_string: false)
315315+ %Atex.OAuth.Permission{
316316+ resource: "account",
317317+ positional: "email",
318318+ parameters: []
319319+ }
320320+321321+ # Read account email (explicit action)
322322+ iex> Atex.OAuth.Permission.account(attr: :email, action: :read)
323323+ "account:email?action=read"
324324+325325+ # Manage account email
326326+ iex> Atex.OAuth.Permission.account(attr: :email, action: :manage)
327327+ "account:email?action=manage"
328328+329329+ # Import repo
330330+ iex> Atex.OAuth.Permission.account(attr: :repo, action: :manage)
331331+ "account:repo?action=manage"
332332+333333+ """
334334+ @spec account(list(account_opt())) :: t() | String.t()
335335+ def account(opts \\ []) do
336336+ opts = Keyword.validate!(opts, attr: nil, action: nil, as_string: true)
337337+ attr = Keyword.get(opts, :attr)
338338+ action = Keyword.get(opts, :action)
339339+ as_string = Keyword.get(opts, :as_string)
340340+341341+ cond do
342342+ is_nil(attr) ->
343343+ raise ArgumentError, "option `:attr` must be provided."
344344+345345+ attr not in [:email, :repo] ->
346346+ raise ArgumentError, "option `:attr` must be `:email` or `:repo`."
347347+348348+ action not in [nil, :read, :manage] ->
349349+ raise ArgumentError, "option `:action` must be `:read`, `:manage`, or `nil`."
350350+351351+ true ->
352352+ struct = %__MODULE__{
353353+ resource: "account",
354354+ positional: Atom.to_string(attr),
355355+ parameters: if(!is_nil(action), do: [{"action", Atom.to_string(action)}], else: [])
356356+ }
357357+358358+ if as_string, do: to_string(struct), else: struct
359359+ end
360360+ end
361361+362362+ @doc """
363363+ Creates a blob permission for uploading media files to PDS.
364364+365365+ Controls the ability to upload blobs (media files) to the PDS. Permissions can
366366+ be restricted by MIME type patterns.
367367+368368+ See the [AT Protocol documentation](https://atproto.com/specs/permission#blob)
369369+ for more information.
370370+371371+ <!-- TODO: When permission sets are supported, add the note from the docs about this not being allowed in permisison sets. -->
372372+373373+ ## Parameters
374374+ - `accept` - A single MIME type string or list of MIME type strings/patterns.
375375+ Supports glob patterns like `"*/*"` or `"video/*"`.
376376+ - `opts` - Keyword list of options.
377377+378378+ ## Options
379379+ - `:as_string` (optional) - If `true` (default), returns a scope string, otherwise
380380+ returns a Permission struct.
381381+382382+ If `:as_string` is true a scope string is returned, otherwise the underlying
383383+ Permission struct is returned.
384384+385385+ ## Examples
386386+387387+ # Upload any type of blob
388388+ iex> Atex.OAuth.Permission.blob("*/*")
389389+ "blob:*/*"
390390+391391+ # Only images
392392+ iex> Atex.OAuth.Permission.blob("image/*", as_string: false)
393393+ %Atex.OAuth.Permission{
394394+ resource: "blob",
395395+ positional: "image/*",
396396+ parameters: []
397397+ }
398398+399399+ # Multiple mimetypes
400400+ iex> Atex.OAuth.Permission.blob(["video/*", "text/html"])
401401+ "blob?accept=video/*&accept=text/html"
402402+403403+ # Multiple more specific mimetypes
404404+ iex> Atex.OAuth.Permission.blob(["image/png", "image/jpeg"], as_string: false)
405405+ %Atex.OAuth.Permission{
406406+ resource: "blob",
407407+ positional: nil,
408408+ parameters: [{"accept", "image/png"}, {"accept", "image/jpeg"}]
409409+ }
410410+411411+ """
412412+ # TODO: should probably validate that these at least look like mimetypes (~r"^.+/.+$")
413413+ @spec blob(String.t() | list(String.t()), list(as_string())) :: t() | String.t()
414414+ def blob(accept, opts \\ [])
415415+416416+ def blob(accept, opts) when is_binary(accept) do
417417+ opts = Keyword.validate!(opts, as_string: true)
418418+ as_string = Keyword.get(opts, :as_string)
419419+ struct = %__MODULE__{resource: "blob", positional: accept}
420420+ if as_string, do: to_string(struct), else: struct
421421+ end
422422+423423+ def blob(accept, opts) when is_list(accept) do
424424+ opts = Keyword.validate!(opts, as_string: true)
425425+ as_string = Keyword.get(opts, :as_string)
426426+427427+ struct = %__MODULE__{
428428+ resource: "blob",
429429+ positional: nil,
430430+ parameters: Enum.map(accept, &{"accept", &1})
431431+ }
432432+433433+ if as_string, do: to_string(struct), else: struct
434434+ end
435435+436436+ @doc """
437437+ Creates an identity permission for controlling network identity.
438438+439439+ Controls access to the account's DID document and handle. Note that the PDS
440440+ might not be able to facilitate identity changes if it does not have control
441441+ over the DID document (e.g., when using `did:web`).
442442+443443+ <!-- TODO: same thing about not allowed in permission sets. -->
444444+445445+ See the [AT Protocol
446446+ documentation](https://atproto.com/specs/permission#identity) for more
447447+ information.
448448+449449+ ## Parameters
450450+ - `attr` - An aspect or component of identity. Must be `:handle` or `:*`
451451+ (wildcard).
452452+ - `opts` - Keyword list of options.
453453+454454+ ## Options
455455+ - `:as_string` (optional) - If `true` (default), returns a scope string,
456456+ otherwise returns a Permission struct.
457457+458458+ If `:as_string` is true a scope string is returned, otherwise the underlying
459459+ Permission struct is returned.
460460+461461+ ## Examples
462462+463463+ # Update account handle (as string)
464464+ iex> Atex.OAuth.Permission.identity(:handle)
465465+ "identity:handle"
466466+467467+ # Full identity control (as struct)
468468+ iex> Atex.OAuth.Permission.identity(:*, as_string: false)
469469+ %Atex.OAuth.Permission{
470470+ resource: "identity",
471471+ positional: "*",
472472+ parameters: []
473473+ }
474474+475475+ """
476476+ @spec identity(:handle | :*, list(as_string())) :: t() | String.t()
477477+ def identity(attr, opts \\ []) when attr in [:handle, :*] do
478478+ opts = Keyword.validate!(opts, as_string: true)
479479+ as_string = Keyword.get(opts, :as_string)
480480+481481+ struct = %__MODULE__{
482482+ resource: "identity",
483483+ positional: Atom.to_string(attr)
484484+ }
485485+486486+ if as_string, do: to_string(struct), else: struct
487487+ end
488488+489489+ @doc """
490490+ Creates a repo permission for write access to records in the account's public
491491+ repository.
492492+493493+ Controls write access to specific record types (collections) with optional
494494+ restrictions on the types of operations allowed (create, update, delete).
495495+496496+ When no options are provided, all operations are permitted. When any action
497497+ option is explicitly set, only the actions set to `true` are enabled. This
498498+ allows for precise control over permissions.
499499+500500+ See the [AT Protocol documentation](https://atproto.com/specs/permission#repo)
501501+ for more information.
502502+503503+ ## Parameters
504504+ - `collection_or_collections` - A single collection NSID string or list of
505505+ collection NSIDs. Use `"*"` for wildcard access to all record types (not
506506+ allowed in permission sets).
507507+ - `options` - Keyword list to restrict operations. If omitted, all operations
508508+ are allowed. If any action is specified, only explicitly enabled actions are
509509+ permitted.
510510+511511+ ## Options
512512+ - `:create` - Allow creating new records.
513513+ - `:update` - Allow updating existing records.
514514+ - `:delete` - Allow deleting records.
515515+ - `:as_string` (optional) - If `true` (default), returns a scope string,
516516+ otherwise returns a Permission struct.
517517+518518+ If `:as_string` is true a scope string is returned, otherwise the underlying
519519+ Permission struct is returned.
520520+521521+ ## Examples
522522+523523+ # Full permission on a single record type (all actions enabled, actions omitted)
524524+ iex> Atex.OAuth.Permission.repo("app.example.profile")
525525+ "repo:app.example.profile"
526526+527527+ # Create only permission (other actions implicitly disabled)
528528+ iex> Atex.OAuth.Permission.repo("app.example.post", create: true, as_string: false)
529529+ %Atex.OAuth.Permission{
530530+ resource: "repo",
531531+ positional: "app.example.post",
532532+ parameters: [{"action", "create"}]
533533+ }
534534+535535+ # Delete only permission
536536+ iex> Atex.OAuth.Permission.repo("app.example.like", delete: true)
537537+ "repo:app.example.like?action=delete"
538538+539539+ # Create and update only, delete implicitly disabled
540540+ iex> Atex.OAuth.Permission.repo("app.example.repost", create: true, update: true)
541541+ "repo:app.example.repost?action=update&action=create"
542542+543543+ # Multiple collections with full permissions (no options provided, actions omitted)
544544+ iex> Atex.OAuth.Permission.repo(["app.example.profile", "app.example.post"])
545545+ "repo?collection=app.example.profile&collection=app.example.post"
546546+547547+ # Multiple collections with only update permission (as struct)
548548+ iex> Atex.OAuth.Permission.repo(["app.example.like", "app.example.repost"], update: true, as_string: false)
549549+ %Atex.OAuth.Permission{
550550+ resource: "repo",
551551+ positional: nil,
552552+ parameters: [
553553+ {"collection", "app.example.like"},
554554+ {"collection", "app.example.repost"},
555555+ {"action", "update"}
556556+ ]
557557+ }
558558+559559+ # Wildcard permission (all record types, all actions enabled, actions omitted)
560560+ iex> Atex.OAuth.Permission.repo("*")
561561+ "repo:*"
562562+ """
563563+ @spec repo(String.t() | list(String.t()), list(repo_opt())) :: t() | String.t()
564564+ def repo(collection_or_collections, actions \\ [create: true, update: true, delete: true])
565565+566566+ def repo(_collection, []),
567567+ do:
568568+ raise(
569569+ ArgumentError,
570570+ ":actions must not be an empty list. If you want to have all actions enabled, either set them explicitly or remove the empty list argument."
571571+ )
572572+573573+ def repo(collection, actions) when is_binary(collection), do: repo([collection], actions)
574574+575575+ def repo(collections, actions) when is_list(collections) do
576576+ actions =
577577+ Keyword.validate!(actions, [:create, :update, :delete, as_string: true])
578578+579579+ # Check if any action keys were explicitly provided
580580+ has_explicit_actions =
581581+ Keyword.has_key?(actions, :create) ||
582582+ Keyword.has_key?(actions, :update) ||
583583+ Keyword.has_key?(actions, :delete)
584584+585585+ # If no action keys provided, default all to true; otherwise use explicit values
586586+ create = if has_explicit_actions, do: Keyword.get(actions, :create, false), else: true
587587+ update = if has_explicit_actions, do: Keyword.get(actions, :update, false), else: true
588588+ delete = if has_explicit_actions, do: Keyword.get(actions, :delete, false), else: true
589589+ all_actions_true = create && update && delete
590590+591591+ as_string = Keyword.get(actions, :as_string)
592592+ singular_collection = length(collections) == 1
593593+ collection_parameters = Enum.map(collections, &{"collection", &1})
594594+595595+ parameters =
596596+ []
597597+ |> add_repo_param(:create, create, all_actions_true)
598598+ |> add_repo_param(:update, update, all_actions_true)
599599+ |> add_repo_param(:delete, delete, all_actions_true)
600600+ |> add_repo_param(:collections, collection_parameters)
601601+602602+ struct = %__MODULE__{
603603+ resource: "repo",
604604+ positional: if(singular_collection, do: hd(collections)),
605605+ parameters: parameters
606606+ }
607607+608608+ if as_string, do: to_string(struct), else: struct
609609+ end
610610+611611+ # When all actions are true, omit them
612612+ defp add_repo_param(list, _type, _value, true), do: list
613613+ # Otherwise add them in
614614+ defp add_repo_param(list, :create, true, false), do: [{"action", "create"} | list]
615615+ defp add_repo_param(list, :update, true, false), do: [{"action", "update"} | list]
616616+ defp add_repo_param(list, :delete, true, false), do: [{"action", "delete"} | list]
617617+618618+ # Catch-all for 4-arity version (must be before 3-arity)
619619+ defp add_repo_param(list, _type, _value, _all_true), do: list
620620+621621+ defp add_repo_param(list, :collections, [_ | [_ | _]] = collections),
622622+ do: Enum.concat(collections, list)
623623+624624+ defp add_repo_param(list, _type, _value), do: list
625625+626626+ @doc """
627627+ Creates an RPC permission for authenticated API requests to remote services.
628628+629629+ The permission is parameterised by the remote endpoint (`lxm`, short for
630630+ "Lexicon Method") and the identity of the remote service (the audience,
631631+ `aud`). Permissions must be restricted by at least one of these parameters.
632632+633633+ See the [AT Protocol documentation](https://atproto.com/specs/permission#rpc)
634634+ for more information.
635635+636636+ ## Parameters
637637+ - `lxm` - A single NSID string or list of NSID strings representing API
638638+ endpoints. Use `"*"` for wildcard access to all endpoints.
639639+ - `opts` - Keyword list of options.
640640+641641+ ## Options
642642+ - `:aud` (semi-required) - Audience of API requests as a DID service
643643+ reference (e.g., `"did:web:api.example.com#srvtype"`). Supports wildcard
644644+ (`"*"`).
645645+ - `:inherit_aud` (optional) - If `true`, the `aud` value will be inherited
646646+ from permission set invocation context. Only used inside permission sets.
647647+ - `:as_string` (optional) - If `true` (default), returns a scope string,
648648+ otherwise returns a Permission struct.
649649+650650+ > #### Note {: .info}
651651+ >
652652+ > `aud` and `lxm` cannot both be wildcard. The permission must be restricted
653653+ > by at least one of them.
654654+655655+ If `:as_string` is true a scope string is returned, otherwise the underlying
656656+ Permission struct is returned.
657657+658658+ ## Examples
659659+660660+ # Single endpoint with wildcard audience (as string)
661661+ iex> Atex.OAuth.Permission.rpc("app.example.moderation.createReport", aud: "*")
662662+ "rpc:app.example.moderation.createReport?aud=*"
663663+664664+ # Multiple endpoints with specific service (as struct)
665665+ iex> Atex.OAuth.Permission.rpc(
666666+ ...> ["app.example.getFeed", "app.example.getProfile"],
667667+ ...> aud: "did:web:api.example.com#svc_appview",
668668+ ...> as_string: false
669669+ ...> )
670670+ %Atex.OAuth.Permission{
671671+ resource: "rpc",
672672+ positional: nil,
673673+ parameters: [
674674+ {"aud", "did:web:api.example.com#svc_appview"},
675675+ {"lxm", "app.example.getFeed"},
676676+ {"lxm", "app.example.getProfile"}
677677+ ]
678678+ }
679679+680680+ # Wildcard method with specific service
681681+ iex> Atex.OAuth.Permission.rpc("*", aud: "did:web:api.example.com#svc_appview")
682682+ "rpc:*?aud=did:web:api.example.com%23svc_appview"
683683+684684+ # Single endpoint with inherited audience (for permission sets)
685685+ iex> Atex.OAuth.Permission.rpc("app.example.getPreferences", inherit_aud: true)
686686+ "rpc:app.example.getPreferences?inheritAud=true"
687687+688688+ """
689689+ @spec rpc(String.t() | list(String.t()), list(rpc_opt())) :: t() | String.t()
690690+ def rpc(lxm_or_lxms, opts \\ [])
691691+ def rpc(lxm, opts) when is_binary(lxm), do: rpc([lxm], opts)
692692+693693+ def rpc(lxms, opts) when is_list(lxms) do
694694+ opts = Keyword.validate!(opts, aud: nil, inherit_aud: false, as_string: true)
695695+ aud = Keyword.get(opts, :aud)
696696+ inherit_aud = Keyword.get(opts, :inherit_aud)
697697+ as_string = Keyword.get(opts, :as_string)
698698+699699+ # Validation: must have at least one of aud or inherit_aud
700700+ cond do
701701+ is_nil(aud) && !inherit_aud ->
702702+ raise ArgumentError,
703703+ "RPC permissions must specify either `:aud` or `:inheritAud` option."
704704+705705+ !is_nil(aud) && inherit_aud ->
706706+ raise ArgumentError,
707707+ "RPC permissions cannot specify both `:aud` and `:inheritAud` options."
708708+709709+ # Both lxm and aud cannot be wildcard
710710+ length(lxms) == 1 && hd(lxms) == "*" && aud == "*" ->
711711+ raise ArgumentError, "RPC permissions cannot have both wildcard `lxm` and wildcard `aud`."
712712+713713+ true ->
714714+ singular_lxm = length(lxms) == 1
715715+ lxm_parameters = Enum.map(lxms, &{"lxm", &1})
716716+717717+ parameters =
718718+ cond do
719719+ inherit_aud && singular_lxm ->
720720+ [{"inheritAud", "true"}]
721721+722722+ inherit_aud ->
723723+ [{"inheritAud", "true"} | lxm_parameters]
724724+725725+ singular_lxm ->
726726+ [{"aud", aud}]
727727+728728+ true ->
729729+ [{"aud", aud} | lxm_parameters]
730730+ end
731731+732732+ struct = %__MODULE__{
733733+ resource: "rpc",
734734+ positional: if(singular_lxm, do: hd(lxms)),
735735+ parameters: parameters
736736+ }
737737+738738+ if as_string, do: to_string(struct), else: struct
739739+ end
740740+ end
741741+742742+ @doc """
743743+ Creates an include permission for referencing a permission set.
744744+745745+ Permission sets are Lexicon schemas that bundle together multiple permissions
746746+ under a single NSID. This allows developers to request a group of related
747747+ permissions with a single scope string, improving user experience by reducing
748748+ the number of individual permissions that need to be reviewed.
749749+750750+ The `nsid` parameter is required and must be a valid NSID that resolves to a
751751+ permission set Lexicon schema. An optional `aud` parameter can be used to specify
752752+ the audience for any RPC permissions within the set that have `inheritAud: true`.
753753+754754+ See the [AT Protocol documentation](https://atproto.com/specs/permission#permission-sets)
755755+ for more information.
756756+757757+ ## Parameters
758758+ - `nsid` - The NSID of the permission set (e.g., "com.example.authBasicFeatures")
759759+ - `opts` - Keyword list of options.
760760+761761+ ## Options
762762+ - `:aud` (optional) - Audience of API requests as a DID service reference
763763+ (e.g., "did:web:api.example.com#srvtype"). Supports wildcard (`"*"`).
764764+ - `:as_string` (optional) - If `true` (default), returns a scope string,
765765+ otherwise returns a Permission struct.
766766+767767+ If `:as_string` is true a scope string is returned, otherwise the underlying
768768+ Permission struct is returned.
769769+770770+ ## Examples
771771+772772+ # Include a permission set (as string)
773773+ iex> Atex.OAuth.Permission.include("com.example.authBasicFeatures")
774774+ "include:com.example.authBasicFeatures"
775775+776776+ # Include a permission set with audience (as struct)
777777+ iex> Atex.OAuth.Permission.include("com.example.authFull", aud: "did:web:api.example.com#svc_chat", as_string: false)
778778+ %Atex.OAuth.Permission{
779779+ resource: "include",
780780+ positional: "com.example.authFull",
781781+ parameters: [{"aud", "did:web:api.example.com#svc_chat"}]
782782+ }
783783+784784+ # Include a permission set with wildcard audience
785785+ iex> Atex.OAuth.Permission.include("app.example.authFull", aud: "*")
786786+ "include:app.example.authFull?aud=*"
787787+788788+ """
789789+ @spec include(String.t(), list(include_opt())) :: t() | String.t()
790790+ def include(nsid, opts \\ []) do
791791+ opts = Keyword.validate!(opts, aud: nil, as_string: true)
792792+ aud = Keyword.get(opts, :aud)
793793+ as_string = Keyword.get(opts, :as_string)
794794+795795+ parameters = if !is_nil(aud), do: [{"aud", aud}], else: []
796796+797797+ struct = %__MODULE__{
798798+ resource: "include",
799799+ positional: nsid,
800800+ parameters: parameters
801801+ }
802802+803803+ if as_string, do: to_string(struct), else: struct
804804+ end
805805+end
806806+807807+defimpl String.Chars, for: Atex.OAuth.Permission do
808808+ def to_string(permission), do: Atex.OAuth.Permission.to_string(permission)
809809+end
+50
test/atex/oauth/permission_test.exs
···11+defmodule Atex.OAuth.PermissionTest do
22+ use ExUnit.Case, async: true
33+ alias Atex.OAuth.Permission
44+ doctest Permission
55+66+ describe "account/1" do
77+ test "requires `:attr`" do
88+ assert_raise ArgumentError, ~r/`:attr` must be provided/, fn ->
99+ Permission.account()
1010+ end
1111+ end
1212+1313+ test "requires valid `:attr`" do
1414+ assert_raise ArgumentError, ~r/`:attr` must be `:email` or `:repo`/, fn ->
1515+ Permission.account(attr: :foobar)
1616+ end
1717+1818+ assert Permission.account(attr: :email)
1919+ end
2020+2121+ test "requires valid `:action`" do
2222+ assert_raise ArgumentError, ~r/`:action` must be `:read`, `:manage`, or `nil`/, fn ->
2323+ Permission.account(attr: :email, action: :foobar)
2424+ end
2525+2626+ assert Permission.account(attr: :email, action: :manage)
2727+ assert Permission.account(attr: :repo, action: nil)
2828+ end
2929+ end
3030+3131+ describe "rpc/2" do
3232+ test "requires at least `:aud` or `:inherit_aud`" do
3333+ assert_raise ArgumentError, ~r/must specify either/, fn ->
3434+ Permission.rpc("com.example.getProfile")
3535+ end
3636+ end
3737+3838+ test "disallows `:aud` and `:inherit_aud` at the same time" do
3939+ assert_raise ArgumentError, ~r/cannot specify both/, fn ->
4040+ Permission.rpc("com.example.getProfile", aud: "example", inherit_aud: true)
4141+ end
4242+ end
4343+4444+ test "disallows wildcard for `lxm` and `aud` at the same time" do
4545+ assert_raise ArgumentError, ~r/wildcard `lxm` and wildcard `aud`/, fn ->
4646+ Permission.rpc("*", aud: "*")
4747+ end
4848+ end
4949+ end
5050+end