47 Commits

Author SHA1 Message Date
self 80101b7e78 fix: tolerate Parrhesia EOSE hints
Handle the NIP-67 EOSE tuple shape emitted by newer Parrhesia while keeping compatibility with the previous message format.
2026-06-08 21:53:24 +02:00
self 1483d30d7e dev: add en_GB locale archive to devenv
Provide a Nix glibc locale archive with en_GB.UTF-8 so tools inside the devenv shell inherit a valid UTF-8 locale.
2026-06-08 21:52:55 +02:00
self 1cbcb5ea55 chore: use conventional Guix manifest
Rename the repo Guix development manifest to manifest.scm and update the wrapper and direnv integration.
2026-06-01 19:38:21 +02:00
self 742359c2cb build: load dotenv in Guix dev shell
Source the repo .env before entering the pinned Guix shell so Guix fallback commands match the devenv environment more closely.
2026-06-01 16:33:13 +02:00
self 3bc16c7cb5 docs: note Guix plugin shell fallback
Clarify that plugin workflows should use devenv when available and the repo-local Guix wrapper when devenv is unavailable.
2026-06-01 15:32:30 +02:00
self d0ba98f382 build: add pinned Guix dev shell wrapper
Add repo-local guix/channels.scm time-machine pins and a guix/guix-dev bootstrap wrapper. Update the Guix direnv path to use the pinned channel set.
2026-05-31 22:57:51 +02:00
self 603ad5d0ae feat: support Guix development shell
Add a repo-local Guix manifest, direnv backend selection, and PATH-based hook configuration for the Aether plugin development environment.
2026-05-30 22:29:37 +02:00
self 34bec7225b feat: use shared Tribes gettext
Use tgettext for shared sign-in UI copy.
2026-05-27 20:07:00 +02:00
self b4b8c83ddb feat: namespace plugin identity
Adopt canonical plugin id/slug manifest fields, vendor-prefixed OTP app naming, and fully-qualified capability ids for Aether.
2026-05-27 19:05:51 +02:00
self 44b9c6caba chore: quiet dev Ash domain warnings
Suppress Ash domain config inclusion warnings while compiling the host as a dev-only plugin dependency.
2026-05-27 16:19:13 +02:00
self c1f4339dde refactor: move Aether into TribeOne plugin namespace
Use TribeOne.TribesPlugin.Aether modules throughout the plugin and expose chat@1 from the entry module for capability-based consumers.
2026-05-26 01:13:28 +02:00
self 341dcb573f docs: update Aether README for chat APIs
Document the current social and chat feature set, NIP-17/NIP-04 behavior, runtime requirements, and plugin API surface. Remove obsolete template content and bump the plugin version to 0.2.0.
2026-05-25 17:20:26 +02:00
self 446fffcadc feat: implement NIP-17 direct messages
Use the session-unlocked Nostr private key to publish and read NIP-17/NIP-59 gift-wrapped DMs via Parrhesia events, with a read-only NIP-04 import path. Wire the chat UI to pass signer context and add nak-backed interoperability tests for inbound and outbound DMs.
2026-05-25 17:13:34 +02:00
self 2884f43f9a test: add external client interop checks
Add deterministic nak coverage for NIP-17/NIP-59 gift wrapping and NIP-04 decryptability, plus a thin algia JSON-consumption smoke test with isolated config.
2026-05-25 16:23:34 +02:00
self 8b1990ee25 feat: add chat backend contract and recipient picker
Introduce plain-Elixir chat backends, participant projections, a chat@1 recipient picker component, and a public-sync user-to-user message flow test while scaffolding NIP-17/NIP-04/Marmot backend capabilities.
2026-05-25 15:54:58 +02:00
self a55bd9612d refactor: extract reusable chat panel component
Move Aether chat panel behavior into a LiveComponent so other plugins can embed it without an iframe while ChatLive continues to own standalone routing.
2026-05-25 12:50:05 +02:00
self d257221dc8 feat: scaffold marmot chat backend
Pin marmot-ts 0.5.1, expose Marmot chat routes/configuration, and keep the web UI disabled until transport, storage, and signing adapters are implemented.
2026-05-25 05:47:49 +02:00
self 421fa01076 feat: add embeddable chat panel
Add compact embedded chat routing and provider helpers so other plugins can attach Aether chat channels by URL without linking against Aether internals.
2026-05-25 05:39:37 +02:00
self f386cd38f5 feat: add public synced chat
Introduce the chat@1 capability, public Ash-backed chat channels/messages, standalone Aether chat page, migrations, and plugin tests.
2026-05-25 05:37:15 +02:00
self 21dd359b2f docs: clarify plugin hook registration
Document that external plugin hooks are registered through global_js bundles and window.TribesPluginHooks rather than host-side colocated imports.
2026-05-24 23:46:10 +02:00
self 763f1ffe14 build: configure npm release delay
Use npm's native min-release-age setting in devenv and assets npm config so dependency resolution avoids very fresh publishes.
2026-05-24 19:53:30 +02:00
self 8f8748b278 build: update devenv inputs
Run devenv update to refresh pinned devenv and related inputs.
2026-05-24 16:26:21 +02:00
self a6fa08118f build: refresh devenv lock
Refresh devenv.lock after the devenv update normalized lock-file inputs.
2026-05-24 16:16:55 +02:00
self 09fa00c92c chore: declare git-hooks input in devenv.yaml
Newer devenv versions require git-hooks.nix to be declared as an
explicit input when devenv.nix uses `git-hooks.hooks`.
2026-05-24 15:20:00 +02:00
self 0fa953420b fix: handle batched aether note events
Accept Parrhesia batch delivery in the timeline LiveView and reuse the existing note processing path for filtering, de-duplication, profile cache updates, and stream insertion.

Add a regression test that sends the batched message shape to the LiveView.
2026-05-23 15:59:44 +02:00
self 20be0857a5 feat: expose plugin migration commands
Forward ash.codegen and ecto.migration through the Aether plugin wrapper and update agent guidance to prefer AshPostgres codegen for resource-backed schema changes.
2026-05-23 15:58:13 +02:00
self ca4995aa2b build: ignore playwright mcp state
Exclude local Playwright MCP session state from the aether plugin repository.
2026-05-21 12:42:42 +02:00
self dbb5491020 fix: remove relay internals from timeline
Drop user-facing relay topology details from the Aether timeline and refresh the Parrhesia lock entry used by the plugin.
2026-05-18 12:19:35 +02:00
self da003b0a4f fix: use alliance plugin service
Switch Aether's timeline feed from the removed communities service to the new alliance service contract.
2026-05-14 19:17:46 +02:00
self 24f314a6d5 fix: use host-backed plugin test aliases
Route raw Mix test and precommit aliases through the plugin wrapper workflow.

Document that normal plugin checks should use plugin test or plugin precommit.
2026-05-09 19:43:30 +02:00
self aecc7da70b fix: advertise social plugin contract 2026-05-09 17:31:18 +02:00
self c0eab283a1 chore: Update plugin scaffolding and nosdump wrapper 2026-05-07 16:49:20 +02:00
self 5d6ab457ef build(plugin): add shared plugin validate workflow
Extend the local plugin helper to forward the new validate command through the host environment and document the validate/test/precommit workflow in the plugin README.
2026-04-27 16:41:44 +02:00
self c121bde9b8 test(aether): adopt host-backed plugin workflow
Switch Aether to the stricter plugin-facing auth and community contract.
Replace local structural coverage with host-backed contract and page tests.
Add plugin helper wrappers and shared test config for the host devenv flow.
2026-04-27 15:42:31 +02:00
self 2afde628ca refactor: Adjust for tribes data model 2026-04-27 12:42:13 +02:00
self 5024e8857c build: Credo, precommit 2026-04-26 22:06:08 +02:00
self 1a0ba6a93c refactor: switch timeline auth state to scope 2026-04-11 18:29:55 +02:00
self 118d32241f refactor: use typed plugin nostr data 2026-04-10 18:38:06 +02:00
self 6fc8be20bf refactor: use plugin route helpers in timeline 2026-04-10 13:58:45 +02:00
self 8297334ea5 refactor: keep aether timeline on cluster-aware nostr wrappers
Retain TimelineLive on the plugin-facing Nostr service after the host implementation switched to multi-relay defaults, so the plugin benefits from cluster-aware reads and publish fanout without falling back to direct host internals.
2026-04-10 12:31:59 +02:00
self c904de90d7 refactor: switch aether timeline to plugin host services
Replace direct Tribes and TribesWeb module references in TimelineLive with the new Tribes.Plugin wrappers so the plugin can compile cleanly against tribes_plugin_api instead of host internals.

