14 Commits

Author SHA1 Message Date
self ab297d508d docs: Mark as deprecated, point to gen 2026-05-23 16:00:57 +02:00
self 0ba524bad9 fix: align plugin template workflow
Update the static plugin template to match the host-backed test/precommit wrapper generated by tribes-plugin-new.

Clarify dependency boundaries around host_api, ui@1, and raw Mix aliases.
2026-05-09 19:43:23 +02:00
self 36c40e3e25 fix: align template capability defaults 2026-05-09 17:11:41 +02:00
self 2e7d201866 chore: curate plugin usage rules
Sync AGENTS.md from a focused UsageRules config that includes Tribes and tribes_plugin_api guidance for plugin authors. Keep Ash deep rules linked to reduce context size and make scripts/plugin executable so the documented workflow works directly.
2026-05-07 10:08:35 +02:00
self c338e12170 feat: default plugin pages to host chrome
Wrap the generated LiveView with Tribes.Plugin.Layouts.app and mount optional host auth so new plugins render inside the replaceable Tribes chrome by default. Keep ui@1 in the manifest and document that host chrome, direct UI imports, and use Tribes.UI all require the UI provider capability.
2026-05-02 13:22:05 +02:00
self 493a2e0a65 docs: document ui capability requirement
Make the provider-backed UI facade contract explicit in template docs and agent guidance. Also ignore generated plugin vendor assets and local browser tooling state.
2026-05-02 01:46:25 +02:00
self 664ed5222f feat: harden plugin template scaffolding
Add AGENTS.md with usage_rules-generated guidance, host-aware smoke checks, TypeScript asset defaults, and plugin contract docs.