Use the new cluster relay discovery service to surface the known cluster relay set in the timeline UI while keeping the current local-relay publish and live-read behavior explicit.
2026-04-10 11:57:44 +02:00
self 4b7fe1b9ed dev: devenv.nix, nosdump pkg 2026-04-09 10:44:46 +02:00
self a07942b644 Render Aether in Tribes layout 2026-04-08 18:33:17 +02:00
self 63e10ad5bc Align aether plugin entrypoint and docs 2026-04-08 13:11:39 +02:00
self f833791991 Build Aether against tribes_plugin_api 2026-04-08 01:16:15 +02:00
self 6645ec48a0 Document tribes.migrate workflow in plugin README 2026-04-04 22:39:37 +02:00
self 78f6c11b30 Rename to aether plugin and add /aether timeline integration 2026-04-04 20:33:51 +02:00
66 changed files with 6751 additions and 362 deletions
+179
View File
@@ -0,0 +1,179 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage, false},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagFIXME, []},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, false},
{Credo.Check.Refactor.FunctionArity, [max_arity: 13]},
{Credo.Check.Refactor.LongQuoteBlocks, false},
{Credo.Check.Refactor.MapInto, false},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 7]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.LazyLogging, false},
{Credo.Check.Warning.MixEnv, false},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.UnsafeExec, []},
#
# Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
#
# Controversial and experimental checks (opt-in, just replace `false` with `[]`)
#
{Credo.Check.Readability.StrictModuleLayout, false},
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
{Credo.Check.Consistency.UnusedVariableNames, false},
{Credo.Check.Design.DuplicatedCode, false},
{Credo.Check.Readability.AliasAs, false},
{Credo.Check.Readability.MultiAlias, false},
{Credo.Check.Readability.Specs, false},
{Credo.Check.Readability.SinglePipe, false},
{Credo.Check.Readability.WithCustomTaggedTuple, false},
{Credo.Check.Refactor.ABCSize, false},
{Credo.Check.Refactor.AppendSingleItem, false},
{Credo.Check.Refactor.DoubleBooleanNegation, false},
{Credo.Check.Refactor.ModuleDependencies, false},
{Credo.Check.Refactor.NegatedIsNil, false},
{Credo.Check.Refactor.PipeChainStart, false},
{Credo.Check.Refactor.VariableRebinding, false},
{Credo.Check.Warning.LeakyEnvironment, false},
{Credo.Check.Warning.MapGetUnsafePass, false},
{Credo.Check.Warning.UnsafeToAtom, false}
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
]
}
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s
# Backend selection:
# - default: use devenv when available, otherwise Guix
# - optional local override: write "guix" or "devenv" to .dev-shell
backend=auto
if [ -f .dev-shell ]; then
backend=$(tr -d '[:space:]' < .dev-shell)
watch_file .dev-shell
fi
use_aether_guix() {
watch_file manifest.scm
watch_file guix/channels.scm
watch_file guix/guix-dev
watch_file .pre-commit-config.yaml
watch_file scripts/plugin
export GUIX_DEV_ROOT="$PWD"
eval "$(guix time-machine -C guix/channels.scm -- shell -m manifest.scm --search-paths)"
if [ -f .env ]; then set -a; . ./.env; set +a; fi
export MIX_OS_DEPS_COMPILE_PARTITION_COUNT=8
export NODE_ENV=development
export NPM_CONFIG_MIN_RELEASE_AGE=7
export TRIBES_HOST_ROOT="${TRIBES_HOST_ROOT:-$PWD/../tribes}"
plugin() { bash "$PWD/scripts/plugin" "$@"; }
}
use_aether_devenv() {
eval "$(devenv direnvrc)"
use devenv
}
case "$backend" in
auto)
if command -v devenv >/dev/null 2>&1; then
use_aether_devenv
elif command -v guix >/dev/null 2>&1; then
use_aether_guix
else
log_error "neither devenv nor guix is available"
exit 1
fi
;;
nix|devenv)
if ! command -v devenv >/dev/null 2>&1; then
log_error ".dev-shell requested devenv, but devenv is unavailable"
exit 1
fi
use_aether_devenv
;;
guix)
if ! command -v guix >/dev/null 2>&1; then
log_error ".dev-shell requested guix, but guix is unavailable"
exit 1
fi
use_aether_guix
;;
*)
log_error "unknown .dev-shell backend: $backend"
exit 1
;;
esac
+21
View File
@@ -2,10 +2,17 @@
/deps/
/_build/
/dist/
/result
/result-*
# Node
/node_modules/
/assets/node_modules/
/priv/static/*
!/priv/static/.gitkeep
# Browser tooling
.playwright-mcp/
# Generated on crash
erl_crash.dump
@@ -23,3 +30,17 @@ Thumbs.db
# Env files
.env
.env.local
# To do list
/TODO.md
# Devenv
.devenv*
.env
.null-ls_*.nix
devenv.local.nix
.guix-dev/
.dev-shell
# direnv
.direnv
+34
View File
@@ -0,0 +1,34 @@
# Shared by Nix/devenv and Guix shells. Keep hook entries PATH-based.
default_stages:
- pre-commit
repos:
- repo: local
hooks:
- id: check-added-large-files
name: check added large files
entry: scripts/hooks/check-added-large-files
args: ["--maxkb=131072"]
language: system
types: [file]
stages: [pre-commit, pre-push, manual]
- id: mix-format
name: mix format
entry: mix format
language: system
files: '\.(ex|exs|heex)$'
types: [file]
stages: [pre-commit]
- id: prettier
name: prettier
entry: prettier
args:
- --ignore-unknown
- --list-different
- --write
language: system
files: '\.(js|ts|tsx|css)$'
types: [text]
stages: [pre-commit]
+669
View File
@@ -0,0 +1,669 @@
# Tribes Aether Plugin Agent Guide
This repository is the Tribes Aether plugin. Keep changes scoped to the plugin
contract, migrations, assets, and tests owned by this repo.
## Required Workflow
- Use `plugin` for plugin-aware commands inside the repo development shell: `plugin validate`,
`plugin test`, `plugin precommit`, and `plugin smoke`. Outside the development
shell, use the local `scripts/plugin` wrapper.
- Prefer `devenv shell -- <command>` when running repo commands from outside the
development shell. If `devenv` is unavailable, use `./guix/guix-dev -- <command>`
rather than raw `guix shell` so pinned channels from `guix/channels.scm` are used.
- Raw `mix test` is not the normal entrypoint for this repo; `plugin test`
invokes `mix raw_test` with the host database/services and host
plugin-manager paths.
- Keep browser assets under the plugin asset pipeline and avoid host app
internals unless the plugin API requires them.
- For AshPostgres resource changes, generate migrations with
`plugin ash.codegen <name>`. Use `plugin ecto.migration <name>` only for rare
manual schema migrations that are not derived from Ash resources.
- Do not edit an existing migration after it may have run; add a new migration
instead.
## Plugin Contract
- `manifest.json` `entry_module` must point at `TribeOne.TribesPlugin.Aether.Plugin`.
- Keep runtime spec fields aligned with `manifest.json`.
- Treat host integrations as capability-based plugin contracts.
<!-- usage-rules-start -->
<!-- usage_rules-start -->
## usage_rules usage
_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_
## Using Usage Rules
Many packages have usage rules, which you should *thoroughly* consult before taking any
action. These usage rules contain guidelines and rules *directly from the package authors*.
They are your best source of knowledge for making decisions.
## Modules & functions in the current app and dependencies
When looking for docs for modules & functions that are dependencies of the current project,
or for Elixir itself, use `mix usage_rules.docs`
```
# Search a whole module
mix usage_rules.docs Enum
# Search a specific function
mix usage_rules.docs Enum.zip
# Search a specific function & arity
mix usage_rules.docs Enum.zip/1
```
## Searching Documentation
You should also consult the documentation of any tools you are using, early and often. The best
way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have
found what you are looking for, use the links in the search results to get more detail. For example:
```
# Search docs for all packages in the current application, including Elixir
mix usage_rules.search_docs Enum.zip
# Search docs for specific packages
mix usage_rules.search_docs Req.get -p req
# Search docs for multi-word queries
mix usage_rules.search_docs "making requests" -p req
# Search only in titles (useful for finding specific functions/modules)
mix usage_rules.search_docs "Enum.zip" --query-by title
```
<!-- usage_rules-end -->
<!-- usage_rules:elixir-start -->
## usage_rules:elixir usage
# Elixir Core Usage Rules
## Pattern Matching
- Use pattern matching over conditional logic when possible
- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies
- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps
## Error Handling
- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail
- Avoid raising exceptions for control flow
- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}`
## Common Mistakes to Avoid
- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned.
- Don't use `Enum` functions on large collections when `Stream` is more appropriate
- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions
- Prefer `Enum` functions like `Enum.reduce` over recursion
- When recursion is necessary, prefer to use pattern matching in function heads for base case detection
- Using the process dictionary is typically a sign of unidiomatic code
- Only use macros if explicitly requested
- There are many useful standard library functions, prefer to use them where possible
## Function Design
- Use guard clauses: `when is_binary(name) and byte_size(name) > 0`
- Prefer multiple function clauses over complex conditional logic
- Name functions descriptively: `calculate_total_price/2` not `calc/2`
- Predicate function names should not start with `is` and should end in a question mark.
- Names like `is_thing` should be reserved for guards
## Data Structures
- Use structs over maps when the shape is known: `defstruct [:name, :age]`
- Prefer keyword lists for options: `[timeout: 5000, retries: 3]`
- Use maps for dynamic key-value data
- Prefer to prepend to lists `[new | list]` not `list ++ [new]`
## Mix Tasks
- Use `mix help` to list available mix tasks
- Use `mix help task_name` to get docs for an individual task
- Read the docs and options fully before using tasks
## Testing
- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123`
- Limit the number of failed tests with `mix test --max-failures n`
- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests
- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end`
- Use `mix help test` to for full documentation on running tests
## Debugging
- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console.
<!-- usage_rules:elixir-end -->
<!-- usage_rules:otp-start -->
## usage_rules:otp usage
# OTP Usage Rules
## GenServer Best Practices
- Keep state simple and serializable
- Handle all expected messages explicitly
- Use `handle_continue/2` for post-init work
- Implement proper cleanup in `terminate/2` when necessary
## Process Communication
- Use `GenServer.call/3` for synchronous requests expecting replies
- Use `GenServer.cast/2` for fire-and-forget messages.
- When in doubt, use `call` over `cast`, to ensure back-pressure
- Set appropriate timeouts for `call/3` operations
## Fault Tolerance
- Set up processes such that they can handle crashing and being restarted by supervisors
- Use `:max_restarts` and `:max_seconds` to prevent restart loops
## Task and Async
- Use `Task.Supervisor` for better fault tolerance
- Handle task failures with `Task.yield/2` or `Task.shutdown/2`
- Set appropriate task timeouts
- Use `Task.async_stream/3` for concurrent enumeration with back-pressure
<!-- usage_rules:otp-end -->
<!-- phoenix:ecto-start -->
## phoenix:ecto usage
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## phoenix:html usage
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## phoenix:liveview usage
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle that imports the generated `phoenix-colocated/<otp_app>` module
- external Tribes plugin OTP apps are not auto-imported by the host `app.js` bundle; use `assets.global_js` plus `window.TribesPluginHooks` for plugin hooks
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) in Tribes plugins must be registered by a plugin JS bundle listed in `manifest.json` `assets.global_js`; the host merges `window.TribesPluginHooks` into the LiveSocket constructor:
window.TribesPluginHooks = window.TribesPluginHooks || {};
window.TribesPluginHooks.MyHook = {
mounted() { ... }
};
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- phoenix:phoenix-start -->
## phoenix:phoenix usage
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- ash-start -->
## ash usage
_A declarative, extensible framework for building Elixir applications._
# Rules for working with Ash
## Understanding Ash
Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.
<!-- ash-end -->
<!-- ash:actions-start -->
## ash:actions usage
[ash:actions usage rules](deps/ash/usage-rules/actions.md)
<!-- ash:actions-end -->
<!-- ash:migrations-start -->
## ash:migrations usage
[ash:migrations usage rules](deps/ash/usage-rules/migrations.md)
<!-- ash:migrations-end -->
<!-- ash:testing-start -->
## ash:testing usage
[ash:testing usage rules](deps/ash/usage-rules/testing.md)
<!-- ash:testing-end -->
<!-- tribes_plugin_api-start -->
## tribes_plugin_api usage
_tribes_plugin_api_
# Tribes Plugin API Usage Rules
These rules apply when implementing the public plugin contract from
`tribes_plugin_api`. For deeper context from a plugin checkout, see
[`../tribes/docs/plugins.md`](../tribes/docs/plugins.md); from this package
source directory, the same document is at `../docs/plugins.md`.
## Entry Modules
- Implement the runtime contract with `Tribes.Plugin` or `Tribes.Plugin.Base`.
- Prefer `use Tribes.Plugin.Base, otp_app: :your_plugin` for normal plugins; it
reads `manifest.json` and fills the manifest-backed spec fields.
- Keep plugin modules under an owner-controlled namespace, such as
`TribeOne.TribesPlugin.<Plugin>.Plugin` for first-party plugins or
`AcmeCorp.TribesPlugins.Foo.Plugin` for third-party plugins. The entry module
must end in `.Plugin` and should be the module named by `manifest.json`.
- `register/1` must return a `Tribes.Plugin.Spec` struct or a map/struct that
validates into that spec.
## Spec Discipline
- Fields mirrored from `manifest.json` must match exactly after capability
normalization: `name`, `version`, `provider_priority`, `provides`,
`requires`, and `enhances_with`.
- Use `%Tribes.Plugin.Spec.NavItem{}` and `%Tribes.Plugin.Spec.Page{}` or maps
with only the documented keys. Unknown keys fail validation.
- Use atom modules for `live_view`, plug modules, hook modules, `ui_components`,
and `ash_domains`; do not pass module names as strings in the runtime spec.
- Keep `children` as valid supervisor child specs and make plugin processes
restartable under normal OTP supervision rules.
- Run `scripts/plugin validate` or `mix tribes.plugin.validate` before relying
on runtime registration behavior.
## Pages, Layouts, And Auth
- Use `Tribes.Plugin.Layouts.app` for pages that should render inside host
chrome.
- Use `Tribes.Plugin.LiveUserAuth` on plugin LiveViews when they need host user
context.
- Avoid `use TribesWeb, :live_view` in standalone release-facing plugin code;
use `Phoenix.LiveView` and public plugin/UI APIs instead.
## Config Schema
- `config_schema` is for small admin-editable runtime defaults rendered by the
host UI and stored through `Tribes.ConfigStore`.
- Group IDs and setting keys must use the identifier format accepted by the
validator: lowercase segments with letters, digits, underscores, and dots.
- Supported setting types are `:string`, `:text`, `:boolean`, `:integer`,
`:number`, `:enum`, `:list`, and `:object`.
- Use `options` only with `:enum`, and provide stable stored values rather than
display text as the value.
<!-- tribes_plugin_api-end -->
<!-- tribes-start -->
## tribes usage
_tribes_
# Tribes Plugin Development Rules
These rules are for external Tribes plugin projects that depend on the host
checkout during development. For the full contract, see
[`../tribes/docs/plugins.md`](../tribes/docs/plugins.md) and
[`../tribes/docs/ui.md`](../tribes/docs/ui.md).
## Plugin Boundaries
- Treat plugins as separate OTP applications. The host discovers them through
`manifest.json` and a single `Tribes.Plugin` entry module, not by reaching
into plugin internals.
- Keep plugin contributions inside the supported runtime spec fields:
`nav_items`, `pages`, `api_routes`, `plugs`, `children`, `global_js`,
`global_css`, `migrations_path`, `ui_components`, `hooks`, `ash_domains`, and
`config_schema`.
- Do not mutate host routers, host Ash domains, endpoint config, or host
supervision trees directly from plugin code.
- Plugin-owned pages and API routes should live under plugin-owned paths. Avoid
taking over unrelated host sections.
## Manifest And Runtime Spec
- `manifest.json` is the static build/runtime contract. Keep `name`,
`version`, `entry_module`, `host_api`, `otp_app`, `provides`, `requires`, and
`enhances_with` aligned with the runtime spec returned by `register/1`.
- `entry_module` must be a valid module ending in `.Plugin` under an owner-controlled namespace.
- Capability versions are discrete breaking-change markers such as `org.tribe-one.caps.ui@1` or
`org.tribe-one.caps.social@1`, not semver. Framework APIs are part of the `host_api` foundation, not separate manifest capabilities.
- Use `requires` for hard dependencies and `enhances_with` for optional
integrations that the plugin can run without.
- Run `scripts/plugin validate` after changing `manifest.json` or the runtime
plugin spec.
## UI And Assets
- Plugin LiveViews that use host chrome should render with
`Tribes.Plugin.Layouts.app` and keep `org.tribe-one.caps.ui@1` in `manifest.json` `requires`.
- Consumers should target the `org.tribe-one.caps.ui@1` facade with `use Tribes.UI` or
`import Tribes.UI.Components`, not a concrete provider module.
- Declare browser assets in `manifest.json` under `assets.global_js` and
`assets.global_css`; the host serves them through the plugin asset surface.
- Register plugin LiveView hooks from `assets.global_js` bundles via
`window.TribesPluginHooks`. Phoenix colocated hooks are not auto-imported from
external plugin OTP apps by the host `app.js` bundle.
- Keep CSS selectors scoped to the plugin, normally with a plugin-specific root
class.
## Runtime Config
- Use plugin OTP app env only for boot-time wiring that is genuinely static.
- For mutable runtime settings, use `Tribes.ConfigStore` and expose small
editable defaults through `config_schema` in the plugin spec.
- `config_schema` is validated by the host and rendered by Tribes in the admin
settings UI. It describes fields only; values are stored as ConfigStore
overrides when an admin saves them.
- If `config_schema.namespace` is omitted, the host uses
`plugin.<plugin_name>`.
- Plugins should read ConfigStore values with the same defaults declared in the
schema, because saving a value equal to the default deletes the override.
- Do not expose secrets, node-local paths, ports, database URLs, TLS material,
package selection, or deployment state through `config_schema`.
## Synced Data
- If a plugin adds Ash resources that should replicate across the cluster, add
them deliberately to the plugin spec `ash_domains` and document replication
semantics in the plugin contract docs.
- Use `AshNostrSync` only for resources whose persisted state is meant to be
cluster-synced. Do not enable it as a default on every plugin resource.
<!-- tribes-end -->
<!-- usage-rules-end -->
+125 -70
View File
@@ -1,103 +1,158 @@
# Tribes Plugin Template
# Aether
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
Aether is an external Tribes plugin that adds local social posting and chat to a
Tribes node.
## Getting Started
It provides:
1. Click **"Use this template"** on GitHub to create your own repo
2. Clone and rename:
- `org.tribe-one.caps.social@1` — a local tribe feed backed by Nostr kind `1` notes.
- `org.tribe-one.caps.chat@1` — reusable chat surfaces for public rooms, embeddable context chat,
NIP-17 direct messages, and future chat backends.
```bash
git clone https://github.com/you/your-plugin.git
cd your-plugin
./scripts/rename.sh your_plugin YourPlugin
```
## Features
3. Edit `manifest.json` — set description, capabilities, requirements
4. Implement your plugin in `lib/your_plugin/plugin.ex`
5. Run tests:
### Local social feed
```bash
mix deps.get
mix test
```
- `/aether` renders the local tribe feed.
- Signed-in users can publish Nostr notes with their Tribes identity.
- Feed subscriptions use the host/Parrhesia Nostr event stream.
## Development
### Public and embeddable chat
For local development alongside a Tribes checkout:
- `/aether/chat` and `/aether/chat/:slug` render standalone chat rooms.
- `/aether/chat/embed/:slug` renders an embeddable chat panel for other plugins
such as Sender livestream pages.
- Public chat uses Aether Ash resources as the synced message projection.
```bash
# Symlink into the host plugins directory
cd /path/to/tribes
ln -s /path/to/your-plugin plugins/your_plugin
### Direct messages
# Start Tribes dev server — your plugin loads automatically
iex --sname dev -S mix phx.server
```
- Signed-in users can start a DM with another local tribe user from the chat
recipient picker.
- New direct conversations default to the `:nostr_nip17` backend.
- NIP-17 DMs are stored canonically as Parrhesia/Nostr kind `1059` giftwrap
events and decrypted into local UI message structs at read time.
- Sender and recipient copies are published so both sides can read the thread.
- Legacy NIP-04 kind `4` DMs are supported as a read-only import/decrypt path.
Edit your plugin source. Phoenix code reloader picks up changes.
Current signing model: Aether uses the existing Tribes session-unlocked Nostr
private key. This produces real NIP-17/NIP-59 and NIP-04 protocol artifacts, but
it is still a server-trusting web model, not browser-only/non-custodial E2EE.
The protocol operations are isolated behind `TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak` so a native
Elixir implementation or NIP-07/NIP-46 signer can replace it later.
## Project Structure
### Marmot scaffold
```
your_plugin/
├── manifest.json # Plugin metadata (Nix build + runtime)
├── mix.exs # Dependencies
├── lib/
│ ├── your_plugin/
│ │ ├── plugin.ex # Tribes.Plugin entry point
│ │ └── application.ex # OTP supervision tree (optional)
│ └── your_plugin_web/
│ └── live/ # LiveView pages
├── assets/ # JS/CSS (one bundle per plugin)
├── priv/
│ ├── static/ # Built assets for release
│ └── repo/migrations/ # Ecto migrations
└── test/
```
A Marmot backend scaffold and UI route exist for future MLS group chat work, but
browser transport, storage, signing, and message rendering are not active yet.
The package pin is kept at `@internet-privacy/marmot-ts@0.5.1`.
## Manifest
## Plugin contract and API surface
`manifest.json` declares your plugin's identity and capabilities:
`manifest.json` declares:
```json
{
"name": "your_plugin",
"provides": ["some_capability@1"],
"requires": ["ecto@1"],
"enhances_with": ["inference@1"]
"id": "org.tribe-one.plugins.aether",
"slug": "aether",
"display_name": "Aether",
"version": "0.2.0",
"entry_module": "TribeOne.TribesPlugin.Aether.Plugin",
"host_api": "1",
"otp_app": "tribe_one_aether",
"provides": ["org.tribe-one.caps.social@1", "org.tribe-one.caps.chat@1"],
"requires": ["org.tribe-one.caps.ui@1"],
"assets": {
"global_js": ["aether.js"],
"global_css": ["aether.css"]
},
"migrations": true
}
```
- **provides** — capabilities this plugin makes available
- **requires** — hard dependencies (build fails without them)
- **enhances_with** — optional dependencies (plugin degrades gracefully)
Runtime contributions from `TribeOne.TribesPlugin.Aether.Plugin` include:
See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification.
- nav items for Aether and Chat,
- LiveView pages for `/aether` and `/aether/chat`,
- the `TribeOne.TribesPlugin.Aether.Chat` Ash domain,
- plugin config schema for chat backend defaults and Marmot UI enablement,
- global JS/CSS assets.
## Testing
The `org.tribe-one.caps.chat@1` provider surface is intentionally small and reusable:
Three test levels:
- `TribeOne.TribesPlugin.Aether.Chat.chat_panel_component/0` returns the reusable chat panel component.
- `TribeOne.TribesPlugin.Aether.Chat.recipient_picker_component/0` returns the recipient picker.
- `TribeOne.TribesPlugin.Aether.Chat.ensure_context_channel/5` creates context-owned chat rooms.
- `TribeOne.TribesPlugin.Aether.Chat.ensure_direct_conversation/4` creates a NIP-17 DM projection.
- `TribeOne.TribesPlugin.Aether.Chat.send_message/3`, `list_conversation_messages/2`, and
`subscribe_conversation/3` dispatch to the selected backend.
- `TribeOne.TribesPlugin.Aether.Chat.embed_path/1`, `standalone_path/1`, and `marmot_path/1` expose
stable route helpers.
- **Unit tests** (`test/your_plugin/`) — plugin logic in isolation
- **Manifest tests** (`test/your_plugin/manifest_test.exs`) — manifest schema validation
- **Contract tests** (`test/contract_test.exs`) — runtime spec matches manifest
Backend modules implement `TribeOne.TribesPlugin.Aether.Chat.Backend`:
Run all: `mix test`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot`
## Building for Release
These are plugin-local today. If other plugins need the same primitives, the
likely host/plugin API candidates are a stable Nostr event publish/query service
and a stable session/external signer interface.
## Development
Use the plugin-aware commands from the repo devenv shell:
```bash
MIX_ENV=prod mix compile
mkdir -p dist/your_plugin
cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/
cp -r priv dist/your_plugin/
cp manifest.json dist/your_plugin/
devenv shell -- plugin validate
devenv shell -- plugin test
devenv shell -- plugin precommit
```
For Nix-based deployment, add your plugin to the host's `plugins.json`.
Outside the devenv shell, use the local wrapper:
## Licence
```bash
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
```
TODO: Choose a licence.
Plain `mix test` and `mix precommit` are not the normal entrypoints; this plugin
suite is host-backed and expects the Tribes test environment.
For local development alongside a Tribes checkout, symlink this repo into the
host plugin directory:
```bash
cd /path/to/tribes
ln -s /path/to/tribes-plugin-aether plugins/aether
iex --sname dev -S mix phx.server
```
Then edit this repo normally. The host watches symlinked external plugins and
reloads changed Elixir, HEEx, assets, manifests, and migrations.
## Assets and hooks
Plugin browser assets are declared in `manifest.json` and served by the host.
Aether currently uses:
- `assets/js/aether.js` for LiveView hooks such as chat auto-scroll,
- `assets/css/aether.css` for plugin-scoped styles.
External plugin hooks are registered through `window.TribesPluginHooks`; Phoenix
colocated hooks from external plugin OTP apps are not auto-imported by the host.
## Runtime requirements
- A Tribes host checkout/runtime with `org.tribe-one.caps.ui@1`.
- Parrhesia available through the host for Nostr event storage and streaming.
- `nak` available in the runtime path for the current NIP-17/NIP-59/NIP-04
protocol implementation.
- Node dependencies under `assets/` for browser asset builds.
## Release packaging
Guix packaging assembles the plugin artifact from the compiled BEAM output,
`priv/`, browser assets, and `manifest.json`. Enable the plugin from the
`guix-tribes` channel-side node configuration.
+1
View File
@@ -0,0 +1 @@
min-release-age=7
@@ -1,13 +1,17 @@
/*
* Plugin CSS entry point.
*
* Served at /plugins-assets/my_plugin/my_plugin.css
* Served at /plugins-assets/aether/aether.css
* and included in the page layout if declared in manifest.json assets.global_css.
*
* Prefix all selectors with your plugin name to avoid collisions
* with host or other plugin styles.
*/
.my-plugin {
.aether {
/* Plugin-scoped styles go here */
}
.aether-chat-message {
scroll-margin-block-end: 1rem;
}
+28
View File
@@ -0,0 +1,28 @@
// Plugin JavaScript entry point.
//
// This file is served by the host at /plugins-assets/aether/aether.js
// and included in the page layout if declared in manifest.json assets.global_js.
//
// Register plugin LiveView hooks here. External plugin OTP apps are not
// auto-imported by the host's phoenix-colocated bundle.
//
// window.TribesPluginHooks = window.TribesPluginHooks || {};
// window.TribesPluginHooks["AetherHook"] = {
// mounted() {
// console.log("Aether hook mounted");
// }
// };
window.TribesPluginHooks = window.TribesPluginHooks || {};
window.TribesPluginHooks.AetherChatScroll = {
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
scrollToBottom() {
this.el.scrollTop = this.el.scrollHeight;
},
};
-15
View File
@@ -1,15 +0,0 @@
// Plugin JavaScript entry point.
//
// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js
// and included in the page layout if declared in manifest.json assets.global_js.
//
// To register LiveView hooks:
//
// window.TribesPluginHooks = window.TribesPluginHooks || {};
// window.TribesPluginHooks["MyPluginHook"] = {
// mounted() {
// console.log("MyPlugin hook mounted");
// }
// };
console.log("my_plugin loaded");
+475
View File
@@ -0,0 +1,475 @@
{
"name": "aether-assets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aether-assets",
"version": "0.1.0",
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
}
},
"node_modules/@hpke/common": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz",
"integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@hpke/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz",
"integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==",
"license": "MIT",
"dependencies": {
"@hpke/common": "^1.10.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@internet-privacy/marmot-ts": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@internet-privacy/marmot-ts/-/marmot-ts-0.5.1.tgz",
"integrity": "sha512-AZ9924Dz75CHbB+YJPmEGj09xYIYAHpyDCq1xn++ubQjP95sQuV4yNXX5CmPQNXcyEMd50LL5dhJOMuH8gmPsg==",
"license": "MIT",
"dependencies": {
"@hpke/core": "^1.9.0",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"debug": "^4.4.3",
"eventemitter3": "^5.0.4",
"ts-mls": "2.0.0-rc.10"
},
"engines": {
"bun": ">=1.1.0",
"deno": ">=2.0.0",
"node": ">=20.0.0"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-common": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/applesauce-common/-/applesauce-common-5.2.0.tgz",
"integrity": "sha512-6Natb0szkj65OBKK5LBHIszAmVd8ha9GkCZcavJnZbNeWBgoDTO0hfkgI4pk2L6L/OWNceo2XCajvDAx0AjlgQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.2.4",
"applesauce-core": "^5.2.0",
"hash-sum": "^2.0.0",
"light-bolt11-decoder": "^3.2.0",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-common/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
"integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"hash-sum": "^2.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/hash-sum": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"license": "MIT"
},
"node_modules/light-bolt11-decoder": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz",
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "1.1.1"
}
},
"node_modules/light-bolt11-decoder/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-tools/node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/ts-mls": {
"version": "2.0.0-rc.10",
"resolved": "https://registry.npmjs.org/ts-mls/-/ts-mls-2.0.0-rc.10.tgz",
"integrity": "sha512-4FFbkysQkJlVaUv4fs7ZC4wuiwTOCgX/bEMo2fFGngy/QofC/lvWWgsScKuGxEIhEf4e2Q8APfJ7DknP0VRCoA==",
"license": "MIT",
"dependencies": {
"@hpke/core": "1.9.0"
},
"peerDependencies": {
"@hpke/chacha20poly1305": "1.8.0",
"@hpke/dhkem-x448": "1.8.0",
"@hpke/hybridkem-x-wing": "0.7.0",
"@hpke/ml-kem": "0.3.0",
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/post-quantum": "0.5.2"
},
"peerDependenciesMeta": {
"@hpke/chacha20poly1305": {
"optional": true
},
"@hpke/dhkem-x448": {
"optional": true
},
"@hpke/hybridkem-x-wing": {
"optional": true
},
"@hpke/ml-kem": {
"optional": true
},
"@noble/curves": {
"optional": true
},
"@noble/post-quantum": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "aether-assets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
},
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
}
}
+5
View File
@@ -0,0 +1,5 @@
import Config
config :tribe_one_aether, ash_domains: [TribeOne.TribesPlugin.Aether.Chat]
import_config "#{config_env()}.exs"
+5
View File
@@ -0,0 +1,5 @@
import Config
# The host is a compile-only dependency in dev. Suppress host Ash domain
# config inclusion warnings while compiling host modules as a dependency.
config :ash, :validate_domain_config_inclusion?, false
+1
View File
@@ -0,0 +1 @@
import Config
+3
View File
@@ -0,0 +1,3 @@
import Config
import_config "../../tribes/config/config.exs"
+142
View File
@@ -0,0 +1,142 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779566536,
"narHash": "sha256-KlMZMcgyU0BFUW2JCrCb9IHJMgCdG0eyhukpu9pwXLs=",
"owner": "cachix",
"repo": "devenv",
"rev": "6715fab7a826d108278fbfd6ef35a61f49ab7ac9",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable"
}
}
},
"root": "root",
"version": 7
}
+110
View File
@@ -0,0 +1,110 @@
{
pkgs,
lib,
inputs,
...
}: let
system = pkgs.stdenv.system;
pkgs-unstable = inputs.nixpkgs-unstable.legacyPackages.${system};
hasGlibcLocales = pkgs.stdenv.hostPlatform.isLinux && pkgs.stdenv.hostPlatform.isGnu;
devLocales =
if hasGlibcLocales
then
pkgs.glibcLocales.override {
allLocales = false;
locales = [
"en_GB.UTF-8/UTF-8"
"en_US.UTF-8/UTF-8"
];
}
else null;
in {
# https://devenv.sh/basics/
env =
{
LANG = "en_GB.UTF-8";
# Parallel deps compilation
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
# Enable JS/LV debugging (required?)
NODE_ENV = "development";
# Delay npm dependency resolution to reduce rushed supply-chain updates.
NPM_CONFIG_MIN_RELEASE_AGE = "7";
}
// lib.optionalAttrs hasGlibcLocales {
LOCALE_ARCHIVE = "${devLocales}/lib/locale/locale-archive";
};
# https://devenv.sh/packages/
packages = with pkgs;
[
git
# linter and formatter for JavaScript, TypeScript, JSX, CSS and GraphQL
prettier
# Nix code formatter
alejandra
# Nostr tooling
pkgs-unstable.nak
pkgs-unstable.algia
(pkgs-unstable.callPackage ./nix/nosdump.nix {})
]
++
# Linux only
lib.optionals pkgs.stdenv.isLinux [
# for ExUnit notifier
libnotify
# for package - file_system
inotify-tools
];
# https://devenv.sh/tests/
# enterTest = ''
# echo "Running tests"
# git --version | grep "2.42.0"
# '';
# https://devenv.sh/languages/
languages = {
elixir = {
enable = true;
package = pkgs.elixir_1_19;
};
javascript = {
enable = true;
package = pkgs.nodejs_24;
npm.enable = true;
};
};
# dotenv.enable = true;
devenv.warnOnNewVersion = false;
# https://devenv.sh/pre-commit-hooks/
git-hooks.hooks = {
alejandra.enable = true;
prettier.enable = true;
prettier.files = "\\.(js|ts|tsx|css)$";
check-added-large-files = {
enable = true;
args = ["--maxkb=131072"];
};
mix-format.enable = true;
mix-format.files = "\\.(ex|exs|heex)$";
};
# https://devenv.sh/scripts/
enterShell = ''
echo
elixir --version
echo -n "Node.js "
node --version
echo
'';
scripts = {
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
};
# See full reference at https://devenv.sh/reference/options/
}
+22
View File
@@ -0,0 +1,22 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs-unstable:
url: github:NixOS/nixpkgs/nixos-unstable
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend
+144
View File
@@ -0,0 +1,144 @@
# Feature Plan: Aether Chat
## Decision: manifest capability
Use `chat@1`, not `group_chat@1`.
Rationale: the first shipped feature is group chat, but the capability should be able to grow to DMs, stream-adjacent chats, support rooms, or other conversation types later. The `chat@1` contract should therefore mean "conversation provider and reusable chat UI". The provider can advertise supported conversation kinds; initially Aether supports public group-style channels and later Marmot-backed channels.
Backward-compatible additions, such as optional DM support, can stay under `chat@1` as long as existing consumers keep working. Breaking provider API changes should become `chat@2`.
## Goals
- Add a standalone Aether chat experience.
- Provide an embeddable chat panel that Sender can mount next to a live video stream.
- Support two backends:
- `public_sync`: plaintext messages stored in plugin-owned synced Ash resources.
- `marmot`: Marmot/Nostr-backed encrypted transport using `@internet-privacy/marmot-ts@0.5.1`, without claiming true E2EE while the current web UI/server trust model is in place.
- Build a high-quality Telegram-like UI that can support media later.
## Non-goals for the first slice
- Full true E2EE claims.
- Media upload/storage.
- DMs.
- Sender integration before Aether exposes a stable provider surface.
## Capability contract sketch
Aether should provide:
```json
"provides": ["social@1", "chat@1"]
```
Initial `chat@1` provider responsibilities:
- ensure or find a channel for a context, e.g. `{provider: "sender", type: "stream", id: stream_id}`
- expose supported conversation kinds/backends
- render standalone chat routes
- provide an embeddable compact panel for consumers
Initial supported kinds:
- `:group`
- `:context_group`, for stream/sidebar-style group channels
Future optional kinds:
- `:dm`
- `:multi_dm`
- `:support_room`
## Data model: public_sync backend
Plugin-owned Ash domain/resources:
- `TribeOne.TribesPlugin.Aether.Chat.Channel`
- `id`
- `slug`
- `title`
- `description`
- `backend`: `:public_sync | :marmot`
- `conversation_kind`: initially `:group | :context_group`
- context fields: `context_provider`, `context_type`, `context_id`
- `metadata`
- timestamps
- `TribeOne.TribesPlugin.Aether.Chat.Message`
- `id`
- `channel_id`
- `author_id`
- `author_pubkey`
- `body`
- `client_message_id`
- `metadata`
- timestamps
Both public resources should be added deliberately to Aether's `ash_domains` and `AshNostrSync` so public chat replicates across cluster nodes.
## UI plan
One reusable chat surface with modes:
- standalone page mode
- compact embedded mode for Sender
MVP UI:
- message stream with stable DOM IDs
- sticky composer
- multiline textarea
- send button
- sender display label
- timestamp display
- empty state
- signed-out state
- compact mode support
- auto-scroll hook later
Media-ready UI affordances can be added later without implementing uploads now.
## Marmot plan
Use `@internet-privacy/marmot-ts@0.5.1` if/when implementing the Marmot backend.
Marmot backend constraints:
- document as encrypted/Marmot-backed, not true E2EE yet
- keep behind a feature flag or explicit backend choice until stable
- use browser local storage for Marmot state if using `marmot-ts`
- integrate with Parrhesia relay interfaces
- decide signing bridge/key handling before productizing
## Phases
1. Add plan, capability, public Ash resources, migrations, and standalone public chat MVP.
2. Extract/shape embeddable panel API and tests.
3. Integrate Sender stream pages with Aether chat provider.
4. Add Marmot spike using `marmot-ts@0.5.1`.
5. Productize Marmot backend behind explicit configuration.
6. Add media support later.
## Validation
For Aether work:
```sh
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
```
For host/plugin API changes:
```sh
cd ../tribes
devenv shell -- mix test
```
For Sender integration:
```sh
cd ../tribes-plugin-sender
scripts/plugin test
```
+24
View File
@@ -0,0 +1,24 @@
(list
(channel
(name 'guix)
(url "https://git.teralink.net/tribes/guix-fork.git")
(branch "refactor/substituter-trace-framing")
;; guix-fork refactor/substituter-trace-framing
(commit
"83b0e7d44546968002fb0c0043004da4e9bedc0d")
(introduction
(make-channel-introduction
"093f27dde01cdbda68f2ec4b81e5a34ae180aab9"
(openpgp-fingerprint
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3"))))
(channel
(name 'tribes)
(url "https://git.teralink.net/tribes/guix-tribes.git")
(branch "master")
(commit
"99789706834d678fb37f2c6a972b78803d2a2cf2")
(introduction
(make-channel-introduction
"607c69a5c1662acca07ad72c3e18646c73500856"
(openpgp-fingerprint
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3")))))
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
if [ ! -f "$root/manifest.scm" ]; then
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
channels="$root/guix/channels.scm"
manifest="$root/manifest.scm"
if [ ! -f "$channels" ] || [ ! -f "$manifest" ]; then
echo "guix-dev: expected guix/channels.scm and manifest.scm in the project root" >&2
exit 1
fi
if [ -f "$root/.env" ]; then
set -a
. "$root/.env"
set +a
fi
exec guix time-machine -C "$channels" -- \
shell -m "$manifest" "$@"
@@ -1,4 +1,4 @@
defmodule MyPlugin.Application do
defmodule TribeOne.TribesPlugin.Aether.Application do
@moduledoc """
OTP Application for this plugin.
@@ -12,10 +12,10 @@ defmodule MyPlugin.Application do
def start(_type, _args) do
children = [
# Add your supervised processes here, e.g.:
# {MyPlugin.Worker, []}
# {TribeOne.TribesPlugin.Aether.Worker, []}
]
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Aether.Supervisor]
Supervisor.start_link(children, opts)
end
end
+352
View File
@@ -0,0 +1,352 @@
defmodule TribeOne.TribesPlugin.Aether.Chat do
@moduledoc """
Chat domain and provider facade for TribeOne.TribesPlugin.Aether.
"""
use Ash.Domain,
otp_app: :tribe_one_aether
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
@default_channel_slug "general"
@default_channel_title "General Chat"
@pubsub Tribes.PubSub
resources do
resource Channel do
define(:create_channel, action: :create)
define(:get_channel, action: :by_id, args: [:id])
define(:get_channel_by_slug, action: :by_slug, args: [:slug])
define(:list_channels, action: :read)
end
resource Message do
define(:create_message, action: :create)
define(:get_message, action: :by_id, args: [:id])
define(:list_all_messages, action: :read)
end
resource Participant do
define(:create_participant, action: :create)
define(:get_participant, action: :by_channel_pubkey, args: [:channel_id, :pubkey])
define(:list_participants, action: :read)
define(:list_participants_by_pubkey, action: :by_pubkey, args: [:pubkey])
end
end
def backend_module(:public_sync), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_module(:nostr_nip17), do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17
def backend_module(:nostr_nip04),
do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly
def backend_module(:marmot), do: TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot
def backend_module(_backend), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_capabilities(backend) do
module = backend_module(backend)
module.capabilities()
end
def supported_conversation_kinds, do: [:group, :context_group, :dm, :legacy_dm, :marmot_group]
def supported_backends, do: [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
def chat_panel_component, do: TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent
def recipient_picker_component, do: TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent
def default_channel_slug, do: @default_channel_slug
def standalone_path(%Channel{slug: slug}), do: standalone_path(slug)
def standalone_path(slug) when is_binary(slug), do: "/aether/chat/" <> slug
def embed_path(%Channel{slug: slug}), do: embed_path(slug)
def embed_path(slug) when is_binary(slug), do: "/aether/chat/embed/" <> slug
def marmot_path(%Channel{slug: slug}), do: marmot_path(slug)
def marmot_path(slug) when is_binary(slug), do: "/aether/chat/marmot/" <> slug
def ensure_context_channel(provider, type, id, attrs \\ %{}, opts \\ [])
when is_binary(provider) and is_binary(type) and is_binary(id) and is_map(attrs) do
attrs
|> Map.merge(%{
context_provider: provider,
context_type: type,
context_id: id
})
|> ensure_conversation(opts)
end
def ensure_direct_conversation(sender, recipient, attrs \\ %{}, opts \\ [])
def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts)
when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do
participants = Enum.sort([sender_pubkey, recipient_pubkey])
attrs =
attrs
|> Map.update(:metadata, %{"participants" => participants}, fn metadata ->
Map.put(metadata || %{}, "participants", participants)
end)
|> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey))
|> Map.put_new(:title, "Direct Message")
|> Map.put_new(:conversation_kind, :dm)
|> Map.put_new(:backend, :nostr_nip17)
with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts),
{:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts),
{:ok, _recipient} <-
ensure_participant(channel, recipient_pubkey, %{role: :member}, opts) do
{:ok, channel}
end
end
def direct_slug(pubkey_a, pubkey_b) when is_binary(pubkey_a) and is_binary(pubkey_b) do
digest =
[pubkey_a, pubkey_b]
|> Enum.sort()
|> Enum.join(":")
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
"dm-" <> digest
end
def ash_opts(context \\ nil) do
[
authorize?: false,
context: %{
private: %{
system?: true,
system_purpose: :aether_chat,
plugin_management_context: context
}
}
]
end
def ensure_conversation(attrs \\ %{}, opts \\ []) when is_map(attrs) do
backend = attrs |> atomize_known_keys() |> Map.get(:backend, :public_sync)
backend_module(backend).ensure_conversation(attrs, opts)
end
def ensure_channel(attrs \\ %{}, opts \\ []) when is_map(attrs) do
attrs = normalize_channel_attrs(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_channel(attrs.slug, ash_opts) do
{:ok, nil} -> create_channel(attrs, ash_opts)
{:ok, %Channel{} = channel} -> {:ok, channel}
{:error, _reason} = error -> error
end
end
defp get_existing_channel(slug, ash_opts) do
Channel
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
defp get_existing_participant(channel_id, pubkey, ash_opts) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id and pubkey == ^pubkey)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
def ensure_participant(%Channel{} = channel, pubkey, attrs \\ %{}, opts \\ [])
when is_binary(pubkey) and is_map(attrs) do
attrs = atomize_known_keys(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_participant(channel.id, pubkey, ash_opts) do
{:ok, nil} ->
attrs
|> Map.merge(%{channel_id: channel.id, pubkey: pubkey})
|> Map.put_new(:role, :member)
|> Map.put_new(:metadata, %{})
|> create_participant(ash_opts)
{:ok, %Participant{} = participant} ->
{:ok, participant}
{:error, _reason} = error ->
error
end
end
def list_conversation_messages(%Channel{backend: backend} = channel, opts \\ []) do
backend_module(backend).list_messages(channel, opts)
end
def list_messages(channel_or_id, opts \\ []) do
channel_id = channel_id(channel_or_id)
limit = Keyword.get(opts, :limit, 100)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
result =
Message
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.Query.sort(inserted_at: :desc, id: :desc)
|> Ash.Query.limit(limit)
|> Ash.read(ash_opts)
case result do
{:ok, messages} -> {:ok, Enum.reverse(messages)}
{:error, _reason} = error -> error
end
end
def send_message(%Channel{backend: backend} = channel, attrs, opts \\ []) when is_map(attrs) do
backend_module(backend).send_message(channel, attrs, opts)
end
def post_message(%Channel{} = channel, attrs, opts \\ []) when is_map(attrs) do
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
attrs
|> normalize_message_attrs(channel)
|> create_message(ash_opts)
|> maybe_broadcast_message()
end
def subscribe_conversation(%Channel{backend: backend} = channel, pid \\ self(), opts \\ []) do
backend_module(backend).subscribe(channel, pid, opts)
end
def subscribe_channel(%Channel{} = channel), do: subscribe_channel(channel.id)
def subscribe_channel(channel_id) when is_binary(channel_id) do
if pubsub_started?() do
Phoenix.PubSub.subscribe(@pubsub, topic(channel_id))
else
{:error, :pubsub_not_started}
end
end
def topic(channel_id) when is_binary(channel_id), do: "aether:chat:" <> channel_id
defp maybe_broadcast_message({:ok, %Message{} = message}) do
broadcast_message(message)
{:ok, message}
end
defp maybe_broadcast_message({:error, _reason} = error), do: error
defp broadcast_message(%Message{} = message) do
if pubsub_started?() do
Phoenix.PubSub.broadcast_from(
@pubsub,
self(),
topic(message.channel_id),
{:aether_chat, :message, message}
)
end
end
defp pubsub_started?, do: Process.whereis(@pubsub) != nil
defp normalize_channel_attrs(attrs) do
attrs = atomize_known_keys(attrs)
title = attrs[:title] || @default_channel_title
slug = attrs[:slug] || context_slug(attrs) || slugify(title) || @default_channel_slug
%{
slug: slug,
title: title,
description: attrs[:description],
backend: attrs[:backend] || :public_sync,
conversation_kind: attrs[:conversation_kind] || default_conversation_kind(attrs),
context_provider: attrs[:context_provider],
context_type: attrs[:context_type],
context_id: attrs[:context_id],
metadata: attrs[:metadata] || %{}
}
end
defp normalize_message_attrs(attrs, %Channel{} = channel) do
attrs = atomize_known_keys(attrs)
%{
channel_id: channel.id,
author_id: attrs[:author_id],
author_pubkey: attrs[:author_pubkey],
body: String.trim(attrs[:body] || ""),
client_message_id: attrs[:client_message_id],
metadata: attrs[:metadata] || %{}
}
end
defp atomize_known_keys(attrs) do
Enum.reduce(attrs, %{}, fn
{key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
{key, value}, acc when is_binary(key) -> put_known_key(acc, key, value)
end)
end
defp put_known_key(acc, key, value) do
case key do
"slug" -> Map.put(acc, :slug, value)
"title" -> Map.put(acc, :title, value)
"description" -> Map.put(acc, :description, value)
"backend" -> Map.put(acc, :backend, parse_atom(value))
"conversation_kind" -> Map.put(acc, :conversation_kind, parse_atom(value))
"context_provider" -> Map.put(acc, :context_provider, value)
"context_type" -> Map.put(acc, :context_type, value)
"context_id" -> Map.put(acc, :context_id, value)
"metadata" -> Map.put(acc, :metadata, value)
"pubkey" -> Map.put(acc, :pubkey, value)
"user_id" -> Map.put(acc, :user_id, value)
"display_name" -> Map.put(acc, :display_name, value)
"role" -> Map.put(acc, :role, parse_atom(value))
"author_id" -> Map.put(acc, :author_id, value)
"author_pubkey" -> Map.put(acc, :author_pubkey, value)
"body" -> Map.put(acc, :body, value)
"client_message_id" -> Map.put(acc, :client_message_id, value)
_other -> acc
end
end
defp parse_atom(value) when is_atom(value), do: value
defp parse_atom("public_sync"), do: :public_sync
defp parse_atom("nostr_nip17"), do: :nostr_nip17
defp parse_atom("nostr_nip04"), do: :nostr_nip04
defp parse_atom("marmot"), do: :marmot
defp parse_atom("group"), do: :group
defp parse_atom("context_group"), do: :context_group
defp parse_atom("dm"), do: :dm
defp parse_atom("legacy_dm"), do: :legacy_dm
defp parse_atom("marmot_group"), do: :marmot_group
defp parse_atom("owner"), do: :owner
defp parse_atom("member"), do: :member
defp parse_atom(_value), do: nil
defp context_slug(%{context_provider: provider, context_type: type, context_id: id})
when is_binary(provider) and is_binary(type) and is_binary(id) do
Enum.map_join([provider, type, id], "-", &slugify/1)
end
defp context_slug(_attrs), do: nil
defp default_conversation_kind(%{context_provider: provider}) when is_binary(provider),
do: :context_group
defp default_conversation_kind(_attrs), do: :group
defp channel_id(%Channel{id: id}), do: id
defp channel_id(id) when is_binary(id), do: id
defp slugify(value) when is_binary(value) do
slug =
value
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
if slug == "", do: nil, else: slug
end
end
+25
View File
@@ -0,0 +1,25 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backend do
@moduledoc """
Behaviour for chat storage/transport backends.
Backends are plain Elixir modules. Ash resources store conversation projections,
participants, and local message state, while protocol backends may use Parrhesia
raw events as canonical storage.
`opts` may carry signing/client context for future non-custodial clients, e.g.
browser-held Nostr signers, NIP-07 bridges, or server-session signers. Backends
that need signatures should reject missing signer context explicitly instead of
assuming server-side custody.
"""
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@type attrs :: map()
@type opts :: keyword()
@callback capabilities() :: map()
@callback ensure_conversation(attrs(), opts()) :: {:ok, Channel.t()} | {:error, term()}
@callback list_messages(Channel.t(), opts()) :: {:ok, [struct()]} | {:error, term()}
@callback send_message(Channel.t(), attrs(), opts()) :: {:ok, struct()} | {:error, term()}
@callback subscribe(Channel.t(), pid(), opts()) :: :ok | {:error, term()}
end
+41
View File
@@ -0,0 +1,41 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot do
@moduledoc """
Marmot/MLS group backend scaffold.
Marmot events should be stored canonically as Parrhesia/Nostr events while the
browser/client maintains MLS state. The current web UI does not claim true E2EE.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:marmot_group],
read_only?: false,
required_protocols: [:marmot, :mls]
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :marmot)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{}, _opts), do: {:ok, []}
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
@impl true
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
end
@@ -0,0 +1,169 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly do
@moduledoc """
Legacy NIP-04 DM read-only fallback.
This backend queries canonical Parrhesia kind-4 events and decrypts messages for
local display when the viewer has an unlocked session private key. Outbound
messages intentionally remain disabled; new DMs should use NIP-17.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
@kind_nip04 4
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:legacy_dm],
read_only?: true,
required_protocols: [:nip04],
signer_modes: [:session_privkey, :future_external_signer]
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip04)
|> Map.put_new(:conversation_kind, :legacy_dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_legacy_dms(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&decrypt_message(&1, privkey, channel, participant_pubkeys))
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend}
@impl true
def subscribe(%Channel{}, _pid, _opts), do: :ok
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp query_legacy_dms(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_nip04],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp decrypt_message(event, privkey, channel, participant_pubkeys) do
with %{"kind" => @kind_nip04, "pubkey" => sender_pubkey, "content" => ciphertext} <- event,
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
true <- Enum.any?(p_tags(event), &MapSet.member?(participant_pubkeys, &1)),
{:ok, body} <- Nak.nip04_decrypt(privkey, sender_pubkey, ciphertext) do
[
%Message{
id: event["id"],
channel_id: channel.id,
author_pubkey: sender_pubkey,
body: body,
client_message_id: event["id"],
metadata: %{"nostr_kind" => @kind_nip04, "nostr_event_id" => event["id"]},
inserted_at: unix_to_datetime(event["created_at"])
}
]
else
_other -> []
end
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+294
View File
@@ -0,0 +1,294 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17 do
@moduledoc """
NIP-17 private DM backend.
Gift-wrapped Nostr events in Parrhesia are the canonical message store. The
Ash chat channel/participant records are local UI projections used to find the
conversation participants; decrypted message structs returned to the UI are not
persisted through the Ash synced message resource.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
alias Parrhesia.API.Stream
@kind_giftwrap 1059
@kind_dm 14
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:dm],
read_only?: false,
required_protocols: [:nip17, :nip44, :nip59],
signer_modes: [:session_privkey, :future_external_signer],
plaintext_projection?: false
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip17)
|> Map.put_new(:conversation_kind, :dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_giftwraps(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&unwrap_message(&1, privkey, channel, participant_pubkeys))
|> dedupe_messages()
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
body = attrs |> Map.get(:body) || Map.get(attrs, "body") || ""
body = String.trim(body)
with false <- body == "",
{:ok, privkey} <- fetch_privkey(opts),
{:ok, sender_pubkey} <- Nak.pubkey_from_private_key(privkey),
:ok <- validate_author(attrs, sender_pubkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
{:ok, recipient_pubkey} <- recipient_pubkey(participant_pubkeys, sender_pubkey),
{:ok, rumor} <- Nak.nip17_rumor(privkey, recipient_pubkey, body),
{:ok, giftwraps} <- publish_giftwraps(privkey, rumor, [recipient_pubkey, sender_pubkey]) do
{:ok, message_from_rumor(rumor, channel, attrs, giftwraps)}
else
true -> {:error, :empty_message}
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def subscribe(%Channel{} = channel, pid, opts) when is_pid(pid) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey) do
filters = [%{"kinds" => [@kind_giftwrap], "#p" => [local_pubkey]}]
Stream.subscribe(pid, "aether-chat-#{channel.id}", filters,
context: request_context([local_pubkey])
)
else
{:error, :missing_session_privkey} -> :ok
{:error, _reason} = error -> error
end
end
def unwrap_event(%Channel{} = channel, giftwrap, opts) when is_map(giftwrap) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, participant_pubkeys} <- participant_pubkeys(channel) do
case unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
[message] -> {:ok, message}
[] -> {:error, :not_conversation_message}
end
end
end
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp validate_author(attrs, sender_pubkey) do
case Map.get(attrs, :author_pubkey) || Map.get(attrs, "author_pubkey") do
nil -> :ok
^sender_pubkey -> :ok
_other -> {:error, :author_pubkey_mismatch}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp recipient_pubkey(participant_pubkeys, sender_pubkey) do
case participant_pubkeys |> MapSet.delete(sender_pubkey) |> MapSet.to_list() do
[recipient_pubkey] -> {:ok, recipient_pubkey}
[] -> {:error, :missing_recipient}
_many -> {:error, :unsupported_multi_recipient_dm}
end
end
defp publish_giftwraps(privkey, rumor, delivery_pubkeys) do
delivery_pubkeys
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn delivery_pubkey, {:ok, acc} ->
with {:ok, giftwrap} <- Nak.nip17_wrap(privkey, delivery_pubkey, rumor),
:ok <- publish_event(giftwrap) do
{:cont, {:ok, [giftwrap | acc]}}
else
{:error, _reason} = error -> {:halt, error}
end
end)
|> case do
{:ok, giftwraps} -> {:ok, Enum.reverse(giftwraps)}
{:error, _reason} = error -> error
end
end
defp publish_event(event) do
with {:ok, result} <- Events.publish(event, context: request_context([event["pubkey"]])),
true <- result.accepted do
:ok
else
false -> {:error, :publish_rejected}
{:ok, %{reason: reason}} when not is_nil(reason) -> {:error, reason}
{:error, _reason} = error -> error
other -> {:error, other}
end
end
defp query_giftwraps(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_giftwrap],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
with {:ok, rumor} <- Nak.nip17_unwrap(privkey, giftwrap),
true <- conversation_rumor?(rumor, participant_pubkeys) do
[message_from_rumor(rumor, channel, %{}, [giftwrap])]
else
_other -> []
end
end
defp conversation_rumor?(%{"kind" => @kind_dm, "pubkey" => sender_pubkey} = rumor, pubkeys) do
recipient_pubkeys = p_tags(rumor)
MapSet.member?(pubkeys, sender_pubkey) and
Enum.any?(recipient_pubkeys, &MapSet.member?(pubkeys, &1))
end
defp conversation_rumor?(_rumor, _pubkeys), do: false
defp message_from_rumor(rumor, channel, attrs, giftwraps) do
%Message{
id: rumor["id"] || (List.first(giftwraps || []) && List.first(giftwraps)["id"]),
channel_id: channel.id,
author_id: Map.get(attrs, :author_id) || Map.get(attrs, "author_id"),
author_pubkey: rumor["pubkey"],
body: rumor["content"] || "",
client_message_id: rumor["id"],
metadata: message_metadata(rumor, attrs, giftwraps),
inserted_at: unix_to_datetime(rumor["created_at"])
}
end
defp message_metadata(rumor, attrs, giftwraps) do
attrs_metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{}
attrs_metadata
|> Map.put_new("author_name", get_in(attrs_metadata, ["author_name"]))
|> Map.put("nostr_kind", @kind_dm)
|> Map.put("nostr_rumor_id", rumor["id"])
|> Map.put("nostr_giftwrap_ids", Enum.map(giftwraps || [], & &1["id"]))
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp dedupe_messages(messages) do
messages
|> Enum.reduce({MapSet.new(), []}, fn message, {seen, acc} ->
key = message.client_message_id || message.id
if MapSet.member?(seen, key) do
{seen, acc}
else
{MapSet.put(seen, key), [message | acc]}
end
end)
|> elem(1)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+35
View File
@@ -0,0 +1,35 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync do
@moduledoc """
Plaintext Ash-backed chat backend for public and local DM-style conversations.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@impl true
def capabilities do
%{
canonical_store: :ash,
nostr_compatible?: false,
non_custodial_signing?: false,
conversation_kinds: [:group, :context_group, :dm],
read_only?: false
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs), do: Chat.ensure_channel(attrs, opts)
@impl true
def list_messages(%Channel{} = channel, opts), do: Chat.list_messages(channel, opts)
@impl true
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
Chat.post_message(channel, attrs, opts)
end
@impl true
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
end
+180
View File
@@ -0,0 +1,180 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Channel do
@moduledoc """
Public chat channel metadata.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
@backends [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
@conversation_kinds [:group, :context_group, :dm, :legacy_dm, :marmot_group]
postgres do
table("aether_chat_channels")
repo(Tribes.Repo)
custom_indexes do
index([:slug], unique: true, where: "deleted_at IS NULL")
index([:context_provider, :context_type, :context_id], where: "deleted_at IS NULL")
end
end
nostr_sync do
namespace("plugins.aether.chat.channel")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_id do
get?(true)
get_by(:id)
end
read :by_slug do
get?(true)
argument :slug, :string do
allow_nil?(false)
end
filter(expr(slug == ^arg(:slug)))
end
create :create do
accept([
:id,
:slug,
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([
:id,
:slug,
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
has_many :messages, TribeOne.TribesPlugin.Aether.Chat.Message do
destination_attribute(:channel_id)
public?(true)
end
has_many :participants, TribeOne.TribesPlugin.Aether.Chat.Participant do
destination_attribute(:channel_id)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :slug, :string do
allow_nil?(false)
public?(true)
end
attribute :title, :string do
allow_nil?(false)
public?(true)
end
attribute :description, :string do
public?(true)
end
attribute :backend, :atom do
constraints(one_of: @backends)
allow_nil?(false)
default(:public_sync)
public?(true)
end
attribute :conversation_kind, :atom do
constraints(one_of: @conversation_kinds)
allow_nil?(false)
default(:group)
public?(true)
end
attribute :context_provider, :string do
public?(true)
end
attribute :context_type, :string do
public?(true)
end
attribute :context_id, :string do
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+139
View File
@@ -0,0 +1,139 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Message do
@moduledoc """
Public synced chat message.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
postgres do
table("aether_chat_messages")
repo(Tribes.Repo)
custom_indexes do
index([:channel_id, :inserted_at])
index([:author_pubkey])
index([:client_message_id],
unique: true,
where: "client_message_id IS NOT NULL AND deleted_at IS NULL"
)
end
end
nostr_sync do
namespace("plugins.aether.chat.message")
lane(:bulk)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_id do
get?(true)
get_by(:id)
end
read :by_channel do
argument :channel_id, :uuid do
allow_nil?(false)
end
prepare(
build(filter: expr(channel_id == ^arg(:channel_id)), sort: [inserted_at: :asc, id: :asc])
)
end
create :create do
accept([
:id,
:channel_id,
:author_id,
:author_pubkey,
:body,
:client_message_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([
:id,
:channel_id,
:author_id,
:author_pubkey,
:body,
:client_message_id,
:metadata
])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([:body, :metadata])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
allow_nil?(false)
attribute_type(:uuid)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :author_id, :uuid do
public?(true)
end
attribute :author_pubkey, :string do
public?(true)
end
attribute :body, :string do
allow_nil?(false)
public?(true)
constraints(allow_empty?: false, trim?: true)
end
attribute :client_message_id, :string do
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+157
View File
@@ -0,0 +1,157 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak do
@moduledoc false
@timeout_ms 30_000
def available?, do: System.find_executable("nak") != nil
def pubkey_from_private_key(privkey) do
with {:ok, privkey_hex} <- normalize_private_key(privkey) do
run_script(
~S'''
set -euo pipefail
nak key public "$1"
''',
[privkey_hex]
)
|> trim_result()
end
end
def nip17_rumor(sender_privkey, recipient_pubkey, body)
when is_binary(recipient_pubkey) and is_binary(body) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, body]
)
|> decode_json_result()
end
end
def nip17_wrap(sender_privkey, recipient_pubkey, rumor) when is_map(rumor) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey),
{:ok, rumor_json} <- encode_json(rumor) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$3" | nak gift wrap --sec "$1" -p "$2" 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, rumor_json]
)
|> decode_json_result()
end
end
def nip17_unwrap(recipient_privkey, giftwrap) when is_map(giftwrap) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
{:ok, giftwrap_json} <- encode_json(giftwrap) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
''',
[recipient_privkey_hex, giftwrap_json]
)
|> decode_json_result()
end
end
def nip04_encrypt(sender_privkey, recipient_pubkey, plaintext)
when is_binary(recipient_pubkey) and is_binary(plaintext) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak encrypt --nip04 --sec "$1" -p "$2" "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, plaintext]
)
|> trim_result()
end
end
def nip04_decrypt(recipient_privkey, sender_pubkey, ciphertext)
when is_binary(sender_pubkey) and is_binary(ciphertext) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
:ok <- validate_pubkey(sender_pubkey) do
run_script(
~S'''
set -euo pipefail
nak decrypt --nip04 --sec "$1" -p "$2" "$3" 2>/dev/null
''',
[recipient_privkey_hex, sender_pubkey, ciphertext]
)
|> trim_result()
end
end
def normalize_private_key(<<_::binary-size(32)>> = privkey),
do: {:ok, Base.encode16(privkey, case: :lower)}
def normalize_private_key(privkey) when is_binary(privkey) do
if privkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
{:ok, String.downcase(privkey)}
else
{:error, :invalid_private_key}
end
end
def normalize_private_key(_privkey), do: {:error, :invalid_private_key}
defp validate_pubkey(pubkey) when is_binary(pubkey) do
if pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
:ok
else
{:error, :invalid_pubkey}
end
end
defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey}
defp run_script(script, args) do
if available?() do
do_run_script(script, args)
else
{:error, :nak_not_found}
end
end
defp do_run_script(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> {:ok, output}
{:ok, {output, status}} -> {:error, {:nak_failed, status, output}}
nil -> {:error, :nak_timeout}
end
end
defp trim_result({:ok, output}), do: {:ok, String.trim(output)}
defp trim_result({:error, _reason} = error), do: error
defp decode_json_result({:ok, output}) do
output
|> String.trim()
|> JSON.decode()
rescue
_error -> {:error, {:invalid_json, output}}
end
defp decode_json_result({:error, _reason} = error), do: error
defp encode_json(value) do
{:ok, JSON.encode!(value)}
rescue
_error -> {:error, :invalid_json}
end
end
+127
View File
@@ -0,0 +1,127 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Participant do
@moduledoc """
Participant projection for Aether conversations.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
@roles [:owner, :member]
postgres do
table("aether_chat_participants")
repo(Tribes.Repo)
custom_indexes do
index([:channel_id, :pubkey], unique: true, where: "deleted_at IS NULL")
index([:pubkey], where: "deleted_at IS NULL")
end
end
nostr_sync do
namespace("plugins.aether.chat.participant")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_channel_pubkey do
get?(true)
argument :channel_id, :uuid do
allow_nil?(false)
end
argument :pubkey, :string do
allow_nil?(false)
end
filter(expr(channel_id == ^arg(:channel_id) and pubkey == ^arg(:pubkey)))
end
read :by_pubkey do
argument :pubkey, :string do
allow_nil?(false)
end
prepare(build(filter: expr(pubkey == ^arg(:pubkey)), sort: [inserted_at: :desc]))
end
create :create do
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([:user_id, :display_name, :role, :metadata])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
allow_nil?(false)
attribute_type(:uuid)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :pubkey, :string do
allow_nil?(false)
public?(true)
end
attribute :user_id, :uuid do
public?(true)
end
attribute :display_name, :string do
public?(true)
end
attribute :role, :atom do
constraints(one_of: @roles)
allow_nil?(false)
default(:member)
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+111
View File
@@ -0,0 +1,111 @@
defmodule TribeOne.TribesPlugin.Aether.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :tribe_one_aether
@behaviour Tribes.Capabilities.Chat.V1
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "Aether",
path: "/aether",
icon: nil,
requires: [],
order: 50
},
%{
label: "Chat",
path: "/aether/chat",
icon: nil,
requires: [],
order: 51
}
],
pages: [
%{
path: "/aether/chat",
live_view: TribeOne.TribesPlugin.AetherWeb.ChatLive,
layout: nil
},
%{
path: "/aether",
live_view: TribeOne.TribesPlugin.AetherWeb.TimelineLive,
layout: nil
}
],
ash_domains: [TribeOne.TribesPlugin.Aether.Chat],
config_schema: %{
title: "Aether",
description: "Social feed and chat defaults.",
groups: [
%{
id: "chat",
label: "Chat",
description: "Group chat backend defaults.",
order: 10,
settings: [
%{
key: "chat.default_backend",
label: "Default chat backend",
description:
"New standalone channels use public synced chat until Marmot is explicitly selected.",
type: :enum,
default: "public_sync",
options: [
%{label: "Public synced", value: "public_sync"},
%{label: "Marmot scaffold", value: "marmot"}
],
order: 10
},
%{
key: "chat.marmot_enabled",
label: "Enable Marmot chat UI",
description:
"Reserved for the Marmot browser transport once storage, relay, and signing adapters are complete.",
type: :boolean,
default: false,
order: 20
}
]
}
]
}
})
end
@impl Tribes.Capabilities.Chat.V1
def embedded_panel(slug, opts \\ []) when is_binary(slug) do
assigns = %{
slug: slug,
backend: Keyword.get(opts, :backend, :public_sync),
embedded?: true,
current_user: Keyword.get(opts, :current_user),
session_privkey: Keyword.get(opts, :session_privkey)
}
{:ok,
Tribes.Plugin.Surface.live_component(
TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent,
Keyword.get(opts, :id, "aether-chat-panel-#{slug}"),
assigns,
capability: "org.tribe-one.caps.chat@1",
provider: __MODULE__
)}
end
@impl Tribes.Capabilities.Chat.V1
def standalone_path(slug), do: TribeOne.TribesPlugin.Aether.Chat.standalone_path(slug)
@impl Tribes.Capabilities.Chat.V1
def handle_surface_info(_surface, {:aether_chat, :message, message}) do
{:ok, %{incoming_message: message}}
end
def handle_surface_info(_surface, _message), do: :ignore
end
+186
View File
@@ -0,0 +1,186 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatLive do
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.AetherWeb.{ChatPanelComponent, ChatRecipientPickerComponent}
alias Tribes.Plugin.Layouts
@component_id "aether-chat-panel-component"
@impl true
def mount(_params, session, socket) do
request = channel_request(session)
{:ok,
socket
|> assign(:page_title, channel_title(request.slug))
|> assign(:chat_request, request)
|> assign(:component_id, @component_id)}
end
@impl true
def handle_info({:aether_chat, :message, message}, socket) do
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
{:noreply, socket}
end
def handle_info({:aether_chat, :recipient_selected, recipient}, socket) do
current_user = socket.assigns.current_user
if current_user && is_binary(recipient.pubkey_hex) do
attrs = %{
title: recipient_label(recipient),
metadata: %{"recipient_username" => recipient.username}
}
case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs,
session_privkey: socket.assigns[:session_privkey]
) do
{:ok, channel} ->
{:noreply, push_navigate(socket, to: Chat.standalone_path(channel))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Could not open conversation: #{inspect(reason)}")}
end
else
{:noreply, put_flash(socket, :error, "Sign in to start a direct message")}
end
end
def handle_info({:parrhesia, :events, _ref, _subscription_id, events}, socket)
when is_list(events) do
Enum.each(events, &send_unwrapped_message(socket, &1))
{:noreply, socket}
end
def handle_info({:parrhesia, :event, _ref, _subscription_id, event}, socket) do
send_unwrapped_message(socket, event)
{:noreply, socket}
end
def handle_info({:parrhesia, :eose, _ref, _subscription_id, _hints}, socket),
do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _subscription_id}, socket), do: {:noreply, socket}
@impl true
def render(%{chat_request: %{mode: :embedded}} = assigns) do
~H"""
<div class="aether aether-chat h-full min-h-[28rem] p-2" id="aether-chat-embed">
<.live_component
module={ChatPanelComponent}
id={@component_id}
slug={@chat_request.slug}
backend={@chat_request.backend}
embedded?={true}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
"""
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="aether aether-chat flex min-h-[calc(100vh-8rem)] flex-col p-4 sm:p-6" id="aether-chat-page">
<section class="mb-4 rounded-3xl border border-base-300 bg-base-100/95 p-5 shadow-sm">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
Aether chat
</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight text-base-content">
{channel_title(@chat_request.slug)}
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-base-content/70">
Public synced rooms and NIP-17 direct messages share this chat surface.
</p>
</div>
<.live_component
:if={@current_user}
module={ChatRecipientPickerComponent}
id="aether-chat-recipient-picker"
current_user={@current_user}
/>
</div>
</section>
<.live_component
module={ChatPanelComponent}
id={@component_id}
slug={@chat_request.slug}
backend={@chat_request.backend}
embedded?={false}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
</Layouts.app>
"""
end
defp channel_request(%{"plugin_path" => path}) when is_binary(path) do
case String.split(path, "/", trim: true) do
["aether", "chat", "embed", slug | _rest] ->
request(:embedded, slug, :public_sync)
["plugins", "aether", "chat", "embed", slug | _rest] ->
request(:embedded, slug, :public_sync)
["aether", "chat", "marmot", slug | _rest] ->
request(:standalone, slug, :marmot)
["plugins", "aether", "chat", "marmot", slug | _rest] ->
request(:standalone, slug, :marmot)
["aether", "chat", slug | _rest] ->
request(:standalone, slug, :public_sync)
["plugins", "aether", "chat", slug | _rest] ->
request(:standalone, slug, :public_sync)
_other ->
request(:standalone, Chat.default_channel_slug(), :public_sync)
end
end
defp channel_request(_session),
do: request(:standalone, Chat.default_channel_slug(), :public_sync)
defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend}
defp send_unwrapped_message(socket, event) do
request = socket.assigns.chat_request
with {:ok, %TribeOne.TribesPlugin.Aether.Chat.Channel{} = channel} <-
Chat.get_channel_by_slug(request.slug, Chat.ash_opts()),
backend <- Chat.backend_module(channel.backend),
true <- function_exported?(backend, :unwrap_event, 3),
{:ok, message} <-
backend.unwrap_event(channel, event, session_privkey: socket.assigns[:session_privkey]) do
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
end
end
defp recipient_label(%{display_name: display_name})
when is_binary(display_name) and display_name != "" do
display_name
end
defp recipient_label(%{username: username}) when is_binary(username) and username != "",
do: username
defp recipient_label(%{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> ""
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
slug
|> String.replace(["-", "_"], " ")
|> String.split(" ", trim: true)
|> Enum.map_join(" ", &String.capitalize/1)
end
end
+294
View File
@@ -0,0 +1,294 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent do
@moduledoc """
Reusable chat panel component for standalone and embedded Aether chat surfaces.
"""
use Phoenix.LiveComponent
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message}
@impl true
def update(%{incoming_message: %Message{} = message}, socket) do
{:ok, put_message(socket, message)}
end
def update(assigns, socket) do
slug = Map.fetch!(assigns, :slug)
backend = Map.get(assigns, :backend, :public_sync)
socket =
socket
|> assign(assigns)
|> assign_new(:channel, fn -> nil end)
|> assign_new(:messages, fn -> [] end)
|> assign_new(:message_ids, fn -> MapSet.new() end)
|> assign_new(:message_count, fn -> 0 end)
|> assign_new(:subscribed_channel_id, fn -> nil end)
socket =
if reload_channel?(socket, slug, backend) do
load_channel(socket, slug, backend)
else
socket
end
{:ok, maybe_subscribe(socket)}
end
@impl true
def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
body = String.trim(body)
cond do
socket.assigns.channel == nil ->
{:noreply, put_flash(socket, :error, "Chat channel is unavailable")}
socket.assigns.current_user == nil ->
{:noreply, put_flash(socket, :error, "Sign in to chat")}
body == "" ->
{:noreply, put_flash(socket, :error, "Message cannot be empty")}
true ->
post_message(socket, body)
end
end
@impl true
def render(assigns) do
~H"""
<div
id="aether-chat-panel"
class={[
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
@embedded? && "h-full rounded-2xl",
!@embedded? && "rounded-3xl"
]}
>
<div
:if={@embedded?}
id="chat-embed-header"
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
</div>
<span class="text-xs text-base-content/60">{@message_count}</span>
</div>
<div
:if={@backend == :marmot}
id="chat-marmot-notice"
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
>
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
</div>
<div
id="chat-messages"
phx-hook="AetherChatScroll"
class={[
"flex-1 space-y-3 overflow-y-auto p-4",
!@embedded? && "min-h-[24rem] sm:p-6",
@embedded? && "min-h-0"
]}
>
<div
:if={@messages == []}
id="chat-empty"
class="rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
>
No messages yet. Start the conversation.
</div>
<article :for={message <- @messages} id={"chat-message-#{message.id}"} class="aether-chat-message flex gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{author_initial(message)}
</div>
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
<span
phx-hook="DateTime"
id={"chat-time-#{message.id}"}
phx-update="ignore"
data-timestamp={message_timestamp(message)}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
</div>
</article>
</div>
<%= cond do %>
<% @backend == :marmot -> %>
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Marmot chat is not enabled in the web UI yet.
</div>
<% @current_user -> %>
<form id="chat-message-form" phx-submit="send_message" phx-target={@myself} class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
<label for="chat-message-body" class="sr-only">Message</label>
<textarea
id="chat-message-body"
name="message[body]"
rows="2"
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
></textarea>
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
Send
</button>
</div>
</form>
<% true -> %>
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Sign in to join the chat.
</div>
<% end %>
</div>
"""
end
defp reload_channel?(socket, slug, backend) do
channel = socket.assigns.channel
channel == nil || channel.slug != slug || channel.backend != backend
end
defp load_channel(socket, slug, backend) do
case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
{:ok, %Channel{} = channel} ->
messages = load_messages(channel, socket.assigns)
message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
socket
|> assign(:channel, channel)
|> assign(:backend, channel.backend)
|> assign(:messages, messages)
|> assign(:message_ids, message_ids)
|> assign(:message_count, MapSet.size(message_ids))
{:error, reason} ->
socket
|> assign(:channel, nil)
|> assign(:backend, backend)
|> assign(:messages, [])
|> assign(:message_ids, MapSet.new())
|> assign(:message_count, 0)
|> assign(:chat_error, inspect(reason))
end
end
defp maybe_subscribe(
%{assigns: %{channel: %Channel{id: id}, subscribed_channel_id: id}} = socket
) do
socket
end
defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
if connected?(socket) do
_ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns))
assign(socket, :subscribed_channel_id, channel.id)
else
socket
end
end
defp maybe_subscribe(socket), do: socket
defp post_message(socket, body) do
user = socket.assigns.current_user
attrs = %{
body: body,
author_id: user_attr(user, :id),
author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey),
metadata: %{"author_name" => user_attr(user, :username)}
}
case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do
{:ok, message} ->
{:noreply, put_message(socket, message)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")}
end
end
defp put_message(socket, %Message{} = message) do
if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do
append_message(socket, message)
else
socket
end
end
defp append_message(socket, %Message{} = message) do
if MapSet.member?(socket.assigns.message_ids, message.id) do
socket
else
message_ids = MapSet.put(socket.assigns.message_ids, message.id)
socket
|> assign(:messages, socket.assigns.messages ++ [message])
|> assign(:message_ids, message_ids)
|> assign(:message_count, MapSet.size(message_ids))
end
end
defp load_messages(%Channel{} = channel, assigns) do
case Chat.list_conversation_messages(channel, chat_opts(assigns)) do
{:ok, messages} -> messages
{:error, _reason} -> []
end
end
defp chat_opts(assigns) do
case Map.get(assigns, :session_privkey) do
privkey when is_binary(privkey) -> [session_privkey: privkey]
_other -> []
end
end
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
slug
|> String.replace(["-", "_"], " ")
|> String.split(" ", trim: true)
|> Enum.map_join(" ", &String.capitalize/1)
end
defp user_attr(nil, _key), do: nil
defp user_attr(user, key), do: Map.get(user, key)
defp author_label(%Message{metadata: %{"author_name" => name}})
when is_binary(name) and name != "" do
name
end
defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do
String.slice(pubkey, 0, 8) <> ""
end
defp author_label(_message), do: "Unknown"
defp author_initial(message) do
message
|> author_label()
|> String.first()
|> Kernel.||("?")
|> String.upcase()
end
defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do
DateTime.to_unix(inserted_at, :millisecond)
end
defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond)
end
@@ -0,0 +1,139 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent do
@moduledoc """
Recipient picker exposed as part of Aether's `org.tribe-one.caps.chat@1` UI contract.
"""
use Phoenix.LiveComponent
alias Tribes.Plugin.Services.Alliance
alias Tribes.Plugin.User
alias Tribes.Scope
@impl true
def update(assigns, socket) do
recipients = Map.get(assigns, :recipients) || load_recipients(Map.get(assigns, :current_user))
{:ok,
socket
|> assign(assigns)
|> assign_new(:query, fn -> "" end)
|> assign(:recipients, recipients)}
end
@impl true
def handle_event("search", %{"recipient" => %{"query" => query}}, socket) do
{:noreply, assign(socket, :query, String.trim(query || ""))}
end
def handle_event("select", %{"pubkey" => pubkey}, socket) do
case Enum.find(socket.assigns.recipients, &(&1.pubkey_hex == pubkey)) do
%User{} = user -> send(self(), {:aether_chat, :recipient_selected, user})
_other -> :ok
end
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<details id="chat-recipient-picker" class="dropdown dropdown-end">
<summary id="chat-recipient-picker-button" class="btn btn-primary btn-sm">
New message
</summary>
<div class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-xl">
<form id="chat-recipient-search-form" phx-change="search" phx-target={@myself}>
<label class="input input-sm input-bordered flex items-center gap-2">
<span class="text-xs text-base-content/50">To</span>
<input
id="chat-recipient-search"
name="recipient[query]"
value={@query}
type="search"
class="grow"
placeholder="Search tribe users"
/>
</label>
</form>
<div id="chat-recipient-options" class="mt-3 max-h-72 space-y-1 overflow-y-auto">
<button
:for={recipient <- filtered_recipients(@recipients, @query, @current_user)}
id={"chat-recipient-#{recipient.id}"}
type="button"
class="flex w-full items-center gap-3 rounded-box px-3 py-2 text-left hover:bg-base-200"
phx-click="select"
phx-target={@myself}
phx-value-pubkey={recipient.pubkey_hex}
>
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{recipient_initial(recipient)}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-medium text-base-content">{recipient_label(recipient)}</span>
<span class="block truncate text-xs text-base-content/50">{recipient_pubkey(recipient)}</span>
</span>
</button>
<p
:if={filtered_recipients(@recipients, @query, @current_user) == []}
id="chat-recipient-empty"
class="rounded-box border border-dashed border-base-300 p-4 text-center text-sm text-base-content/60"
>
No matching users.
</p>
</div>
</div>
</details>
"""
end
defp load_recipients(_current_user) do
scope = Scope.new()
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
Enum.filter(users, &is_binary(&1.pubkey_hex))
else
_other -> []
end
end
defp filtered_recipients(recipients, query, current_user) do
current_pubkey = current_user && current_user.pubkey_hex
query = String.downcase(query || "")
recipients
|> Enum.reject(&(&1.pubkey_hex == current_pubkey))
|> Enum.filter(&matches_query?(&1, query))
end
defp matches_query?(_recipient, ""), do: true
defp matches_query?(recipient, query) do
[recipient.username, recipient.display_name, recipient.npub, recipient.pubkey_hex]
|> Enum.filter(&is_binary/1)
|> Enum.any?(&String.contains?(String.downcase(&1), query))
end
defp recipient_label(%User{display_name: display_name})
when is_binary(display_name) and display_name != "" do
display_name
end
defp recipient_label(%User{username: username}) when is_binary(username) and username != "",
do: username
defp recipient_label(%User{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> ""
defp recipient_pubkey(%User{npub: npub}) when is_binary(npub) and npub != "", do: npub
defp recipient_pubkey(%User{pubkey_hex: pubkey}), do: pubkey
defp recipient_initial(recipient) do
recipient
|> recipient_label()
|> String.first()
|> Kernel.||("?")
|> String.upcase()
end
end
@@ -1,9 +1,9 @@
defmodule MyPluginWeb.HomeLive do
defmodule TribeOne.TribesPlugin.AetherWeb.HomeLive do
@moduledoc """
Example LiveView page for the plugin.
This page is registered in the plugin spec and mounted by the host
at /plugins/my_plugin.
at /plugins/aether.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
@@ -22,7 +22,7 @@ defmodule MyPluginWeb.HomeLive do
<h1 class="text-2xl font-bold">My Plugin</h1>
<p class="mt-2 text-base-content/70">
This is a Tribes plugin. Edit this page in
<code>lib/my_plugin_web/live/home_live.ex</code>.
<code>lib/aether_web/live/home_live.ex</code>.
</p>
</div>
"""
+339
View File
@@ -0,0 +1,339 @@
defmodule TribeOne.TribesPlugin.AetherWeb.TimelineLive do
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Tribes.Plugin.Layouts
alias Tribes.Plugin.Nostr.Profile
alias Tribes.Plugin.Services.Alliance
alias Tribes.Plugin.Services.Nostr
alias Tribes.Plugin.Services.Routes
alias Tribes.Scope
import Tribes.Plugin.Gettext
@impl true
def mount(_params, _session, socket) do
feed = load_feed()
notes = load_notes(feed.member_pubkeys)
profile_cache = load_profile_cache(notes)
note_ids = notes |> Enum.map(& &1.id) |> MapSet.new()
socket =
socket
|> assign(:page_title, "Aether")
|> assign(:tribe, feed.tribe)
|> assign(:tribe_member_pubkeys, feed.member_pubkeys)
|> assign(:profile_cache, profile_cache)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> assign(:subscription_ref, nil)
|> stream_configure(:notes, dom_id: &("note-" <> &1.id))
|> stream(:notes, notes)
if connected?(socket) do
{:ok, ref} = subscribe_notes(feed.member_pubkeys)
{:ok, assign(socket, :subscription_ref, ref)}
else
{:ok, socket}
end
end
@impl true
def handle_event("post", %{"note" => %{"content" => content}}, socket) do
content = String.trim(content)
cond do
socket.assigns.current_user == nil ->
{:noreply, put_flash(socket, :error, "Sign in to post")}
content == "" ->
{:noreply, put_flash(socket, :error, "Post cannot be empty")}
true ->
case socket.assigns[:session_privkey] do
privkey when is_binary(privkey) ->
case Nostr.publish_note(
content,
socket.assigns.current_user.pubkey_hex,
privkey
) do
{:ok, event} ->
handle_published_note(socket, event)
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to publish: #{inspect(reason)}")}
end
_other ->
{:noreply, put_flash(socket, :error, "Signing key unavailable in session")}
end
end
end
@impl true
def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = raw_event}, socket) do
{:noreply, handle_note_event(socket, raw_event)}
end
def handle_info({:parrhesia, :events, _ref, _sub_id, events}, socket) when is_list(events) do
socket =
Enum.reduce(events, socket, fn
%{"kind" => 1} = raw_event, socket -> handle_note_event(socket, raw_event)
_raw_event, socket -> socket
end)
{:noreply, socket}
end
def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :events, _ref, _sub_id, _events}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _sub_id, _hints}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket}
@impl true
def terminate(_reason, socket) do
if socket.assigns[:subscription_ref] do
Nostr.unsubscribe(socket.assigns.subscription_ref)
end
:ok
end
defp handle_note_event(socket, raw_event) do
case Nostr.to_event(raw_event) do
{:ok, event} ->
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
profile_cache = maybe_put_profile(socket.assigns.profile_cache, event.pubkey)
if MapSet.member?(socket.assigns.note_ids, event.id) do
assign(socket, :profile_cache, profile_cache)
else
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
socket
|> assign(:profile_cache, profile_cache)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream_insert(:notes, event, at: 0)
end
else
socket
end
{:error, _reason} ->
socket
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
current_scope={@current_scope}
>
<div class="space-y-6 p-6" id="aether-page">
<section class="rounded-3xl border border-base-300 bg-base-100/95 p-6 shadow-sm">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
Local feed
</p>
<h1 class="text-3xl font-semibold tracking-tight text-base-content">
{timeline_title(@tribe)}
</h1>
<p class="max-w-2xl text-sm leading-6 text-base-content/70">
{timeline_description(@tribe)}
</p>
</div>
<div class="rounded-full border border-base-300 px-4 py-2 text-sm font-medium text-base-content/70">
{@note_count} notes
</div>
</div>
</section>
<%= if @current_user do %>
<form id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
<div class="card-body">
<label class="fieldset mb-2" for="note-content">
<span class="label mb-1">Post</span>
<textarea
id="note-content"
name="note[content]"
rows="3"
class="textarea w-full"
placeholder="Share something with your local network"
></textarea>
</label>
<div class="card-actions justify-end">
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
Post note
</button>
</div>
</div>
</form>
<% else %>
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
{tgettext("Sign in")} to publish notes from your local identity.
</div>
<% end %>
<div id="notes" phx-update="stream" class="space-y-3">
<div
id="notes-empty"
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
>
No local notes yet.
</div>
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
<div class="card-body gap-2">
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
<a href={Routes.profile_path(note.pubkey)} class="link link-hover font-medium">
{author_label(@profile_cache, note.pubkey)}
</a>
<span
phx-hook="DateTime"
id={"time-#{note.id}"}
phx-update="ignore"
data-timestamp={note.created_at * 1000}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap text-sm leading-6">{note.content}</p>
</div>
</article>
</div>
</div>
</Layouts.app>
"""
end
defp handle_published_note(socket, event) do
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
{:noreply,
socket
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream_insert(:notes, event, at: 0)}
else
{:noreply, put_flash(socket, :info, "Posted outside the local tribe feed")}
end
end
defp load_feed do
scope = Scope.new()
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
member_pubkeys =
users
|> Enum.map(& &1.pubkey_hex)
|> Enum.filter(&is_binary/1)
|> MapSet.new()
%{tribe: tribe, member_pubkeys: member_pubkeys}
else
_ -> %{tribe: nil, member_pubkeys: MapSet.new()}
end
end
defp load_notes(member_pubkeys) do
query_opts =
[limit: 100]
|> maybe_put_authors(member_pubkeys)
case Nostr.list_notes(query_opts) do
{:ok, notes} ->
notes
|> filter_notes(member_pubkeys)
|> sort_notes()
{:error, _reason} ->
[]
end
end
defp subscribe_notes(member_pubkeys) do
query_opts =
[limit: 100]
|> maybe_put_authors(member_pubkeys)
case Nostr.subscribe_notes(self(), "aether", query_opts) do
{:ok, ref} -> {:ok, ref}
{:error, _reason} -> {:ok, nil}
end
end
defp note_in_scope?(event, member_pubkeys) do
MapSet.member?(member_pubkeys, event.pubkey)
end
defp filter_notes(notes, member_pubkeys) do
Enum.filter(notes, &note_in_scope?(&1, member_pubkeys))
end
defp sort_notes(notes) do
Enum.sort_by(notes, fn note ->
{-note.created_at, note.id}
end)
end
defp load_profile_cache(notes) do
notes
|> Enum.map(& &1.pubkey)
|> Enum.uniq()
|> Enum.reduce(%{}, &maybe_put_profile(&2, &1))
end
defp maybe_put_profile(cache, pubkey) do
if Map.has_key?(cache, pubkey) do
cache
else
case Nostr.get_profile(pubkey) do
{:ok, nil} -> Map.put(cache, pubkey, nil)
{:ok, %Profile{} = profile} -> Map.put(cache, pubkey, profile)
{:error, _reason} -> Map.put(cache, pubkey, nil)
end
end
end
defp author_label(profile_cache, pubkey) do
case Map.get(profile_cache, pubkey) do
%Profile{name: name} when is_binary(name) and name != "" -> name
_other -> "#{String.slice(pubkey, 0, 8)}"
end
end
defp timeline_title(nil), do: "Aether"
defp timeline_title(%{name: name}) when is_binary(name) do
"#{name} on Aether"
end
defp timeline_title(_tribe), do: "Aether"
defp timeline_description(%{name: name}) when is_binary(name) do
"Showing notes from #{name} members first. Global discovery can come later without losing the local trust graph."
end
defp timeline_description(_tribe) do
"Showing notes from local tribe members first. Sign in to publish from your local identity."
end
defp maybe_put_authors(opts, member_pubkeys) do
authors = MapSet.to_list(member_pubkeys)
if authors == [] do
opts
else
Keyword.put(opts, :authors, authors)
end
end
end
-81
View File
@@ -1,81 +0,0 @@
defmodule MyPlugin.Plugin do
@moduledoc """
Tribes plugin entry point.
Implements the `Tribes.Plugin` behaviour. This module is referenced by
`entry_module` in manifest.json and is called by the host's PluginManager
during startup.
"""
# Once Tribes.Plugin.Base is available, replace the manual implementation
# with:
#
# use Tribes.Plugin.Base, otp_app: :my_plugin
#
# and override only register/1.
# @behaviour Tribes.Plugin
def register(_context) do
manifest = read_manifest()
%{
name: manifest["name"],
version: manifest["version"],
provides: manifest["provides"] || [],
requires: manifest["requires"] || [],
enhances_with: manifest["enhances_with"] || [],
nav_items: [
%{
label: "My Plugin",
path: "/plugins/my_plugin",
icon: nil,
requires: [],
order: 50
}
],
pages: [
%{
path: "/plugins/my_plugin",
live_view: MyPluginWeb.HomeLive,
layout: nil
}
],
api_routes: [],
plugs: [],
children: [],
global_js: get_in(manifest, ["assets", "global_js"]) || [],
global_css: get_in(manifest, ["assets", "global_css"]) || [],
migrations_path: migrations_path(manifest),
hooks: %{}
}
end
defp read_manifest do
manifest_path()
|> File.read!()
|> Jason.decode!()
end
defp manifest_path do
# In a release, manifest.json sits alongside ebin/ in the plugin directory.
# In dev mode (path dep), it's at the project root.
case :code.priv_dir(:my_plugin) do
{:error, :bad_name} ->
# Dev mode fallback: relative to project root
Path.join(__DIR__, "../../../manifest.json") |> Path.expand()
priv_dir ->
priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand()
end
end
defp migrations_path(manifest) do
if manifest["migrations"] do
case :code.priv_dir(:my_plugin) do
{:error, :bad_name} -> nil
priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations")
end
end
end
end
+21 -9
View File
@@ -1,16 +1,28 @@
{
"name": "my_plugin",
"version": "0.1.0",
"description": "TODO: Describe what this plugin does",
"entry_module": "MyPlugin.Plugin",
"id": "org.tribe-one.plugins.aether",
"slug": "aether",
"display_name": "Aether",
"version": "0.2.0",
"description": "Local social feed and chat for Tribes, including NIP-17 direct messages.",
"entry_module": "TribeOne.TribesPlugin.Aether.Plugin",
"host_api": "1",
"provides": [],
"requires": ["ecto@1"],
"otp_app": "tribe_one_aether",
"provides": [
"org.tribe-one.caps.social@1",
"org.tribe-one.caps.chat@1"
],
"requires": [
"org.tribe-one.caps.ui@1"
],
"enhances_with": [],
"assets": {
"global_js": ["my_plugin.js"],
"global_css": ["my_plugin.css"]
"global_js": [
"aether.js"
],
"global_css": [
"aether.css"
]
},
"migrations": false,
"migrations": true,
"children": false
}
+77
View File
@@ -0,0 +1,77 @@
;; Guix development manifest for the Aether plugin.
;;
;; Usage:
;; ./guix/guix-dev
;;
;; This intentionally keeps the repo-specific development package list here,
;; similar to devenv.nix. Shared custom packages such as node-24, prek, and
;; prettier come from the pinned guix-tribes channel.
(use-modules (gnu packages bash)
(gnu packages base)
(gnu packages elixir)
(gnu packages erlang)
(gnu packages gnome)
(gnu packages linux)
(gnu packages nss)
(gnu packages package-management)
(gnu packages version-control)
(guix build-system trivial)
(guix gexp)
((guix licenses) #:prefix license:)
(guix packages)
(guix profiles)
(tribes packages devtools)
((tribes packages node) #:select (node-24)))
(define guix-dev-script
(local-file "guix/guix-dev" "guix-dev-script"))
(define guix-dev-command-package
(package
(name "guix-dev-command")
(version "0.1")
(source #f)
(build-system trivial-build-system)
(arguments
(list
#:modules '((guix build utils))
#:builder
#~(begin
(use-modules (guix build utils))
(let* ((bin (string-append #$output "/bin"))
(target (string-append bin "/guix-dev")))
(mkdir-p bin)
(copy-file #$guix-dev-script target)
(substitute* target
(("^#!/usr/bin/env bash")
(string-append "#!" #$(file-append bash-minimal "/bin/bash"))))
(chmod target #o555)))))
(inputs (list bash-minimal))
(home-page "https://git.teralink.net/tribes/tribes-plugin-aether.git")
(synopsis "Pinned Guix development shell helper")
(description
"guix-dev re-enters this checkout's Guix development shell through its
repo-local guix/channels.scm time-machine pin and manifest.scm.")
(license license:asl2.0)))
(packages->manifest
(list bash
coreutils
findutils
grep
sed
nss-certs
git
guix
guix-dev-command-package
pre-commit
prek
prettier
erlang
elixir
elixir-hex
rebar3
node-24
libnotify
inotify-tools))
+79 -13
View File
@@ -1,15 +1,22 @@
defmodule MyPlugin.MixProject do
defmodule TribeOne.TribesPlugin.Aether.MixProject do
use Mix.Project
def project do
[
app: :my_plugin,
version: "0.1.0",
app: :tribe_one_aether,
version: "0.2.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases()
aliases: aliases(),
usage_rules: usage_rules()
]
end
def cli do
[
preferred_envs: [precommit: :test, raw_precommit: :test, raw_test: :test]
]
end
@@ -17,7 +24,7 @@ defmodule MyPlugin.MixProject do
[
extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree:
# mod: {MyPlugin.Application, []}
# mod: {TribeOne.TribesPlugin.Aether.Application, []}
]
end
@@ -26,21 +33,80 @@ defmodule MyPlugin.MixProject do
defp deps do
[
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
# Plugin API dependency for local development alongside a tribes checkout.
#
# For local development alongside a tribes checkout:
{:tribes, path: "../tribes", only: [:dev, :test]},
#
# For CI or standalone development (when not co-located with tribes):
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},
# For CI or standalone development, this can be replaced with a published
# package once tribes_plugin_api is released.
{:tribes_plugin_api, path: "../tribes/tribes_plugin_api", runtime: false},
{:tribes_plugin, path: "../tribes-plugin-new", only: [:dev, :test], runtime: false},
{:igniter, "~> 0.7", only: [:dev, :test], runtime: false},
{:usage_rules, "~> 1.2", only: :dev},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix, "~> 1.8"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_view, "~> 1.1.0"}
] ++ tribes_deps(Mix.env())
end
{:jason, "~> 1.2"}
defp tribes_deps(:dev), do: [{:tribes, path: "../tribes", only: :dev, runtime: false}]
defp tribes_deps(:test), do: [{:tribes, path: "../tribes", only: :test}]
defp tribes_deps(_), do: []
defp usage_rules do
[
file: "AGENTS.md",
usage_rules: [
{:usage_rules, sub_rules: []},
{"usage_rules:elixir", main: false},
{"usage_rules:otp", main: false},
"phoenix:ecto",
"phoenix:html",
"phoenix:liveview",
"phoenix:phoenix",
{:ash, sub_rules: []},
{"ash:actions", link: :markdown, main: false},
{"ash:migrations", link: :markdown, main: false},
{"ash:testing", link: :markdown, main: false},
{:tribes_plugin_api, sub_rules: []},
{:tribes, sub_rules: []}
]
]
end
defp aliases do
[
test: ["test"]
test: &plugin_test_message/1,
raw_test: &raw_test/1,
lint: ["format --check-formatted", "credo"],
precommit: &plugin_precommit_message/1,
raw_precommit: &raw_precommit/1
]
end
defp plugin_test_message(_args) do
Mix.raise("""
This plugin test suite is host-backed. Use `plugin test` or `scripts/plugin test`.
For low-level debugging only, set up the host environment yourself and run `mix raw_test`.
""")
end
defp plugin_precommit_message(_args) do
Mix.raise("""
This plugin precommit is host-backed. Use `plugin precommit` or `scripts/plugin precommit`.
For low-level debugging only, set up the host environment yourself and run `mix raw_precommit`.
""")
end
defp raw_precommit(args) do
Mix.Task.run("format")
Mix.Task.run("compile", ["--warnings-as-errors"])
Mix.Task.run("credo", ["--strict", "--all"])
Mix.Task.run("deps.unlock", ["--unused"])
raw_test(args)
end
defp raw_test(args), do: Mix.Tasks.Test.run(args)
end
+104
View File
@@ -0,0 +1,104 @@
%{
"absinthe": {:hex, :absinthe, "1.9.1", "19fe8614d5cdabefaf127ee224cb89eceea48314de4d709737451b43b5bdedd5", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d93e1aa61d68b974f48d5660104cb911ae045ee3a5d69954d251f91f3dbe2077"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"ash": {:hex, :ash, "3.21.3", "4cb8b05655664fe65daf6862285f0a6f91991f1a73ff6554b4609a484c1ceb8b", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "deec4223f11c7cbf3b35cb14b3c8df50b58b06a5c9f64de539a93469fa249b04"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_graphql": {:hex, :ash_graphql, "1.9.3", "02d5a8e34a9fb22267513dab6e91380d5aaaffb9d8403d78a26cf1eba655f743", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "dc3a8dbab4858a655c3f58813f1e1d8f815886d8eb0a9536601c52ceddbab521"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
"ash_postgres": {:hex, :ash_postgres, "2.8.0", "bf1a30c57b15ae3622f1cea34959b3a11c33f0f4319830dd28235a3c0c79f647", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "06cd4278ccc838104fdbc92e0f9508cf75c4f3376a922ec7e6d85bc7192d616d"},
"ash_sql": {:hex, :ash_sql, "0.5.1", "f4fcaa29308bdf417fe85373e57fb6d868b7db1e7ccc2fae5cc7f8f92016d622", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "33febc8a84443683c3685a2d65f674391d8f21c073555d7c22d6c484f72faa65"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bech32": {:hex, :bech32, "1.0.0", "85a3bb58c408d735b5becb39e8a23536660ec0df1ef0afee72377c130939de1b", [:mix], [], "hexpm", "f781b8524c30a524922613d97c1858c27bd9f639b4e6b350f4a4843ee97607d3"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cldr_utils": {:hex, :cldr_utils, "2.29.5", "f43161e04acb4016f5841b2320d69120d51827f5346babb2227893a2c5916dc8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "962d3a2028b232ee0a5373941dc411028a9442f53444a4d5d2c354f687db1835"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_cldr": {:hex, :ex_cldr, "2.47.2", "c866f4b45523abd25eea3e5252eb91364296dd15bddf970db1c78cd38f25df9a", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "4a7cef380a1c2546166b45d6ee5e8e2f707ea695b12ae6dadd250201588b4f16"},
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "2.4.2", "31d5d7a65add622dc4bf16fc1bfe5ef378d71cfdaf02fb33c7d0742c585f6117", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_numbers, "~> 2.36", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ab69fd04bc1ae18baf9d2e57335d4754c5ac263076ea397eb112621702251fe5"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.1", "89947c7102ff1b46fc46095624239a1c3d72499b19ed650597630771d9e4a662", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "e266a0a61f4c7d83608154d49b59e4d7485b2aaa7ba1d0e17b3c55910595de51"},
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.25.6", "6db974ab2b430b5733994c2bfbe98a69e25eeb076b876a929791ff521f8fdd96", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_calendars, "~> 2.4", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:tz, "~> 0.26", [hex: :tz, repo: "hexpm", optional: true]}], "hexpm", "926ff5662b849f86088832ee66b61a96aab0fa5a54d5e14240e08ad3030663e2"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.1", "e5124e288a8e672831e10d39530ecb5329bc9af2169709ebfbadc814cae7d4fb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4f95738f1dc4e821485e52226666f7691c9276bf6eba49cba8d23c8a2db05e84"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"parrhesia": {:hex, :parrhesia, "0.14.0", "7ff96a6d645740a19b66567c3346162735ba821a90377616e74493637c35195e", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:lib_secp256k1, "~> 0.7", [hex: :lib_secp256k1, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus, "~> 1.1", [hex: :telemetry_metrics_prometheus, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: false]}], "hexpm", "2bda91e6f385b6990465f2ccc9e8e0266105a31f72e2b466ae247f846ec3ef28"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"},
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
}
+30
View File
@@ -0,0 +1,30 @@
--- a/command.ts
+++ b/command.ts
@@ -3,8 +3,6 @@ import { toText as streamToText } from "@std/streams";
import type { ArgumentValue } from "@cliffy/command";
import { Command, ValidationError } from "@cliffy/command";
import { CompletionsCommand } from "@cliffy/command/completions";
-import { UpgradeCommand } from "@cliffy/command/upgrade";
-import { JsrProvider } from "@cliffy/command/upgrade/provider/jsr";
import type { AllEventsIterOptions, FetchFilter, FetchTimeRangeFilter } from "nostr-fetch";
import * as nip19 from "nostr-tools/nip19";
@@ -29,18 +27,6 @@ export const nosdumpCommand = new Command()
.description("A tool to dump events stored in Nostr relays")
.usage("[options...] <relays...>")
.command("completions", new CompletionsCommand())
- .command(
- "upgrade",
- new UpgradeCommand({
- provider: [
- new JsrProvider({
- scope: "jiftechnify",
- package: "@jiftechnify/nosdump",
- }),
- ],
- args: ["--allow-all"], // grant all permissions while upgrading
- }),
- )
.command("relay-alias", relayAliasCommand).alias("alias")
.command("relay-set", relaySetCommand).alias("rset")
.reset()
+49
View File
@@ -0,0 +1,49 @@
{
lib,
stdenv,
fetchFromGitHub,
deno,
makeWrapper,
}: let
pname = "nosdump";
version = "0.7.1";
src = fetchFromGitHub {
owner = "jiftechnify";
repo = "nosdump";
rev = version;
hash = "sha256-sn6KUZWA2Y65Y/f1J718pT/a2BvX1Vr1LMe5RL4KtrM=";
};
in
stdenv.mkDerivation {
inherit pname version src;
nativeBuildInputs = [makeWrapper];
patches = [./nosdump-no-upgrade.patch];
installPhase = ''
runHook preInstall
mkdir -p "$out/share/${pname}" "$out/bin"
cp -R . "$out/share/${pname}/"
makeWrapper ${deno}/bin/deno "$out/bin/nosdump" \
--add-flags "run" \
--add-flags "--frozen" \
--add-flags "--no-check" \
--add-flags "--allow-env" \
--add-flags "--allow-net" \
--add-flags "--allow-read" \
--add-flags "--allow-write" \
--add-flags "--allow-sys" \
--add-flags "$out/share/${pname}/mod.ts"
runHook postInstall
'';
meta = with lib; {
description = "Dump events from Nostr relays";
homepage = "https://github.com/jiftechnify/nosdump";
license = licenses.mit;
mainProgram = "nosdump";
platforms = platforms.linux ++ platforms.darwin;
};
}
@@ -0,0 +1,143 @@
defmodule Tribes.Repo.Migrations.AddChatExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE($1, $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS TRUE THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS NOT NULL THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
RETURNS text[] AS $$
DECLARE
start_index INT = 1;
end_index INT = array_length(arr, 1);
BEGIN
WHILE start_index <= end_index AND arr[start_index] = '' LOOP
start_index := start_index + 1;
END LOOP;
WHILE end_index >= start_index AND arr[end_index] = '' LOOP
end_index := end_index - 1;
END LOOP;
IF start_index > end_index THEN
RETURN ARRAY[]::text[];
ELSE
RETURN arr[start_index : end_index];
END IF;
END; $$
LANGUAGE plpgsql
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
RETURNS BOOLEAN AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
RETURNS ANYCOMPATIBLE AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID
AS $$
DECLARE
timestamp TIMESTAMPTZ;
microseconds INT;
BEGIN
timestamp = clock_timestamp();
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
RETURN encode(
set_byte(
set_byte(
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
),
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
),
7, microseconds::bit(8)::int
),
'hex')::UUID;
END
$$
LANGUAGE PLPGSQL
SET search_path = ''
VOLATILE;
""")
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
execute(
"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
)
# execute("DROP EXTENSION IF EXISTS \"citext\"")
end
end
@@ -0,0 +1,134 @@
defmodule Tribes.Repo.Migrations.AddChat do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:aether_chat_messages, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:author_id, :uuid)
add(:author_pubkey, :text)
add(:body, :text, null: false)
add(:client_message_id, :text)
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:channel_id, :uuid, null: false)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_messages, [:client_message_id],
unique: true,
where: "deleted_at IS NULL AND client_message_id IS NOT NULL AND deleted_at IS NULL"
)
)
create(index(:aether_chat_messages, [:author_pubkey], where: "deleted_at IS NULL"))
create(index(:aether_chat_messages, [:channel_id, :inserted_at], where: "deleted_at IS NULL"))
create table(:aether_chat_channels, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
end
alter table(:aether_chat_messages) do
modify(
:channel_id,
references(:aether_chat_channels,
column: :id,
name: "aether_chat_messages_channel_id_fkey",
type: :uuid,
prefix: "public"
)
)
end
alter table(:aether_chat_channels) do
add(:slug, :text, null: false)
add(:title, :text, null: false)
add(:description, :text)
add(:backend, :text, null: false, default: "public_sync")
add(:conversation_kind, :text, null: false, default: "group")
add(:context_provider, :text)
add(:context_type, :text)
add(:context_id, :text)
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_channels, [:context_provider, :context_type, :context_id],
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
create(
index(:aether_chat_channels, [:slug],
unique: true,
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
end
def down do
drop_if_exists(index(:aether_chat_channels, [:slug]))
drop_if_exists(index(:aether_chat_channels, [:context_provider, :context_type, :context_id]))
alter table(:aether_chat_channels) do
remove(:deleted_at)
remove(:updated_at)
remove(:inserted_at)
remove(:metadata)
remove(:context_id)
remove(:context_type)
remove(:context_provider)
remove(:conversation_kind)
remove(:backend)
remove(:description)
remove(:title)
remove(:slug)
end
drop(constraint(:aether_chat_messages, "aether_chat_messages_channel_id_fkey"))
alter table(:aether_chat_messages) do
modify(:channel_id, :uuid)
end
drop(table(:aether_chat_channels))
drop_if_exists(index(:aether_chat_messages, [:channel_id, :inserted_at]))
drop_if_exists(index(:aether_chat_messages, [:author_pubkey]))
drop_if_exists(index(:aether_chat_messages, [:client_message_id]))
drop(table(:aether_chat_messages))
end
end
@@ -0,0 +1,66 @@
defmodule Tribes.Repo.Migrations.AddChatParticipants do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:aether_chat_participants, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:pubkey, :text, null: false)
add(:user_id, :uuid)
add(:display_name, :text)
add(:role, :text, null: false, default: "member")
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(
:channel_id,
references(:aether_chat_channels,
column: :id,
name: "aether_chat_participants_channel_id_fkey",
type: :uuid,
prefix: "public"
),
null: false
)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_participants, [:pubkey],
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
create(
index(:aether_chat_participants, [:channel_id, :pubkey],
unique: true,
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
end
def down do
drop(constraint(:aether_chat_participants, "aether_chat_participants_channel_id_fkey"))
drop_if_exists(index(:aether_chat_participants, [:channel_id, :pubkey]))
drop_if_exists(index(:aether_chat_participants, [:pubkey]))
drop(table(:aether_chat_participants))
end
end
@@ -0,0 +1,231 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "title",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"public_sync\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "backend",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"group\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "conversation_kind",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_provider",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"slug"
],
"fields": [
{
"type": "atom",
"value": "slug"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "deleted_at IS NULL"
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"context_provider",
"context_type",
"context_id"
],
"fields": [
{
"type": "atom",
"value": "context_provider"
},
{
"type": "atom",
"value": "context_type"
},
{
"type": "atom",
"value": "context_id"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": "deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "274584387DB5ECF41145DE06404A06BF41F74821287238F3424C60E1B3FCEF21",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_channels"
}
@@ -0,0 +1,231 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "author_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "author_pubkey",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "body",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "client_message_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "aether_chat_messages_channel_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "aether_chat_channels"
},
"scale": null,
"size": null,
"source": "channel_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"channel_id",
"inserted_at"
],
"fields": [
{
"type": "atom",
"value": "channel_id"
},
{
"type": "atom",
"value": "inserted_at"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"author_pubkey"
],
"fields": [
{
"type": "atom",
"value": "author_pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"client_message_id"
],
"fields": [
{
"type": "atom",
"value": "client_message_id"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "client_message_id IS NOT NULL AND deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "476E6D316A778142AA734E0506BCC0CE1726F1CE12BBE1798BF41A34A5946BFA",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_messages"
}
@@ -0,0 +1,209 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "pubkey",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "display_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"member\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "role",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "aether_chat_participants_channel_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "aether_chat_channels"
},
"scale": null,
"size": null,
"source": "channel_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"channel_id",
"pubkey"
],
"fields": [
{
"type": "atom",
"value": "channel_id"
},
{
"type": "atom",
"value": "pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "deleted_at IS NULL"
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"pubkey"
],
"fields": [
{
"type": "atom",
"value": "pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": "deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "FE4AA6C74C1C2B9D844C47CDFA9418A41D4ACAFD7B2EF6D15CDA264295CBD6B3",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_participants"
}
@@ -0,0 +1,7 @@
{
"ash_functions_version": 5,
"installed": [
"ash-functions",
"citext"
]
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env sh
set -eu
maxkb=500
while [ "$#" -gt 0 ]; do
case "$1" in
--maxkb=*)
maxkb=${1#--maxkb=}
shift
;;
--)
shift
break
;;
-*)
echo "check-added-large-files: unknown option: $1" >&2
exit 2
;;
*)
break
;;
esac
done
limit=$((maxkb * 1024))
failed=0
for file in "$@"; do
[ -f "$file" ] || continue
size=$(wc -c < "$file" | tr -d '[:space:]')
if [ "$size" -gt "$limit" ]; then
kb=$(((size + 1023) / 1024))
echo "$file (${kb} KB) exceeds ${maxkb} KB" >&2
failed=1
fi
done
exit "$failed"
Executable
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
plugin validate
plugin test [mix test args...]
plugin precommit [mix precommit args...]
plugin ecto.migration <name> [mix ecto.gen.migration args...]
plugin ash.codegen <name> [mix ash_postgres.generate_migrations args...]
plugin smoke
plugin shell
EOF
}
fail() {
echo "plugin: $*" >&2
exit 1
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
plugin_root="$(cd "$script_dir/.." && pwd -P)"
host_root="${TRIBES_HOST_ROOT:-$plugin_root/../tribes}"
host_root="$(cd "$host_root" && pwd -P)"
host_script="$host_root/scripts/plugin"
json_field() {
local field="$1"
FIELD="$field" mix run --no-start -e '
field = System.fetch_env!("FIELD")
manifest = "manifest.json" |> File.read!() |> JSON.decode!()
value = Map.fetch!(manifest, field)
IO.write(value)
'
}
run_smoke() {
cd "$plugin_root"
mix compile
local otp_app
local entry_module
otp_app="$(json_field otp_app)"
entry_module="$(json_field entry_module)"
local beam_path="_build/dev/lib/$otp_app/ebin/Elixir.$entry_module.beam"
[[ -f "$beam_path" ]] || fail "expected entry module beam at $beam_path"
cd "$host_root"
PLUGIN_ROOT="$plugin_root" ENTRY_MODULE="$entry_module" mix run --no-start -e '
plugin_root = System.fetch_env!("PLUGIN_ROOT")
entry_module = System.fetch_env!("ENTRY_MODULE")
plugin_root
|> Path.join("_build/dev/lib/*/ebin")
|> Path.wildcard()
|> Enum.each(&(:code.add_patha(String.to_charlist(&1))))
module = String.to_atom("Elixir." <> entry_module)
case Code.ensure_loaded(module) do
{:module, ^module} ->
IO.puts("Loaded #{entry_module}")
other ->
raise "failed to load #{entry_module}: #{inspect(other)}"
end
'
}
command_name="${1:-}"
if [[ -z "$command_name" ]]; then
usage
exit 1
fi
shift
case "$command_name" in
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
-h | --help | help)
usage
exit 0
;;
*)
usage
fail "unknown command: $command_name"
;;
esac
[[ -x "$host_script" || -f "$host_script" ]] || fail "expected host plugin script at $host_script"
if [[ "$command_name" == "smoke" ]]; then
run_smoke "$@"
exit 0
fi
if [[ "${DEVENV_ROOT:-}" == "$plugin_root" ]]; then
command -v devenv >/dev/null 2>&1 || fail "devenv is required when running from the plugin devenv shell"
cd "$host_root"
exec devenv shell -- bash ./scripts/plugin "$command_name" "$plugin_root" "$@"
fi
exec bash "$host_script" "$command_name" "$plugin_root" "$@"
+26 -7
View File
@@ -45,13 +45,32 @@ if [ -d "test/my_plugin" ]; then
mv "test/my_plugin" "test/$SNAKE"
fi
# Replace in all text files
find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \
sed -i '' \
-e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \
-e "s/my-plugin/$SNAKE/g" \
{} +
# Replace in all text files (portable across GNU/BSD sed)
sed_in_place() {
file=$1
if sed --version >/dev/null 2>&1; then
sed -i \
-e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \
-e "s/my-plugin/$SNAKE/g" \
"$file"
else
sed -i '' \
-e "s/my_plugin/$SNAKE/g" \
-e "s/MyPlugin/$MODULE/g" \
-e "s/my-plugin/$SNAKE/g" \
"$file"
fi
}
while IFS= read -r -d '' file; do
sed_in_place "$file"
done < <(
find . -type f \
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
-print0
)
# Rename asset files
for ext in js css; do
+244
View File
@@ -0,0 +1,244 @@
defmodule TribeOne.TribesPlugin.Aether.ChatBackendTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
test "NIP-17 DM flow sends a user-to-user message", %{
signed_in_conn: sender_conn,
current_user: sender,
conn: conn
} do
recipient = create_user!()
{:ok, view, _html} = live(sender_conn, "/aether/chat")
assert has_element?(view, "#chat-recipient-picker")
view
|> form("#chat-recipient-search-form", %{"recipient" => %{"query" => recipient.username}})
|> render_change()
view
|> element("#chat-recipient-#{recipient.id}")
|> render_click()
assert_redirect(
view,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
{:ok, sender_view, _html} =
live(
sender_conn,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
sender_view
|> form("#chat-message-form", %{"message" => %{"body" => "hello recipient"}})
|> render_submit()
assert has_element?(sender_view, "#chat-messages", "hello recipient")
recipient_conn = sign_in(conn, recipient.username)
{:ok, recipient_view, _html} =
live(
recipient_conn,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
assert has_element?(recipient_view, "#chat-messages", "hello recipient")
end
test "Aether publishes real NIP-17 giftwraps that nak can unwrap" do
require_executable!("nak")
{sender_pubkey, sender_privkey} = Tribes.Keyring.generate_keypair()
recipient_privkey = nak_key_generate!()
recipient_pubkey = nak_key_public!(recipient_privkey)
{:ok, channel} =
Chat.ensure_direct_conversation(sender_pubkey, recipient_pubkey, %{
title: "NIP-17 interop"
})
assert {:ok, message} =
Chat.send_message(
channel,
%{body: "hello nak recipient", author_pubkey: sender_pubkey},
session_privkey: sender_privkey
)
assert message.body == "hello nak recipient"
[giftwrap] = giftwraps_for(recipient_pubkey)
rumor = nak_gift_unwrap!(recipient_privkey, giftwrap)
assert giftwrap["kind"] == 1059
assert ["p", recipient_pubkey] in Enum.map(giftwrap["tags"], &Enum.take(&1, 2))
assert rumor["kind"] == 14
assert rumor["pubkey"] == sender_pubkey
assert rumor["content"] == "hello nak recipient"
assert ["p", recipient_pubkey] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
end
test "Aether reads external NIP-17 giftwraps produced by nak" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "External NIP-17"
})
giftwrap =
nak_nip17_giftwrap!(external_sender_privkey, recipient_pubkey, "hello from external nak")
:ok = publish_event!(giftwrap)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "hello from external nak"
end
test "NIP-04 backend imports decryptable legacy DMs as read-only" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "Legacy NIP-04",
backend: :nostr_nip04,
conversation_kind: :legacy_dm
})
{:ok, ciphertext} =
Nak.nip04_encrypt(external_sender_privkey, recipient_pubkey, "legacy hello from nak")
legacy_event = nak_event!(external_sender_privkey, 4, recipient_pubkey, ciphertext)
:ok = publish_event!(legacy_event)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "legacy hello from nak"
assert {:error, :read_only_backend} =
Chat.send_message(channel, %{body: "nope"}, session_privkey: recipient_privkey)
end
test "backend capabilities prepare Nostr-compatible non-custodial backends" do
assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} =
Chat.backend_capabilities(:nostr_nip17)
assert %{read_only?: true, required_protocols: [:nip04]} =
Chat.backend_capabilities(:nostr_nip04)
end
defp create_user! do
username = "chat_recipient_#{System.unique_integer([:positive])}"
{:ok, user} =
Ash.create(
Tribes.Accounts.User,
%{username: username, password: "password_123", password_confirmation: "password_123"},
action: :register_with_password,
domain: Tribes.Accounts
)
Tribes.Plugin.User.from_host(user)
end
defp publish_event!(event) do
assert {:ok, result} =
Events.publish(event,
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([event["pubkey"]])
}
)
assert result.accepted
:ok
end
defp giftwraps_for(recipient_pubkey) do
assert {:ok, events} =
Events.query(
[%{"kinds" => [1059], "#p" => [recipient_pubkey], "limit" => 10}],
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([recipient_pubkey])
}
)
events
end
defp nak_nip17_giftwrap!(sender_privkey, recipient_pubkey, body) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null |
nak gift wrap --sec "$1" -p "$2" 2>/dev/null
'''
script |> run_script!([sender_privkey, recipient_pubkey, body]) |> JSON.decode!()
end
defp nak_event!(sender_privkey, kind, recipient_pubkey, content) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k "$2" -p "$3" -c "$4" </dev/null 2>/dev/null
'''
script
|> run_script!([sender_privkey, to_string(kind), recipient_pubkey, content])
|> JSON.decode!()
end
defp nak_gift_unwrap!(recipient_privkey, giftwrap) do
script = ~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
'''
script
|> run_script!([recipient_privkey, JSON.encode!(giftwrap)])
|> JSON.decode!()
end
defp nak_key_generate!, do: run_script!("nak key generate", [])
defp nak_key_public!(privkey), do: run_script!("nak key public \"$1\"", [privkey])
defp require_executable!(name) do
unless System.find_executable(name) do
flunk("expected #{name} in PATH")
end
end
defp run_script!(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> String.trim(output)
{:ok, {output, status}} -> flunk("script failed with status #{status}:\n#{output}")
nil -> flunk("script timed out")
end
end
end
+74
View File
@@ -0,0 +1,74 @@
defmodule TribeOne.TribesPlugin.Aether.ChatPageTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
alias TribeOne.TribesPlugin.Aether.Chat
test "renders the public chat for signed-out visitors", %{conn: conn} do
{:ok, view, _html} = live(conn, "/aether/chat")
assert has_element?(view, "#aether-chat-page")
assert has_element?(view, "#chat-signed-out", "Sign in to join the chat.")
end
test "renders the message composer for signed-in users", %{signed_in_conn: conn} do
{:ok, view, _html} = live(conn, "/aether/chat")
assert has_element?(view, "#chat-message-form")
assert has_element?(view, "#chat-send-button", "Send")
end
test "posts a public synced message", %{signed_in_conn: conn} do
slug = "test-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/#{slug}")
view
|> form("#chat-message-form", %{"message" => %{"body" => "Hello from chat test"}})
|> render_submit()
assert has_element?(view, "#chat-messages", "Hello from chat test")
end
test "renders compact embedded chat", %{conn: conn} do
slug = "embed-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/embed/#{slug}")
assert has_element?(view, "#aether-chat-embed")
assert has_element?(view, "#chat-embed-header")
refute has_element?(view, "#aether-chat-page")
end
test "renders Marmot scaffold without public composer", %{signed_in_conn: conn} do
slug = "marmot-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/marmot/#{slug}")
assert has_element?(view, "#chat-marmot-notice")
assert has_element?(view, "#chat-marmot-disabled")
refute has_element?(view, "#chat-message-form")
end
test "ensures context group channels with stable slugs" do
attrs = %{
context_provider: "sender",
context_type: "stream",
context_id: "stream-123",
title: "Stream chat"
}
assert {:ok, first} = Chat.ensure_channel(attrs)
assert {:ok, second} = Chat.ensure_channel(attrs)
assert first.id == second.id
assert first.slug == "sender-stream-stream-123"
assert first.conversation_kind == :context_group
assert Chat.embed_path(first) == "/aether/chat/embed/sender-stream-stream-123"
assert Chat.marmot_path(first) == "/aether/chat/marmot/sender-stream-stream-123"
end
test "ensures context group channels through provider helper" do
assert {:ok, channel} =
Chat.ensure_context_channel("sender", "stream", "stream-456", %{title: "Stream Chat"})
assert channel.slug == "sender-stream-stream-456"
assert channel.conversation_kind == :context_group
end
end
@@ -0,0 +1,90 @@
defmodule TribeOne.TribesPlugin.Aether.ExternalClientInteropTest do
use ExUnit.Case, async: false
test "nak can produce and consume NIP-17/NIP-59 and NIP-04 DM artifacts" do
require_executable!("nak")
output =
run_script!(~S'''
set -euo pipefail
sender_sec=$(nak key generate)
recipient_sec=$(nak key generate)
sender_pub=$(nak key public "$sender_sec")
recipient_pub=$(nak key public "$recipient_sec")
gift=$(nak event --sec "$sender_sec" -k 14 -p "$recipient_pub" -c "hello from nak" </dev/null 2>/dev/null | nak gift wrap --sec "$sender_sec" -p "$recipient_pub" 2>/dev/null)
rumor=$(printf '%s\n' "$gift" | nak gift unwrap --sec "$recipient_sec" 2>/dev/null)
legacy_ciphertext=$(nak encrypt --nip04 --sec "$sender_sec" -p "$recipient_pub" "legacy hello from nak" </dev/null 2>/dev/null)
legacy_plaintext=$(nak decrypt --nip04 --sec "$recipient_sec" -p "$sender_pub" "$legacy_ciphertext" 2>/dev/null)
printf 'SENDER_PUB\t%s\n' "$sender_pub"
printf 'RECIPIENT_PUB\t%s\n' "$recipient_pub"
printf 'GIFT\t%s\n' "$gift"
printf 'RUMOR\t%s\n' "$rumor"
printf 'NIP04\t%s\n' "$legacy_plaintext"
''')
lines = prefixed_lines(output)
sender_pub = Map.fetch!(lines, "SENDER_PUB")
recipient_pub = Map.fetch!(lines, "RECIPIENT_PUB")
gift = lines |> Map.fetch!("GIFT") |> JSON.decode!()
rumor = lines |> Map.fetch!("RUMOR") |> JSON.decode!()
assert gift["kind"] == 1059
assert ["p", recipient_pub] in Enum.map(gift["tags"], &Enum.take(&1, 2))
assert rumor["kind"] == 14
assert rumor["pubkey"] == sender_pub
assert rumor["content"] == "hello from nak"
assert ["p", recipient_pub] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
assert Map.fetch!(lines, "NIP04") == "legacy hello from nak"
end
test "algia can consume Nostr event JSON with isolated config" do
require_executable!("nak")
require_executable!("algia")
output =
run_script!(~S'''
set -euo pipefail
home=$(mktemp -d)
mkdir -p "$home/.config/algia"
sec=$(nak key generate)
nsec=$(nak encode nsec "$sec")
printf '{"privateKey":"%s","relays":{}}\n' "$nsec" > "$home/.config/algia/config.json"
nak event --sec "$sec" -k 1 -c "hello from algia cat" </dev/null 2>/dev/null | HOME="$home" algia cat --json 2>/dev/null
''')
event = JSON.decode!(String.trim(output))
assert event["kind"] == 1
assert event["content"] == "hello from algia cat"
assert is_binary(event["id"])
assert is_binary(event["sig"])
end
defp require_executable!(name) do
unless System.find_executable(name) do
flunk("expected #{name} in PATH")
end
end
defp run_script!(script) do
case System.cmd("timeout", ["30s", "bash", "-lc", script], stderr_to_stdout: true) do
{output, 0} -> output
{output, status} -> flunk("script failed with status #{status}:\n#{output}")
end
end
defp prefixed_lines(output) do
output
|> String.split("\n", trim: true)
|> Map.new(fn line ->
[key, value] = String.split(line, "\t", parts: 2)
{key, value}
end)
end
end
@@ -1,11 +1,12 @@
defmodule MyPlugin.ManifestTest do
defmodule TribeOne.TribesPlugin.Aether.ManifestTest do
use ExUnit.Case, async: true
@manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand()
@capability_regex ~r/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+@[1-9][0-9]*$/
setup_all do
content = File.read!(@manifest_path)
manifest = Jason.decode!(content)
manifest = JSON.decode!(content)
%{manifest: manifest}
end
@@ -15,7 +16,17 @@ defmodule MyPlugin.ManifestTest do
end
test "has required fields", %{manifest: manifest} do
required = ["name", "version", "entry_module", "host_api", "provides", "requires"]
required = [
"id",
"slug",
"display_name",
"version",
"entry_module",
"host_api",
"otp_app",
"provides",
"requires"
]
for field <- required do
assert Map.has_key?(manifest, field),
@@ -23,37 +34,49 @@ defmodule MyPlugin.ManifestTest do
end
end
test "name matches OTP app name", %{manifest: manifest} do
assert manifest["name"] == "my_plugin"
test "identity fields are namespaced", %{manifest: manifest} do
assert manifest["id"] == "org.tribe-one.plugins.aether"
assert manifest["slug"] == "aether"
assert manifest["otp_app"] == "tribe_one_aether"
end
test "entry_module is a valid Elixir module name", %{manifest: manifest} do
test "entry_module uses the TribeOne namespace and Plugin suffix", %{manifest: manifest} do
module_name = manifest["entry_module"]
assert is_binary(module_name)
assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ")
assert Regex.match?(
~r/^TribeOne\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*\.Plugin$/,
module_name
)
end
test "provides contains valid capability identifiers", %{manifest: manifest} do
for cap <- manifest["provides"] do
assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
assert Regex.match?(@capability_regex, cap),
"invalid capability identifier: #{inspect(cap)}"
end
end
test "requires contains valid capability identifiers", %{manifest: manifest} do
for cap <- manifest["requires"] do
assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
assert Regex.match?(@capability_regex, cap),
"invalid capability identifier: #{inspect(cap)}"
end
end
test "declares the chat capability", %{manifest: manifest} do
assert "org.tribe-one.caps.chat@1" in manifest["provides"]
end
test "host_api is a string version", %{manifest: manifest} do
assert is_binary(manifest["host_api"])
end
test "entry_module matches actual plugin module", %{manifest: manifest} do
module = String.to_atom("Elixir.#{manifest["entry_module"]}")
assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable"
assert Code.ensure_loaded?(module),
"entry_module #{manifest["entry_module"]} must be loadable"
end
end
end
+11
View File
@@ -0,0 +1,11 @@
defmodule TribeOne.TribesPlugin.Aether.MarmotAssetsTest do
use ExUnit.Case, async: true
@package_path Path.expand("../../assets/package.json", __DIR__)
test "pins marmot-ts to the selected client version" do
package = @package_path |> File.read!() |> JSON.decode!()
assert get_in(package, ["dependencies", "@internet-privacy/marmot-ts"]) == "0.5.1"
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule TribeOne.TribesPlugin.Aether.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Aether.Plugin
end
+44
View File
@@ -0,0 +1,44 @@
defmodule TribeOne.TribesPlugin.Aether.TimelinePageTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
test "renders the timeline for signed-out visitors", %{conn: conn} do
{:ok, _view, html} = live(conn, "/aether")
assert html =~ "Aether"
assert html =~ "Sign in to publish notes from your local identity."
end
test "renders the posting form for signed-in users", %{signed_in_conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/aether")
assert has_element?(view, "#note-form")
assert has_element?(view, "#post-note-button", "Post note")
assert is_binary(user.username)
end
test "handles batched Parrhesia note events", %{signed_in_conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/aether")
send(view.pid, {:parrhesia, :events, make_ref(), "aether", [note_event(user.pubkey_hex)]})
assert render(view) =~ "Streamed batch note"
end
test "dispatches top-level subpaths to the timeline page", %{conn: conn} do
{:ok, _view, html} = live(conn, "/aether/tribe-123")
assert html =~ "Aether"
end
defp note_event(pubkey) do
%{
"id" => "batch-note-#{System.unique_integer([:positive])}",
"pubkey" => pubkey,
"created_at" => 1_777_237_381,
"kind" => 1,
"tags" => [],
"content" => "Streamed batch note",
"sig" => "test-signature"
}
end
end
-86
View File
@@ -1,86 +0,0 @@
defmodule MyPlugin.ContractTest do
@moduledoc """
Contract compliance tests.
These verify that the plugin conforms to the Tribes plugin contract.
When Tribes.Plugin.ContractTest is available (host dep loaded),
replace this file with:
defmodule MyPlugin.ContractTest do
use Tribes.Plugin.ContractTest,
plugin: MyPlugin.Plugin,
otp_app: :my_plugin
end
Until then, this file provides a standalone equivalent.
"""
use ExUnit.Case, async: true
@plugin MyPlugin.Plugin
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
setup_all do
manifest = @manifest_path |> File.read!() |> Jason.decode!()
spec = @plugin.register(%{pubsub: nil, repo: nil})
%{manifest: manifest, spec: spec}
end
test "runtime provides matches manifest provides", %{manifest: manifest, spec: spec} do
normalise = fn cap ->
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
end
manifest_caps = manifest["provides"] |> Enum.map(normalise) |> Enum.sort()
runtime_caps = spec.provides |> Enum.map(normalise) |> Enum.sort()
assert manifest_caps == runtime_caps,
"manifest provides #{inspect(manifest_caps)} != runtime provides #{inspect(runtime_caps)}"
end
test "runtime requires matches manifest requires", %{manifest: manifest, spec: spec} do
normalise = fn cap ->
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
end
manifest_caps = manifest["requires"] |> Enum.map(normalise) |> Enum.sort()
runtime_caps = spec.requires |> Enum.map(normalise) |> Enum.sort()
assert manifest_caps == runtime_caps,
"manifest requires #{inspect(manifest_caps)} != runtime requires #{inspect(runtime_caps)}"
end
test "spec has all required keys", %{spec: spec} do
required_keys = [
:name,
:version,
:provides,
:requires,
:nav_items,
:pages,
:children,
:global_js,
:global_css,
:migrations_path,
:hooks
]
for key <- required_keys do
assert Map.has_key?(spec, key), "spec must contain #{inspect(key)}"
end
end
test "name in spec matches manifest", %{manifest: manifest, spec: spec} do
assert spec.name == manifest["name"]
end
test "migrations_path exists if manifest declares migrations", %{manifest: manifest, spec: spec} do
if manifest["migrations"] do
assert is_binary(spec.migrations_path),
"migrations_path must be set when manifest declares migrations: true"
assert File.dir?(spec.migrations_path),
"migrations_path #{inspect(spec.migrations_path)} must be an existing directory"
end
end
end
-63
View File
@@ -1,63 +0,0 @@
defmodule MyPlugin.PluginTest do
use ExUnit.Case, async: true
describe "register/1" do
setup do
context = %{pubsub: nil, repo: nil}
%{spec: MyPlugin.Plugin.register(context)}
end
test "returns plugin name and version", %{spec: spec} do
assert spec.name == "my_plugin"
assert is_binary(spec.version)
end
test "provides is a list of capability strings", %{spec: spec} do
assert is_list(spec.provides)
for cap <- spec.provides do
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
end
end
test "requires is a list of capability strings", %{spec: spec} do
assert is_list(spec.requires)
for cap <- spec.requires do
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
end
end
test "nav items have required fields", %{spec: spec} do
assert is_list(spec.nav_items)
for item <- spec.nav_items do
assert is_binary(item.label), "nav item must have a label"
assert is_binary(item.path), "nav item must have a path"
assert is_integer(item.order), "nav item must have an integer order"
end
end
test "pages reference existing modules", %{spec: spec} do
assert is_list(spec.pages)
for page <- spec.pages do
assert is_binary(page.path), "page must have a path"
assert is_atom(page.live_view), "page must have a live_view module"
assert Code.ensure_loaded?(page.live_view), "module #{page.live_view} must be loadable"
end
end
test "children are valid child specs", %{spec: spec} do
assert is_list(spec.children)
end
test "asset lists are string lists", %{spec: spec} do
assert is_list(spec.global_js)
assert is_list(spec.global_css)
for js <- spec.global_js, do: assert(is_binary(js))
for css <- spec.global_css, do: assert(is_binary(css))
end
end
end
+9
View File
@@ -1 +1,10 @@
ExUnit.start()
{:ok, _apps} = Application.ensure_all_started(:tribes)
Ecto.Migrator.run(
Tribes.Repo,
Path.expand("../priv/repo/migrations", __DIR__),
:up,
all: true
)