Split Tribes path dependencies for dev/test so templates compile entry-module beams for host loading while preserving runtime-backed tests, and update rename.sh for bridge modules and TS assets.
2026-05-01 23:40:06 +02:00
self fddd616772 feat(template): adopt host-backed plugin workflow
Add the shared plugin helper, host-backed test config, and runtime validation entrypoint. Update the example plugin route/docs and replace the old standalone test suite with the host-backed contract and page tests.
2026-04-27 17:00:16 +02:00
self 569f7e9785 build: Credo, devenv, precommit 2026-04-26 22:00:17 +02:00
self 22fd27da14 Document current AshNostrSync plugin defaults 2026-04-18 12:42:58 +02:00
self f6b767fbec Update plugin template for host-driven dev reload 2026-04-08 13:11:37 +02:00
self 41d35103dd Document tribes.migrate workflow in template README 2026-04-04 22:39:43 +02:00
self ed0d4f9c0d Adopt strict plugin entry module and otp_app conventions 2026-04-04 20:33:59 +02:00
self 54d9bdd99c template: make rename portable and add explicit otp_app dev flow 2026-04-04 19:45:32 +02:00
69 changed files with 594 additions and 5539 deletions
+7 -62
View File
@@ -2,66 +2,11 @@
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
eval "$(devenv direnvrc)"
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
# `use devenv` supports the same options as the `devenv shell` command.
#
# To silence all output, use `--quiet`.
#
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
use devenv
+12 -16
View File
@@ -5,11 +5,21 @@
/result
/result-*
# Devenv
.devenv*
devenv.local.nix
# Pre-commit
.pre-commit-config.yaml
# Node
/node_modules/
/assets/node_modules/
/priv/static/*
!/priv/static/.gitkeep
# Built plugin assets
/priv/static/*.css
/priv/static/*.js
/priv/static/vendor/
# Browser tooling
.playwright-mcp/
@@ -30,17 +40,3 @@ 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
@@ -1,34 +0,0 @@
# 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]
+36 -40
View File
@@ -1,33 +1,32 @@
# Tribes Aether Plugin Agent Guide
# Tribes Plugin Template Agent Guide
This repository is the Tribes Aether plugin. Keep changes scoped to the plugin
contract, migrations, assets, and tests owned by this repo.
This repository is a template for Tribes plugins. Preserve template placeholders
unless a task explicitly asks to rename the template.
## 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.
- Use `scripts/plugin` for plugin-aware commands: `scripts/plugin validate`, `scripts/plugin test`, `scripts/plugin precommit`, and `scripts/plugin smoke`.
- Use `devenv shell -- <command>` when installing or building Node assets from outside the devenv shell. Do not run raw `npm install` directly.
- Do not use raw `mix test` for host-backed plugin suites unless you are
deliberately recreating the `plugin test` host environment by hand; use
`mix raw_test` only for that low-level debugging path.
- Run `scripts/plugin smoke` after changing `mix.exs`, `manifest.json`, entry modules, or host-facing dependency setup.
- Keep commits semantic, for example `feat: add template smoke checks`, and include a body except for minimal patches.
## 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.
- `manifest.json` `entry_module` must point at `Tribes.Plugins.MyPlugin.Plugin` until the template is renamed.
- Keep the host bridge module at `lib/tribes/plugins/my_plugin/plugin.ex`; after renaming it should become `lib/tribes/plugins/<plugin>/plugin.ex`.
- The dev `:tribes` dependency is compile-only so host plugin loading can build `_build/dev` beams without starting a nested host app.
- The test `:tribes` dependency is runtime-enabled so host-backed tests can start `Tribes.Repo` and related services.
- If the plugin adds cluster-synced Ash resources, use `AshNostrSync` deliberately and document replication semantics in `docs/plugin-contract.md`.
## Frontend
- Default pages should render inside host chrome with `Tribes.Plugin.Layouts.app`; keep `ui@1` in `manifest.json` `requires` for host chrome or direct `Tribes.UI` usage.
- Prefer TypeScript in `assets/ts` for browser code. The default build emits browser-ready files to `priv/static`.
- Keep CSS selectors plugin-scoped, normally prefixed with `.my-plugin`, to avoid collisions with the host or other plugins.
- Use the package scripts in `assets/package.json`; do not bypass them with ad-hoc asset commands.
<!-- usage-rules-start -->
<!-- usage_rules-start -->
@@ -363,18 +362,20 @@ when writing scripts inside the template**:
}
</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 are automatically integrated into the app.js bundle
- 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:
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
window.TribesPluginHooks = window.TribesPluginHooks || {};
window.TribesPluginHooks.MyHook = {
const MyHook = {
mounted() { ... }
};
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
@@ -548,10 +549,8 @@ source directory, the same document is at `../docs/plugins.md`.
- 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`.
- Keep the host-facing entry module under `Tribes.Plugins.*.Plugin`. It may be a
thin delegate to the plugin application's own module.
- `register/1` must return a `Tribes.Plugin.Spec` struct or a map/struct that
validates into that spec.
@@ -620,9 +619,9 @@ checkout during development. For the full contract, see
- `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.
- `entry_module` must be under `Tribes.Plugins.*.Plugin` and end in `.Plugin`.
- Capability versions are discrete breaking-change markers such as `ui@1` or
`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
@@ -631,14 +630,11 @@ checkout during development. For the full contract, see
## 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
`Tribes.Plugin.Layouts.app` and keep `ui@1` in `manifest.json` `requires`.
- Consumers should target the `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.
+178 -127
View File
@@ -1,158 +1,209 @@
# Aether
# Tribes Plugin Template
Aether is an external Tribes plugin that adds local social posting and chat to a
Tribes node.
**DEPRECATED!** Use [the generator (`tribes-plugin-new`)](https://git.teralink.net/tribes/tribes-plugin-new) instead.
It provides:
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
- `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.
## Getting Started
## Features
1. Click **"Use this template"** on GitHub to create your own repo
2. Clone and rename:
### Local social feed
- `/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.
### Public and embeddable chat
- `/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.
### Direct messages
- 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.
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.
### Marmot scaffold
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`.
## Plugin contract and API surface
`manifest.json` declares:
```json
{
"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
}
```bash
git clone https://github.com/you/your-plugin.git
cd your-plugin
./scripts/rename.sh your_plugin YourPlugin
```
Runtime contributions from `TribeOne.TribesPlugin.Aether.Plugin` include:
3. Edit `manifest.json` — set description, capabilities, requirements
4. Implement your plugin in `lib/your_plugin/plugin.ex`
5. Run validation, smoke checks, and tests:
- 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.
```bash
mix deps.get
mix tribes.plugin.validate
scripts/plugin smoke
scripts/plugin test
```
The `org.tribe-one.caps.chat@1` provider surface is intentionally small and reusable:
- `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.
Backend modules implement `TribeOne.TribesPlugin.Aether.Chat.Backend`:
- `TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot`
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:
If you are using the shared `tribes` devenv, run through the local helper instead:
```bash
devenv shell -- plugin validate
devenv shell -- plugin smoke
devenv shell -- plugin test
devenv shell -- plugin precommit
```
Outside the devenv shell, use the local wrapper:
## Development
```bash
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
```
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:
For local development alongside a Tribes checkout:
```bash
# Symlink into the host plugins directory once
cd /path/to/tribes
ln -s /path/to/tribes-plugin-aether plugins/aether
ln -s /path/to/your-plugin plugins/your_plugin
# Start Tribes dev server
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.
Then edit the plugin in its own repo. In development, the host now watches
symlinked external plugins and automatically:
## Assets and hooks
- runs `mix compile` for Elixir/HEEx or manifest changes
- runs `npm run build --prefix assets` when `assets/package.json` is present
- reloads the plugin in the running Tribes VM
- triggers a Phoenix browser reload after the rebuild finishes
Plugin browser assets are declared in `manifest.json` and served by the host.
Aether currently uses:
That means the normal edit/compile loop is:
- `assets/js/aether.js` for LiveView hooks such as chat auto-scroll,
- `assets/css/aether.css` for plugin-scoped styles.
```bash
cd /path/to/your-plugin
mix deps.get
External plugin hooks are registered through `window.TribesPluginHooks`; Phoenix
colocated hooks from external plugin OTP apps are not auto-imported by the host.
# in another terminal
cd /path/to/tribes
iex --sname dev -S mix phx.server
```
## Runtime requirements
Inside the plugin repo's devenv shell, the `plugin` helper forwards to the host devenv automatically:
- 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.
```bash
plugin validate
plugin smoke
plugin test
plugin precommit
```
## Release packaging
If your plugin does not need a custom frontend pipeline, you can skip
`assets/package.json` and write browser-ready files directly under `assets/js`
and `assets/css`; the host dev watcher will copy them into `priv/static` for
you in development. The default template uses TypeScript under `assets/ts`.
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.
## Project Structure
```
your_plugin/
├── manifest.json # Plugin metadata (Nix build + runtime)
├── mix.exs # Dependencies
├── config/ # Host-backed test config
├── lib/
│ ├── your_plugin/
│ │ ├── plugin.ex # Tribes.Plugin entry point
│ │ └── application.ex # OTP supervision tree (optional)
│ └── your_plugin_web/
│ └── live/ # LiveView pages
├── assets/ # TS/CSS (one bundle per plugin)
│ ├── ts/ # TypeScript browser entry points
│ ├── package.json # Optional build script used by dev + Guix packaging
│ ├── tsconfig.json # TypeScript compiler settings
│ └── package-lock.json # Optional, recommended for reproducible builds
├── priv/
│ ├── static/ # Built assets for release
│ └── repo/migrations/ # Ecto migrations
├── scripts/
│ ├── plugin # Shared devenv/non-devenv test wrapper
│ └── rename.sh
└── test/
```
## Manifest
`manifest.json` declares your plugin's identity and capabilities:
```json
{
"name": "your_plugin",
"entry_module": "Tribes.Plugins.YourPlugin.Plugin",
"host_api": "1",
"otp_app": "your_plugin",
"provides": ["some_capability@1"],
"requires": ["ui@1"],
"enhances_with": ["inference@1"]
}
```
- **entry_module** — must be `Tribes.Plugins.*.Plugin`
- **otp_app** — required and must match `name`
- **provides** — capabilities this plugin makes available
- **requires** — hard plugin/API contracts beyond the `host_api` foundation
(build fails without them)
- **enhances_with** — optional dependencies (plugin degrades gracefully)
The default template includes `ui@1` because the generated LiveView renders
inside the host chrome through `Tribes.Plugin.Layouts.app`. Keep `ui@1` when a
plugin uses host chrome, imports `Tribes.UI.Components`, or uses `use Tribes.UI`:
```json
"requires": ["ui@1"]
```
`host_api` is the versioned foundation contract. It provides the supported
Phoenix, Ash, PubSub, data, and cluster-event APIs for plugins. Do not add
separate framework capability requirements for APIs that belong to that
foundation.
See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification.
The default template assumes the plugin owns a top-level page such as
`/your_plugin` and any views beneath it. Render normal plugin pages with
`Tribes.Plugin.Layouts.app` so they live inside the Tribes chrome/navigation;
only omit it for intentionally unwrapped fullscreen surfaces.
## Plugin Data
If your plugin adds Ash resources that should replicate cluster-wide, use
`extensions: [AshNostrSync]` on those resources and follow the host defaults:
- persisted attributes sync by default unless explicitly excluded
- synced resources should use one UUID primary key by default
- `AshNostrSync` requires exactly one primary key; composite keys are not supported
- synced resources get extension-managed `deleted_at` soft-delete support by default
- destroy actions can use `soft_delete()` when they should tombstone rows
- `soft_delete?(false)` is the opt-out if a synced resource should not use the default tombstone model
Keep resource-specific side effects explicit in actions. `AshNostrSync` owns the
default tombstone/filter/projection behavior, but your plugin still decides which
actions are semantic deletes and what else they should do.
## Testing
The template starts with two host-backed test layers:
- **Contract tests** (`test/your_plugin/plugin_contract_test.exs`) — manifest and runtime spec stay aligned
- **Page tests** (`test/your_plugin/home_page_test.exs`) — the plugin renders through the real host page pipeline
Run them with:
```bash
mix tribes.plugin.validate
scripts/plugin smoke
scripts/plugin test
```
Use `plugin test` / `plugin precommit` for host-backed tests. The helper invokes
Mix with the host database/services and host plugin-manager paths, including
built-in providers such as `tribes_ui`. Raw `mix test` in a plugin checkout is
guarded and prints this guidance; `mix raw_test` / `mix raw_precommit` are
available only for unusual manual debugging when you have set the same host
environment yourself.
## Building for Release
```bash
MIX_ENV=prod mix compile
devenv shell -- npm run build --prefix assets
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/
```
For Guix-based deployment, package your plugin in the `guix-tribes` channel and
enable it from the node config.
## Licence
TODO: Choose a licence.
-1
View File
@@ -1 +0,0 @@
min-release-age=7
@@ -1,17 +1,13 @@
/*
* Plugin CSS entry point.
*
* Served at /plugins-assets/aether/aether.css
* Served at /plugins-assets/my_plugin/my_plugin.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.
*/
.aether {
.my-plugin {
/* Plugin-scoped styles go here */
}
.aether-chat-message {
scroll-margin-block-end: 1rem;
}
-28
View File
@@ -1,28 +0,0 @@
// 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;
},
};
+14 -460
View File
@@ -1,475 +1,29 @@
{
"name": "aether-assets",
"name": "my-plugin-assets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aether-assets",
"name": "my-plugin-assets",
"version": "0.1.0",
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
"devDependencies": {
"typescript": "^6.0.3"
}
},
"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==",
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"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"
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"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
}
"engines": {
"node": ">=14.17"
}
},
"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"
}
}
}
+5 -4
View File
@@ -1,11 +1,12 @@
{
"name": "aether-assets",
"name": "my-plugin-assets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
"build": "npm run check && mkdir -p ../priv/static && cp -r css/. ../priv/static && tsc --project tsconfig.json",
"check": "tsc --project tsconfig.json --noEmit"
},
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
"devDependencies": {
"typescript": "^6.0.3"
}
}
+17
View File
@@ -0,0 +1,17 @@
// Plugin TypeScript entry point.
//
// This file is compiled to /priv/static/my_plugin.js, served by the host at
// /plugins-assets/my_plugin/my_plugin.js, and included when declared in
// manifest.json assets.global_js.
declare global {
interface Window {
TribesPluginHooks?: Record<string, unknown>;
}
}
window.TribesPluginHooks = window.TribesPluginHooks ?? {};
console.info("my_plugin loaded");
export {};
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"outDir": "../priv/static",
"rootDir": "ts"
},
"include": ["ts/**/*.ts"]
}
-2
View File
@@ -1,5 +1,3 @@
import Config
config :tribe_one_aether, ash_domains: [TribeOne.TribesPlugin.Aether.Chat]
import_config "#{config_env()}.exs"
-4
View File
@@ -1,5 +1 @@
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
+15 -34
View File
@@ -3,11 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779566536,
"narHash": "sha256-KlMZMcgyU0BFUW2JCrCb9IHJMgCdG0eyhukpu9pwXLs=",
"lastModified": 1777109791,
"owner": "cachix",
"repo": "devenv",
"rev": "6715fab7a826d108278fbfd6ef35a61f49ab7ac9",
"rev": "025b6ba9903b96a55ac21a9a63fa290a6da5afe6",
"type": "github"
},
"original": {
@@ -21,7 +20,6 @@
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
@@ -42,11 +40,10 @@
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"lastModified": 1776796298,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
"type": "github"
},
"original": {
@@ -63,11 +60,10 @@
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"lastModified": 1762808025,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github"
},
"original": {
@@ -81,11 +77,10 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"lastModified": 1776852779,
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"rev": "ec3063523dcd911aeadb50faa589f237cdab5853",
"type": "github"
},
"original": {
@@ -98,11 +93,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"lastModified": 1776329215,
"narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"rev": "b86751bc4085f48661017fa226dee99fab6c651b",
"type": "github"
},
"original": {
@@ -112,31 +107,17 @@
"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"
"pre-commit-hooks": [
"git-hooks"
]
}
}
},
"root": "root",
"version": 7
}
}
+8 -58
View File
@@ -1,69 +1,23 @@
{
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";
};
}: {
env = {
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
NODE_ENV = "development";
};
# 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 {})
prettier
]
++
# Linux only
lib.optionals pkgs.stdenv.isLinux [
# for ExUnit notifier
libnotify
# for package - file_system
++ lib.optionals pkgs.stdenv.isLinux [
inotify-tools
];
# https://devenv.sh/tests/
# enterTest = ''
# echo "Running tests"
# git --version | grep "2.42.0"
# '';
# https://devenv.sh/languages/
languages = {
elixir = {
enable = true;
@@ -77,10 +31,9 @@ in {
};
};
# dotenv.enable = true;
dotenv.enable = true;
devenv.warnOnNewVersion = false;
# https://devenv.sh/pre-commit-hooks/
git-hooks.hooks = {
alejandra.enable = true;
prettier.enable = true;
@@ -93,7 +46,6 @@ in {
mix-format.files = "\\.(ex|exs|heex)$";
};
# https://devenv.sh/scripts/
enterShell = ''
echo
elixir --version
@@ -105,6 +57,4 @@ in {
scripts = {
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
};
# See full reference at https://devenv.sh/reference/options/
}
+3 -14
View File
@@ -2,21 +2,10 @@
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
# If you have more than one devenv you can merge them.
# imports:
# - ./backend
+19
View File
@@ -0,0 +1,19 @@
# Checklist
## After Renaming
- Run `./scripts/rename.sh your_plugin YourPlugin`.
- Confirm `manifest.json` `name`, `otp_app`, and `entry_module`.
- Confirm the bridge module path is `lib/tribes/plugins/your_plugin/plugin.ex`.
- Rename asset files and update manifest asset names if needed.
- Run `mix deps.get`.
- Run `scripts/plugin smoke`.
- Run `scripts/plugin test`.
## Before Committing
- Run `mix format`.
- Run `scripts/plugin precommit`.
- If assets changed, run `devenv shell -- npm run build --prefix assets`.
- Check `git status --short` for generated files that should not be committed.
- Commit with a semantic subject and a useful body unless the patch is minimal.
+53
View File
@@ -0,0 +1,53 @@
# Development
## Local Setup
This template expects a sibling Tribes checkout at `../tribes`.
```bash
mix deps.get
devenv shell -- plugin test
```
To load the plugin in the host during development:
```bash
cd ../tribes
ln -s ../tribes-plugin-template plugins/my_plugin
iex --sname dev -S mix phx.server
```
Set `TRIBES_HOST_ROOT=/path/to/tribes` if your host checkout is not a sibling.
## Command Wrapper
Use `scripts/plugin` for host-aware workflows:
```bash
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
scripts/plugin smoke
```
Inside the template devenv shell, the `plugin` command is available and forwards
through the same wrapper.
Prefer `plugin test` over raw `mix test` for host-backed plugin suites. The
wrapper runs Mix with the host database/services and points the host plugin
manager at the host manifest plus built-in providers such as `tribes_ui`. Use
`mix raw_test` / `mix raw_precommit` only for deliberate low-level debugging.
## Assets
Browser code lives in `assets/ts` and is compiled to `priv/static`:
```bash
devenv shell -- npm run build --prefix assets
```
Use the wrapper form for installs too:
```bash
devenv shell -- npm install --prefix assets
```
-144
View File
@@ -1,144 +0,0 @@
# 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
```
+62
View File
@@ -0,0 +1,62 @@
# Plugin Contract
## Manifest
`manifest.json` is the runtime contract consumed by Tribes:
- `name` and `otp_app` must match the Mix application name.
- `entry_module` must be a loadable `Tribes.Plugins.*.Plugin` module.
- `provides` declares capabilities exported by the plugin.
- `requires` declares hard plugin/API contracts beyond the `host_api` foundation.
- `enhances_with` declares optional host capabilities.
- `migrations` should be `true` when `priv/repo/migrations` contains plugin migrations.
- `children` should be `true` when the plugin starts its own supervision tree.
Declare `ui@1` in `requires` when rendering through `Tribes.Plugin.Layouts.app`,
importing `Tribes.UI.Components`, or using `use Tribes.UI`. The facade is
intentionally provider-backed, so host chrome and UI consumers must make the
runtime dependency explicit.
`host_api` is the versioned foundation contract. It provides the supported
Phoenix, Ash, PubSub, data, and cluster-event APIs for plugins. Do not add
separate framework capability requirements for APIs that belong to that
foundation.
Default LiveView pages should wrap their content in `Tribes.Plugin.Layouts.app`
and use `on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})`. This
keeps plugin pages inside the replaceable Tribes chrome while avoiding a hard
compile-time dependency on host web macros.
## Entry Modules
The template uses a thin host bridge:
```elixir
defmodule Tribes.Plugins.MyPlugin.Plugin do
defdelegate register(context), to: MyPlugin.Plugin
end
```
Keep plugin implementation code in `MyPlugin.Plugin`. Keep the bridge stable so
the host plugin manager can load it from the plugin build output.
## Host Dependencies
`tribes_plugin_api` is the release-facing `host_api` foundation. It carries the
supported plugin behaviours, helpers, Phoenix/Ash data surface, and sync DSL.
Do not add the full `:tribes` app as a production dependency.
The template intentionally splits the `:tribes` path dependency by environment:
- In `:dev`, `:tribes` is compile-only. This lets `mix compile` create the
entry-module beam expected by the host plugin manager without starting a
nested Tribes application.
- In `:test`, `:tribes` is runtime-enabled. Host-backed tests need the real
repository, endpoint pipeline, and plugin contract helpers.
## Ash Resources
For cluster-synced plugin data, add Ash resources under the plugin namespace and
use `extensions: [AshNostrSync]` only for data that should replicate across the
cluster. Prefer one UUID primary key per synced resource. Document any local-only
tables, retention policies, or side effects in plugin docs.
-24
View File
@@ -1,24 +0,0 @@
(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")))))
-26
View File
@@ -1,26 +0,0 @@
#!/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" "$@"
-352
View File
@@ -1,352 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,41 +0,0 @@
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
@@ -1,169 +0,0 @@
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
@@ -1,294 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,180 +0,0 @@
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
@@ -1,139 +0,0 @@
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
@@ -1,157 +0,0 @@
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
@@ -1,127 +0,0 @@
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
@@ -1,111 +0,0 @@
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
@@ -1,186 +0,0 @@
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
@@ -1,294 +0,0 @@
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
@@ -1,139 +0,0 @@
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
-30
View File
@@ -1,30 +0,0 @@
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/aether.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
# use TribesWeb, :live_view
#
# For release builds (standalone OTP app), use Phoenix.LiveView directly:
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "My Plugin")}
end
def render(assigns) do
~H"""
<div class="p-6">
<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/aether_web/live/home_live.ex</code>.
</p>
</div>
"""
end
end
-339
View File
@@ -1,339 +0,0 @@
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
@@ -1,4 +1,4 @@
defmodule TribeOne.TribesPlugin.Aether.Application do
defmodule MyPlugin.Application do
@moduledoc """
OTP Application for this plugin.
@@ -12,10 +12,10 @@ defmodule TribeOne.TribesPlugin.Aether.Application do
def start(_type, _args) do
children = [
# Add your supervised processes here, e.g.:
# {TribeOne.TribesPlugin.Aether.Worker, []}
# {MyPlugin.Worker, []}
]
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Aether.Supervisor]
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
Supervisor.start_link(children, opts)
end
end
+33
View File
@@ -0,0 +1,33 @@
defmodule MyPlugin.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :my_plugin
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "My Plugin",
path: "/my_plugin",
icon: nil,
requires: [],
order: 50
}
],
pages: [
%{
path: "/my_plugin",
live_view: MyPluginWeb.HomeLive,
layout: nil
}
],
api_routes: [],
plugs: [],
hooks: %{}
})
end
end
+36
View File
@@ -0,0 +1,36 @@
defmodule MyPluginWeb.HomeLive do
@moduledoc """
Example LiveView page for the plugin.
This page is registered in the plugin spec, mounted by the host at
/my_plugin, and rendered inside the Tribes chrome by default.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
# use TribesWeb, :live_view
#
# For release builds (standalone OTP app), use Phoenix.LiveView directly:
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Tribes.Plugin.Layouts
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "My Plugin")}
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="my-plugin p-6">
<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>.
</p>
</div>
</Layouts.app>
"""
end
end
+5
View File
@@ -0,0 +1,5 @@
defmodule Tribes.Plugins.MyPlugin.Plugin do
@moduledoc false
defdelegate register(context), to: MyPlugin.Plugin
end
+10 -21
View File
@@ -1,28 +1,17 @@
{
"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",
"name": "my_plugin",
"version": "0.1.0",
"description": "TODO: Describe what this plugin does",
"entry_module": "Tribes.Plugins.MyPlugin.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"
],
"otp_app": "my_plugin",
"provides": [],
"requires": ["ui@1"],
"enhances_with": [],
"assets": {
"global_js": [
"aether.js"
],
"global_css": [
"aether.css"
]
"global_js": ["my_plugin.js"],
"global_css": ["my_plugin.css"]
},
"migrations": true,
"migrations": false,
"children": false
}
-77
View File
@@ -1,77 +0,0 @@
;; 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))
+6 -6
View File
@@ -1,10 +1,10 @@
defmodule TribeOne.TribesPlugin.Aether.MixProject do
defmodule MyPlugin.MixProject do
use Mix.Project
def project do
[
app: :tribe_one_aether,
version: "0.2.0",
app: :my_plugin,
version: "0.1.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -24,7 +24,7 @@ defmodule TribeOne.TribesPlugin.Aether.MixProject do
[
extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree:
# mod: {TribeOne.TribesPlugin.Aether.Application, []}
# mod: {MyPlugin.Application, []}
]
end
@@ -40,12 +40,12 @@ defmodule TribeOne.TribesPlugin.Aether.MixProject do
{: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"}
{:phoenix_live_view, "~> 1.1.0"},
{:usage_rules, "~> 1.2", only: :dev}
] ++ tribes_deps(Mix.env())
end
+28 -28
View File
@@ -1,34 +1,34 @@
%{
"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": {:hex, :absinthe, "1.10.0", "58e4923c2a96bf12cba9aad9298f36f624533039f2e5305badf0d60904eee0a0", [: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", "6810d21f13a2b4783d1b7f468c63a5174d769a3035f188e3fc93f440128a46af"},
"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": {:hex, :ash, "3.24.3", "f7280a43c5e64f769a450f3dd59ace6dcd73edcdd0de7599815b1b31f59292fb", [: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", "c1022f8c549632137cbc8956f07bb4981405297f5abe7a752b4dffac175c3381"},
"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"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [: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", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
"ash_graphql": {:hex, :ash_graphql, "1.9.4", "3e75eaa0917e1085c938aadfec8205cb028a161842d13b9304475f56a8b1c6a0", [: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", "da3a7b3026ca31b006bf675f6dcbe49c4575c42cad794684373fc7a51893165b"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.21", "757e227e4d835b748a31932305e307e2db3bd3229c219ddad0277f6598d3e392", [: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", "4e28b92709e86856927c7c32effa9336f0aa3daec769883993bf42ba49cce90f"},
"ash_postgres": {:hex, :ash_postgres, "2.9.0", "5867a8f91f136add9b11fb44c22f7636538ed29ff8601da80470cfe792b81cf2", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [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", "81f284b7550b9b9cd19bf2a68abc222066a8e6e3c4e88143c9c39ede37df6b42"},
"ash_sql": {:hex, :ash_sql, "0.6.1", "d2788d020a5fe7b1052cdf529f97937979634e912baaea957b750b1f8f38ba52", [:mix], [{:ash, "~> 3.24", [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", "16efb0a5551269faafb32f173c144fbf85cce1576206005f6dbf244b6b403969"},
"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"},
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [: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", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"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"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"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": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [: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", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"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"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"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"},
"db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"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": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.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", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"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"},
@@ -40,53 +40,53 @@
"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"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [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", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"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"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"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"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [: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", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"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"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"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"},
"parrhesia": {:hex, :parrhesia, "0.12.0", "67f33b62a6d7d32ce8d801e3dd09f3e8f46d82a21e9b15dfda6a161d2c7f7e09", [: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", "e91f757972746c73f888706cb084ba745ebf1a602a0f2f6a7c380555c6066cc3"},
"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_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [: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", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
"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": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"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"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"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"},
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [: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", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [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", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"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"},
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
"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"},
"swoosh": {:hex, :swoosh, "1.25.0", "d60dcba6d1ce538b1994f8712a3d55bc9519ffba4654cc4665a75683881d11dd", [: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", "c59db3d838b595b95954a3d0a13782e56881cecfe7ba7b793b1a1a6775273a6e"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"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"},
-30
View File
@@ -1,30 +0,0 @@
--- 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
@@ -1,49 +0,0 @@
{
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;
};
}
@@ -1,143 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,66 +0,0 @@
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
@@ -1,231 +0,0 @@
{
"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"
}
@@ -1,231 +0,0 @@
{
"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"
}
@@ -1,209 +0,0 @@
{
"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"
}
@@ -1,7 +0,0 @@
{
"ash_functions_version": 5,
"installed": [
"ash-functions",
"citext"
]
}
-38
View File
@@ -1,38 +0,0 @@
#!/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"
+1 -3
View File
@@ -7,8 +7,6 @@ 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
@@ -79,7 +77,7 @@ fi
shift
case "$command_name" in
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
validate | test | precommit | smoke | shell) ;;
-h | --help | help)
usage
exit 0
+7 -4
View File
@@ -44,6 +44,9 @@ fi
if [ -d "test/my_plugin" ]; then
mv "test/my_plugin" "test/$SNAKE"
fi
if [ -d "lib/tribes/plugins/my_plugin" ]; then
mv "lib/tribes/plugins/my_plugin" "lib/tribes/plugins/$SNAKE"
fi
# Replace in all text files (portable across GNU/BSD sed)
sed_in_place() {
@@ -67,13 +70,13 @@ sed_in_place() {
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' \) \
find . \( -name ".devenv" -o -name ".git" -o -name "deps" -o -name "node_modules" \) -prune -o -type f \
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.ts' -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
for ext in js ts css; do
if [ -f "assets/$ext/my_plugin.$ext" ]; then
mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext"
fi
@@ -84,4 +87,4 @@ done
echo "Done. Review the changes, then:"
echo " 1. Edit manifest.json — set description, capabilities"
echo " 2. Run: mix deps.get && mix test"
echo " 2. Run: mix deps.get && scripts/plugin smoke && scripts/plugin test"
-244
View File
@@ -1,244 +0,0 @@
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
@@ -1,74 +0,0 @@
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
@@ -1,90 +0,0 @@
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
-82
View File
@@ -1,82 +0,0 @@
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 = JSON.decode!(content)
%{manifest: manifest}
end
describe "manifest.json" do
test "is valid JSON", %{manifest: manifest} do
assert is_map(manifest)
end
test "has required fields", %{manifest: manifest} do
required = [
"id",
"slug",
"display_name",
"version",
"entry_module",
"host_api",
"otp_app",
"provides",
"requires"
]
for field <- required do
assert Map.has_key?(manifest, field),
"manifest.json must contain #{inspect(field)}"
end
end
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 uses the TribeOne namespace and Plugin suffix", %{manifest: manifest} do
module_name = manifest["entry_module"]
assert is_binary(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?(@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?(@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"
end
end
end
-11
View File
@@ -1,11 +0,0 @@
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
@@ -1,3 +0,0 @@
defmodule TribeOne.TribesPlugin.Aether.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Aether.Plugin
end
-44
View File
@@ -1,44 +0,0 @@
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
+17
View File
@@ -0,0 +1,17 @@
defmodule MyPlugin.HomePageTest do
use Tribes.PluginTest.PageCase, plugin: Tribes.Plugins.MyPlugin.Plugin
test "renders the plugin home page for signed-out visitors", %{conn: conn} do
{:ok, view, html} = live(conn, "/my_plugin")
assert html =~ "My Plugin"
assert html =~ "This is a Tribes plugin."
assert has_element?(view, "#plugin-nav-my-plugin", "My Plugin")
end
test "dispatches top-level subpaths to the plugin page", %{conn: conn} do
{:ok, _view, html} = live(conn, "/my_plugin/example")
assert html =~ "My Plugin"
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule MyPlugin.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: Tribes.Plugins.MyPlugin.Plugin
end
-9
View File
@@ -1,10 +1 @@
ExUnit.start()
{:ok, _apps} = Application.ensure_all_started(:tribes)
Ecto.Migrator.run(
Tribes.Repo,
Path.expand("../priv/repo/migrations", __DIR__),
:up,
all: true
)