feat: add trust plugin foundation
CI / Test (push) Failing after 20s

Introduce the Trust plugin as the federation provider for tribe identity and hello handshakes, with synced Ash resources for remote tribes and tribe relationships plus an admin LiveView for trust management.
This commit is contained in:
2026-05-27 22:49:44 +02:00
commit 40310b26ea
45 changed files with 3720 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
%{
configs: [
%{
name: "default",
files: %{
included: ["lib/", "src/", "test/"],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
plugins: [],
requires: [],
strict: false,
parse_timeout: 5000,
color: true,
checks: [
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
{Credo.Check.Design.AliasUsage, false},
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagFIXME, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Refactor.CyclomaticComplexity, false},
{Credo.Check.Refactor.FunctionArity, [max_arity: 13]},
{Credo.Check.Refactor.LongQuoteBlocks, false},
{Credo.Check.Refactor.MapInto, false},
{Credo.Check.Refactor.Nesting, [max_nesting: 7]},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.LazyLogging, false},
{Credo.Check.Warning.MixEnv, false},
{Credo.Check.Warning.UnsafeExec, []}
]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
use devenv
+3
View File
@@ -0,0 +1,3 @@
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
+71
View File
@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MIX_ENV: test
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "27"
- name: Cache deps
uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
run: mix deps.get
- name: Compile (warnings as errors)
run: mix compile --warnings-as-errors
- name: Check formatting
run: mix format --check-formatted
- name: Run tests
run: scripts/plugin test
- name: Validate manifest
run: |
elixir -e '
manifest = "manifest.json" |> File.read!() |> JSON.decode!()
required = ["id", "slug", "version", "entry_module", "host_api", "provides", "requires"]
missing = Enum.filter(required, &(not Map.has_key?(manifest, &1)))
if missing != [] do
IO.puts(:stderr, "Missing manifest fields: #{inspect(missing)}")
System.halt(1)
end
IO.puts("Plugin: #{manifest["id"]} (#{manifest["slug"]}) v#{manifest["version"]}")
'
+42
View File
@@ -0,0 +1,42 @@
# Dependencies and build
/deps/
/_build/
/dist/
/result
/result-*
# Devenv
.devenv*
devenv.local.nix
# Pre-commit
.pre-commit-config.yaml
# Node
/node_modules/
/assets/node_modules/
# Built plugin assets
/priv/static/*.css
/priv/static/*.js
/priv/static/vendor/
# Browser tooling
.playwright-mcp/
# Generated on crash
erl_crash.dump
# Editor and OS
.elixir_ls/
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Env files
.env
.env.local
+28
View File
@@ -0,0 +1,28 @@
# Tribes Plugin Agent Guide
This repository is a Tribes plugin. Preserve generated names unless a task explicitly asks to rename the plugin.
## Required Workflow
- Use `plugin` for plugin-aware commands in the devenv shell: `plugin validate`, `plugin test`, `plugin precommit`, and `plugin smoke`. Outside the devenv shell, use the local `scripts/plugin` wrapper.
- 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.
- 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.
- Run `scripts/plugin smoke` after changing `mix.exs`, `manifest.json`, entry modules, or host-facing dependency setup.
- Keep commits semantic, for example `feat: add plugin smoke checks`, and include a body except for minimal patches.
## Plugin Contract
- `manifest.json` `entry_module` must point at the plugin entry module.
- 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 `org.tribe-one.caps.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`.
- Register plugin LiveView hooks from `manifest.json` `assets.global_js` bundles via `window.TribesPluginHooks`. The host does not auto-import external plugin `phoenix-colocated/<otp_app>` modules into its `app.js` bundle.
- Keep CSS selectors plugin-scoped 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.
+208
View File
@@ -0,0 +1,208 @@
# Trust
Tribe-to-tribe alliances and trust
## Getting Started
1. Edit `manifest.json` - set description, capabilities, requirements
2. Implement your plugin in `lib/trust/plugin.ex`
3. Run validation, smoke checks, and tests:
```bash
mix deps.get
mix tribes.plugin.validate
scripts/plugin smoke
scripts/plugin test
```
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
```
Generate AshPostgres resource migrations through the same wrapper so they
target the host `Tribes.Repo` and the plugin migration directory:
```bash
plugin ash.codegen update_example_resources
```
Manual Ecto migrations are the rare exception for schema work not derived
from Ash resources:
```bash
plugin ecto.migration add_example_table
```
## Development
For local development alongside a Tribes checkout:
```bash
# Symlink into the host plugins directory once
cd ../tribes
ln -s ../tribes-plugin-trust plugins/trust
# Start Tribes dev server
iex --sname dev -S mix phx.server
```
Then edit the plugin in its own repo. In development, the host watches
symlinked external plugins and automatically:
- runs `mix compile` for Elixir/HEEx or manifest changes
- runs `npm run build --prefix assets` when `assets/package.json` is present and plugin asset files change
- reloads the plugin in the running Tribes VM
- triggers a Phoenix browser reload after the rebuild finishes
Browser hooks for external plugins should live in plugin JS assets declared
in `manifest.json` `assets.global_js` and register themselves through
`window.TribesPluginHooks`. Phoenix colocated hooks are not auto-imported
from external plugin OTP apps by the host `app.js` bundle.
That means the normal edit/compile loop is:
```bash
cd /path/to/tribes-plugin-trust
mix deps.get
# in another terminal
cd /path/to/tribes
iex --sname dev -S mix phx.server
```
Inside the plugin repo's devenv shell, the `plugin` helper forwards to the host devenv automatically:
```bash
plugin validate
plugin smoke
plugin test
plugin precommit
```
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 generator uses TypeScript under `assets/ts`.
## LiveView Hooks
External plugins do not participate in the host asset bundle, so the host
cannot statically import arbitrary plugin `phoenix-colocated/<otp_app>`
modules. Register browser hooks from your plugin JS instead:
```typescript
window.TribesPluginHooks = window.TribesPluginHooks ?? {};
window.TribesPluginHooks.TribeOneTribesPluginTrustExample = {
mounted() {
console.info("trust hook mounted");
}
};
```
Keep the compiled JS file listed in `manifest.json` `assets.global_js` so it
loads before the host LiveSocket connects.
## Project Structure
```
trust/
|-- manifest.json # Plugin metadata (Nix build + runtime)
|-- mix.exs # Dependencies
|-- config/ # Host-backed test config
|-- lib/
| |-- trust/
| | |-- plugin.ex # Tribes.Plugin entry point
| | `-- application.ex # OTP supervision tree (optional)
| `-- trust_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
`-- test/
```
## Manifest
`manifest.json` declares your plugin's identity and capabilities:
```json
{
"id": "org.tribe-one.plugins.trust",
"slug": "trust",
"display_name": "Trust",
"entry_module": "TribeOne.TribesPlugin.Trust.Plugin",
"host_api": "1",
"otp_app": "tribe_one_trust",
"provides": ["org.tribe-one.caps.some_capability@1"],
"requires": ["org.tribe-one.caps.ui@1"],
"enhances_with": ["org.tribe-one.caps.inference@1"]
}
```
- **entry_module** - must be a valid module ending in `.Plugin`
- **otp_app** - required OTP application name; vendor-prefix it to avoid collisions
- **provides** - capabilities this plugin makes available
- **requires** - hard plugin/API contracts (build fails without them)
- **enhances_with** - optional dependencies (plugin degrades gracefully)
The default generator includes `org.tribe-one.caps.ui@1` because the generated LiveView renders
inside the host chrome through `Tribes.Plugin.Layouts.app`. Keep `org.tribe-one.caps.ui@1` when a
plugin uses host chrome, imports `Tribes.UI.Components`, or uses `use Tribes.UI`.
## Plugin Data
If your plugin adds Ash resources that should replicate cluster-wide, use
`extensions: [AshNostrSync]` on those resources and assign a sync lane per
resource. The host default lane is `bulk`; latency-sensitive topology or
orchestration resources should opt into `control`. Keep resource-specific
side effects explicit in actions.
## Testing
The generated plugin starts with two host-backed test layers:
- **Contract tests** (`test/trust/plugin_contract_test.exs`) - manifest and runtime spec stay aligned
- **Page tests** (`test/trust/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/trust
cp -r _build/prod/lib/tribe_one_trust/ebin dist/trust/
cp -r priv dist/trust/
cp manifest.json dist/trust/
```
## Licence
TODO: Choose a licence.
+1
View File
@@ -0,0 +1 @@
min-release-age=7
+13
View File
@@ -0,0 +1,13 @@
/*
* Plugin CSS entry point.
*
* Served at /plugins-assets/trust/trust.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.
*/
.trust {
/* Plugin-scoped styles go here */
}
+514
View File
@@ -0,0 +1,514 @@
{
"name": "trust-assets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trust-assets",
"version": "0.1.0",
"devDependencies": {
"esbuild": "^0.28.0",
"typescript": "^6.0.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"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",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "trust-assets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "npm run check && mkdir -p ../priv/static && cp -r css/. ../priv/static && esbuild ts/trust.ts --bundle --target=es2022 --format=iife --outfile=../priv/static/trust.js",
"check": "tsc --project tsconfig.json --noEmit"
},
"devDependencies": {
"esbuild": "^0.28.0",
"typescript": "^6.0.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
// Plugin TypeScript entry point.
//
// This file is compiled to /priv/static/trust.js, served by the host at
// /plugins-assets/trust/trust.js, and included when 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.
declare global {
interface Window {
TribesPluginHooks?: Record<string, unknown>;
}
}
window.TribesPluginHooks = window.TribesPluginHooks ?? {};
// Example:
// window.TribesPluginHooks.TribeOneTribesPluginTrustExample = {
// mounted() {
// console.info("trust hook mounted");
// },
// };
console.info("trust 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"]
}
+5
View File
@@ -0,0 +1,5 @@
import Config
config :tribe_one_trust, ash_domains: [TribeOne.TribesPlugin.Trust.Domain]
import_config "#{config_env()}.exs"
+5
View File
@@ -0,0 +1,5 @@
import Config
# The host is a compile-only dependency in dev. Suppress host Ash domain
# config inclusion warnings while compiling host modules as a dependency.
config :ash, :validate_domain_config_inclusion?, false
+1
View File
@@ -0,0 +1 @@
import Config
+3
View File
@@ -0,0 +1,3 @@
import Config
import_config "../../tribes/config/config.exs"
+125
View File
@@ -0,0 +1,125 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779899848,
"narHash": "sha256-Rch5lM6yNoKUjsPd0jb4db3XCm0Q5PZ+eNsgnYKOlBQ=",
"owner": "cachix",
"repo": "devenv",
"rev": "7c4c690d5aa3cb94a9cb7bd7172f8739c408a343",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
pkgs,
lib,
...
}: {
env = {
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
NODE_ENV = "development";
# Delay npm dependency resolution to reduce rushed supply-chain updates.
NPM_CONFIG_MIN_RELEASE_AGE = "7";
};
packages = with pkgs;
[
git
alejandra
prettier
]
++ lib.optionals pkgs.stdenv.isLinux [
inotify-tools
];
languages = {
elixir = {
enable = true;
package = pkgs.elixir_1_19;
};
javascript = {
enable = true;
package = pkgs.nodejs_24;
npm.enable = true;
};
};
dotenv.enable = true;
devenv.warnOnNewVersion = false;
git-hooks.hooks = {
alejandra.enable = true;
prettier.enable = true;
prettier.files = "\\.(js|ts|tsx|css)$";
check-added-large-files = {
enable = true;
args = ["--maxkb=131072"];
};
mix-format.enable = true;
mix-format.files = "\\.(ex|exs|heex)$";
};
enterShell = ''
echo
elixir --version
echo -n "Node.js "
node --version
echo
'';
scripts = {
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
};
}
+9
View File
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs
+17
View File
@@ -0,0 +1,17 @@
# Checklist
## After Generation
- Confirm `manifest.json` `id`, `slug`, `otp_app`, and `entry_module`.
- Confirm `manifest.json` `entry_module` matches your plugin module.
- 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.
+55
View File
@@ -0,0 +1,55 @@
# Development
## Local Setup
This plugin 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-trust plugins/trust
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 ash.codegen update_example_resources
scripts/plugin ecto.migration add_example_table
scripts/plugin smoke
```
Inside the plugin 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
```
+90
View File
@@ -0,0 +1,90 @@
# Plugin Contract
## Manifest
`manifest.json` is the runtime contract consumed by Tribes:
- `id` is the canonical reverse-DNS plugin identity; `slug` is the local URL/assets namespace.
- `otp_app` should be vendor-prefixed and must match the Mix application name.
- `entry_module` must be a loadable module ending in `.Plugin`.
- `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.
`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.
Declare `org.tribe-one.caps.ui@1` in `requires` when rendering through `Tribes.Plugin.Layouts.app`,
importing `Tribes.UI.Components`, or using `use Tribes.UI`.
## Entry Modules
The plugin entry module is:
```elixir
defmodule TribeOne.TribesPlugin.Trust.Plugin do
use Tribes.Plugin.Base, otp_app: :tribe_one_trust
end
```
Keep plugin implementation code under your own namespace. The default
generator uses `TribeOne.TribesPlugin.*`; third-party plugins should use their own
organization or project namespace.
## 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.
Host services are exposed through public facade modules in
`Tribes.Plugin.Services`. Use `Tribes.Plugin.Services.Alliance` for local
tribe metadata and tribe users, `Tribes.Plugin.Services.Metrics` for compact
metric rollups, and `Tribes.Plugin.Services.Logs` for operational plugin
logging.
## Localization
Keep plugin-specific copy in this plugin's own Gettext backend and
`priv/gettext` catalogs. For shared Tribes UI labels that are documented as
public plugin API, import `Tribes.Plugin.Gettext` and call `tgettext/2`:
```elixir
import Tribes.Plugin.Gettext
tgettext("Cancel")
tgettext("Save")
```
Do not call arbitrary internal Tribes Gettext domains from plugin code.
The generated plugin 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.
This plugin owns the tribe-to-tribe trust provider for
`org.tribes.federation.provider@1` and stores:
- `RemoteTribe` — signed or locally observed remote tribe identity metadata.
- `TribeRelationship` — local relationship state, trust score, notes, requested
capabilities, and granted capabilities.
Both resources are cluster-synced because they are tribe-level governance state.
They are not internal cluster trust records and must not be used to authorize
AshNostrSync node writers.
+302
View File
@@ -0,0 +1,302 @@
defmodule TribeOne.TribesPlugin.Trust do
@moduledoc false
alias TribeOne.TribesPlugin.Trust.Domain
@federation_capabilities [
"org.tribes.federation.hello@1",
"org.tribes.federation.relationship.status@1",
"org.tribes.trust.attest@1"
]
def federation_capabilities, do: @federation_capabilities
def federation_hidden? do
case Tribes.ConfigStore.get_for_plugin("trust", "federation.hidden", default: false) do
{:ok, value} -> value in [true, "true", "1", "on"]
{:error, _reason} -> false
end
end
def set_federation_hidden?(value, opts \\ []) when is_boolean(value) do
Tribes.ConfigStore.put_for_plugin("trust", "federation.hidden", value, opts)
end
def receive_hello(attrs, opts \\ []) when is_map(attrs) do
with {:ok, tribe_attrs} <- normalize_remote_tribe_attrs(attrs),
{:ok, relationship_attrs} <- normalize_inbound_relationship_attrs(attrs),
{:ok, tribe} <- Domain.upsert_remote_tribe(tribe_attrs, default_trust_opts(opts)),
{:ok, relationship} <-
upsert_inbound_relationship(tribe.pubkey, relationship_attrs, opts) do
{:ok, %{tribe: tribe, relationship: relationship}}
end
end
def observe_remote_tribe(attrs, opts \\ []) when is_map(attrs) do
with {:ok, tribe_attrs} <- normalize_remote_tribe_attrs(attrs),
{:ok, relationship_attrs} <- normalize_observed_relationship_attrs(attrs),
{:ok, tribe} <- Domain.upsert_remote_tribe(tribe_attrs, default_trust_opts(opts)),
{:ok, relationship} <-
ensure_observed_relationship(tribe.pubkey, relationship_attrs, opts) do
{:ok, %{tribe: tribe, relationship: relationship}}
end
end
def list_relationship_rows(opts \\ []) do
with {:ok, tribes} <- Domain.list_remote_tribes(default_trust_opts(opts)),
{:ok, relationships} <- Domain.list_tribe_relationships(default_trust_opts(opts)) do
tribes_by_pubkey = Map.new(tribes, &{&1.pubkey, &1})
rows =
relationships
|> Enum.map(fn relationship ->
%{
tribe: Map.get(tribes_by_pubkey, relationship.remote_tribe_pubkey),
relationship: relationship
}
end)
|> Enum.sort_by(fn %{relationship: relationship} ->
{status_rank(relationship.status), relationship.updated_at || relationship.inserted_at}
end)
{:ok, rows}
end
end
def update_relationship(remote_pubkey, attrs, opts \\ []) when is_binary(remote_pubkey) do
with {:ok, pubkey} <- normalize_pubkey(remote_pubkey),
{:ok, existing} <-
Domain.get_tribe_relationship(
pubkey,
Keyword.put(default_trust_opts(opts), :not_found_error?, false)
),
{:ok, attrs} <- normalize_relationship_update(attrs, existing) do
Domain.upsert_tribe_relationship(attrs, default_trust_opts(opts))
end
end
defp upsert_inbound_relationship(remote_pubkey, attrs, opts) do
existing =
case Domain.get_tribe_relationship(
remote_pubkey,
Keyword.put(default_trust_opts(opts), :not_found_error?, false)
) do
{:ok, relationship} -> relationship
{:error, _reason} -> nil
end
status =
case existing do
%{status: status} when status in [:active, :blocked, :revoked] -> status
_other -> :pending_inbound
end
attrs
|> Map.merge(%{remote_tribe_pubkey: remote_pubkey, status: status})
|> Domain.upsert_tribe_relationship(default_trust_opts(opts))
end
defp ensure_observed_relationship(remote_pubkey, attrs, opts) do
case Domain.get_tribe_relationship(
remote_pubkey,
Keyword.put(default_trust_opts(opts), :not_found_error?, false)
) do
{:ok, nil} ->
Domain.upsert_tribe_relationship(
Map.merge(
%{
remote_tribe_pubkey: remote_pubkey,
status: :observed,
requested_capabilities: [],
granted_capabilities: [],
trust_score: 0
},
attrs
),
default_trust_opts(opts)
)
{:ok, relationship} ->
{:ok, relationship}
{:error, _reason} = error ->
error
end
end
defp normalize_remote_tribe_attrs(attrs) do
with {:ok, pubkey} <-
normalize_pubkey(attrs["from_tribe"] || attrs["pubkey"] || attrs[:pubkey]) do
{:ok,
%{
pubkey: pubkey,
name: normalize_optional_string(attrs["name"] || attrs[:name]) || pubkey,
description: normalize_optional_string(attrs["description"] || attrs[:description]),
last_seen_at: DateTime.utc_now()
}}
end
end
defp normalize_inbound_relationship_attrs(attrs) do
with {:ok, requested_capabilities} <-
normalize_string_list(
attrs["requested_capabilities"] || attrs[:requested_capabilities] || []
),
{:ok, remote_relay_urls} <-
normalize_string_list(attrs["relay_urls"] || attrs[:relay_urls] || []),
{:ok, remote_capabilities} <-
normalize_string_list(attrs["capabilities"] || attrs[:capabilities] || []) do
{:ok,
%{
remote_api_url: normalize_optional_string(attrs["api_url"] || attrs[:api_url]),
remote_profile_url:
normalize_optional_string(attrs["profile_url"] || attrs[:profile_url]),
remote_relay_urls: remote_relay_urls,
remote_capabilities: remote_capabilities,
requested_capabilities: requested_capabilities,
inbound_message: normalize_optional_string(attrs["message"] || attrs[:message]),
last_inbound_at: DateTime.utc_now()
}}
end
end
defp normalize_observed_relationship_attrs(attrs) do
with {:ok, remote_relay_urls} <-
normalize_string_list(attrs["relay_urls"] || attrs[:relay_urls] || []),
{:ok, remote_capabilities} <-
normalize_string_list(attrs["capabilities"] || attrs[:capabilities] || []) do
{:ok,
%{
remote_api_url: normalize_optional_string(attrs["api_url"] || attrs[:api_url]),
remote_profile_url:
normalize_optional_string(attrs["profile_url"] || attrs[:profile_url]),
remote_relay_urls: remote_relay_urls,
remote_capabilities: remote_capabilities
}}
end
end
defp normalize_relationship_update(_attrs, nil), do: {:error, :relationship_not_found}
defp normalize_relationship_update(attrs, existing) do
with {:ok, status} <- normalize_status(attrs["status"] || attrs[:status] || existing.status),
{:ok, trust_score} <-
normalize_trust_score(
attrs["trust_score"] || attrs[:trust_score] || existing.trust_score
),
{:ok, granted_capabilities} <-
normalize_capability_input(
attrs["granted_capabilities"] || attrs[:granted_capabilities] ||
existing.granted_capabilities || []
) do
{:ok,
%{
remote_tribe_pubkey: existing.remote_tribe_pubkey,
status: status,
remote_api_url: existing.remote_api_url,
remote_profile_url: existing.remote_profile_url,
remote_relay_urls: existing.remote_relay_urls || [],
remote_capabilities: existing.remote_capabilities || [],
requested_capabilities: existing.requested_capabilities || [],
granted_capabilities: granted_capabilities,
trust_score: trust_score,
trust_note:
normalize_optional_string(
attrs["trust_note"] || attrs[:trust_note] || existing.trust_note
),
inbound_message: existing.inbound_message,
last_inbound_at: existing.last_inbound_at,
last_outbound_at: existing.last_outbound_at
}}
end
end
defp normalize_capability_input(value) when is_binary(value) do
value
|> String.split([",", "\n"], trim: true)
|> Enum.map(&String.trim/1)
|> normalize_string_list()
end
defp normalize_capability_input(value), do: normalize_string_list(value)
defp normalize_pubkey(value) when is_binary(value) do
pubkey = value |> String.trim() |> String.downcase()
if String.match?(pubkey, ~r/\A[0-9a-f]{64}\z/) do
{:ok, pubkey}
else
{:error, :invalid_pubkey}
end
end
defp normalize_pubkey(_value), do: {:error, :invalid_pubkey}
defp normalize_status(:observed), do: {:ok, :observed}
defp normalize_status(:pending_inbound), do: {:ok, :pending_inbound}
defp normalize_status(:pending_outbound), do: {:ok, :pending_outbound}
defp normalize_status(:active), do: {:ok, :active}
defp normalize_status(:blocked), do: {:ok, :blocked}
defp normalize_status(:revoked), do: {:ok, :revoked}
defp normalize_status("observed"), do: {:ok, :observed}
defp normalize_status("pending_inbound"), do: {:ok, :pending_inbound}
defp normalize_status("pending_outbound"), do: {:ok, :pending_outbound}
defp normalize_status("active"), do: {:ok, :active}
defp normalize_status("blocked"), do: {:ok, :blocked}
defp normalize_status("revoked"), do: {:ok, :revoked}
defp normalize_status(_status), do: {:error, :invalid_status}
defp normalize_trust_score(value) when is_integer(value) and value >= -100 and value <= 100,
do: {:ok, value}
defp normalize_trust_score(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{score, ""} -> normalize_trust_score(score)
_other -> {:error, :invalid_trust_score}
end
end
defp normalize_trust_score(_value), do: {:error, :invalid_trust_score}
defp normalize_string_list(values) when is_list(values) do
values
|> Enum.reduce_while({:ok, []}, fn
value, {:ok, acc} when is_binary(value) ->
trimmed = String.trim(value)
next = if trimmed == "", do: acc, else: [trimmed | acc]
{:cont, {:ok, next}}
_value, _acc ->
{:halt, {:error, :invalid_string_list}}
end)
|> case do
{:ok, values} -> {:ok, Enum.reverse(values)}
{:error, _reason} = error -> error
end
end
defp normalize_string_list(_values), do: {:error, :invalid_string_list}
defp normalize_optional_string(nil), do: nil
defp normalize_optional_string(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp normalize_optional_string(_value), do: nil
defp status_rank(:pending_inbound), do: 0
defp status_rank(:pending_outbound), do: 1
defp status_rank(:active), do: 2
defp status_rank(:observed), do: 3
defp status_rank(:blocked), do: 4
defp status_rank(:revoked), do: 5
defp status_rank(_status), do: 6
defp default_trust_opts(opts) do
Keyword.put_new(opts, :context, %{private: %{system?: true, system_purpose: :trust_plugin}})
end
end
+21
View File
@@ -0,0 +1,21 @@
defmodule TribeOne.TribesPlugin.Trust.Application do
@moduledoc """
OTP Application for this plugin.
Uncomment the `mod:` entry in mix.exs to activate this supervision tree.
Add plugin-specific GenServers, workers, or supervisors as children here.
"""
use Application
@impl true
def start(_type, _args) do
children = [
# Add your supervised processes here, e.g.:
# {TribeOne.TribesPlugin.Trust.Worker, []}
]
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Trust.Supervisor]
Supervisor.start_link(children, opts)
end
end
+23
View File
@@ -0,0 +1,23 @@
defmodule TribeOne.TribesPlugin.Trust.Domain do
@moduledoc false
use Ash.Domain,
otp_app: :tribe_one_trust
alias TribeOne.TribesPlugin.Trust.RemoteTribe
alias TribeOne.TribesPlugin.Trust.TribeRelationship
resources do
resource RemoteTribe do
define(:list_remote_tribes, action: :read)
define(:get_remote_tribe, action: :by_pubkey, args: [:pubkey])
define(:upsert_remote_tribe, action: :upsert)
end
resource TribeRelationship do
define(:list_tribe_relationships, action: :read)
define(:get_tribe_relationship, action: :by_remote_pubkey, args: [:remote_tribe_pubkey])
define(:upsert_tribe_relationship, action: :upsert)
end
end
end
+258
View File
@@ -0,0 +1,258 @@
defmodule TribeOne.TribesPlugin.Trust.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :tribe_one_trust
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
alias Parrhesia.API.Auth
alias Parrhesia.Web.Listener
alias TribeOne.TribesPlugin.Trust
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "Trust",
path: "/trust",
icon: nil,
requires: [],
order: 50
}
],
pages: [
%{
path: "/trust",
live_view: TribeOne.TribesPlugin.TrustWeb.HomeLive,
layout: nil
}
],
api_routes: [],
management_methods: [],
metrics: [],
plugs: [],
hooks: %{},
ash_domains: [TribeOne.TribesPlugin.Trust.Domain],
config_schema: %{
title: "Trust",
description: "Tribe-to-tribe federation and trust settings.",
groups: [
%{
id: "federation",
label: "Federation",
settings: [
%{
key: "federation.hidden",
label: "Hide unauthenticated federation identity",
type: :boolean,
default: false,
description:
"When enabled, /.well-known/tribes/identity returns 404 unless a signed federation call is used."
}
]
}
]
}
})
end
def federation_identity(conn) do
cond do
Trust.federation_hidden?() ->
conn |> put_status(:not_found) |> json(%{"ok" => false, "error" => "not-found"})
true ->
case local_identity(api_url(conn)) do
{:ok, identity} ->
json(conn, identity)
{:error, :local_tribe_not_found} ->
conn |> put_status(404) |> json(%{"ok" => false, "error" => "local-tribe-not-found"})
{:error, reason} ->
conn |> put_status(503) |> json(%{"ok" => false, "error" => inspect(reason)})
end
end
end
def federation_hello(conn) do
authorization = get_req_header(conn, "authorization") |> List.first()
full_url = full_request_url(conn)
with {:ok, auth_context} <- Auth.validate_nip98(authorization, conn.method, full_url),
{:ok, attrs} <- parse_hello(conn.body_params),
:ok <- verify_sender(auth_context.pubkey, attrs),
{:ok, result} <- Trust.receive_hello(attrs) do
json(conn, %{
"ok" => true,
"relationship" => %{
"remote_tribe_pubkey" => result.relationship.remote_tribe_pubkey,
"status" => to_string(result.relationship.status),
"trust_score" => result.relationship.trust_score,
"granted_capabilities" => result.relationship.granted_capabilities || []
}
})
else
{:error, :missing_authorization} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-required"})
{:error, :invalid_authorization} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-authorization"})
{:error, :invalid_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-auth-event"})
{:error, :stale_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "stale-auth-event"})
{:error, :replayed_auth_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "replayed-auth-event"})
{:error, :invalid_method_tag} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-method-tag-mismatch"})
{:error, :invalid_url_tag} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-url-tag-mismatch"})
{:error, :sender_mismatch} ->
conn |> put_status(403) |> json(%{"ok" => false, "error" => "sender-mismatch"})
{:error, :invalid_payload} ->
conn |> put_status(400) |> json(%{"ok" => false, "error" => "invalid-payload"})
{:error, reason} ->
conn |> put_status(400) |> json(%{"ok" => false, "error" => inspect(reason)})
end
end
defp local_identity(api_url) when is_binary(api_url) do
with {:ok, tribe} when not is_nil(tribe) <- Tribes.Alliance.local_tribe(authorize?: false),
{:ok, relay_urls} <- Tribes.Cluster.list_public_relay_urls() do
{:ok,
%{
"protocol" => "org.tribes.federation@1",
"tribe" => %{
"pubkey" => tribe.pubkey,
"name" => tribe.name,
"description" => tribe.description,
"visibility" => to_string(tribe.visibility)
},
"api_url" => api_url,
"relay_urls" => relay_urls,
"capabilities" => Trust.federation_capabilities()
}}
else
{:ok, nil} -> {:error, :local_tribe_not_found}
{:error, _reason} = error -> error
end
end
defp parse_hello(%{"from_tribe" => from_tribe} = params) when is_binary(from_tribe) do
if is_list(Map.get(params, "relay_urls", [])) and is_list(Map.get(params, "capabilities", [])) and
is_list(Map.get(params, "requested_capabilities", [])) do
{:ok, params}
else
{:error, :invalid_payload}
end
end
defp parse_hello(_params), do: {:error, :invalid_payload}
defp verify_sender(pubkey, %{"from_tribe" => from_tribe}) when is_binary(pubkey) do
if String.downcase(pubkey) == String.downcase(from_tribe) do
:ok
else
{:error, :sender_mismatch}
end
end
defp verify_sender(_pubkey, _attrs), do: {:error, :sender_mismatch}
defp api_url(conn) do
base_url(conn) <> "/api/tribes/v1"
end
defp full_request_url(conn) do
query_suffix = if conn.query_string == "", do: "", else: "?#{conn.query_string}"
base_url(conn) <> Listener.visible_request_path(conn) <> query_suffix
end
defp base_url(conn) do
scheme = forwarded_scheme(conn)
{host, host_port} = forwarded_host(conn)
port = forwarded_port(conn, scheme, host_port)
port_suffix =
cond do
scheme == :http and port == 80 -> ""
scheme == :https and port == 443 -> ""
true -> ":#{port}"
end
"#{scheme}://#{host}#{port_suffix}"
end
defp forwarded_scheme(conn) do
case first_forwarded_header(conn, "x-forwarded-proto") do
"https" -> :https
"http" -> :http
_other -> conn.scheme
end
end
defp forwarded_host(conn) do
case first_forwarded_header(conn, "x-forwarded-host") do
nil -> {conn.host, nil}
forwarded_host -> parse_forwarded_host(forwarded_host)
end
end
defp forwarded_port(conn, scheme, host_port) do
forwarded? =
Enum.any?(["x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"], fn header ->
is_binary(first_forwarded_header(conn, header))
end)
case first_forwarded_header(conn, "x-forwarded-port") do
nil when is_integer(host_port) -> host_port
nil when forwarded? -> default_port(scheme)
nil -> conn.port
port -> parse_port(port, default_port(scheme))
end
end
defp first_forwarded_header(conn, header) do
conn
|> get_req_header(header)
|> List.first()
|> case do
nil -> nil
value -> value |> String.split(",", parts: 2) |> List.first() |> String.trim()
end
end
defp parse_forwarded_host(host) when is_binary(host) do
case URI.parse("//" <> host) do
%URI{host: parsed_host, port: port} when is_binary(parsed_host) and parsed_host != "" ->
{parsed_host, port}
_other ->
{host, nil}
end
end
defp parse_port(value, fallback) when is_binary(value) do
case Integer.parse(value) do
{port, ""} when port > 0 -> port
_other -> fallback
end
end
defp default_port(:https), do: 443
defp default_port(_scheme), do: 80
end
+103
View File
@@ -0,0 +1,103 @@
defmodule TribeOne.TribesPlugin.Trust.RemoteTribe do
@moduledoc false
use Ash.Resource,
otp_app: :tribe_one_trust,
domain: TribeOne.TribesPlugin.Trust.Domain,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshNostrSync]
postgres do
table("trust_remote_tribes")
repo(Tribes.Repo)
custom_indexes do
index([:pubkey], unique: true)
end
end
nostr_sync do
namespace("plugins.trust.remote_tribe")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_pubkey do
get?(true)
argument :pubkey, :string do
allow_nil?(false)
end
filter(expr(pubkey == ^arg(:pubkey)))
end
create :upsert do
accept([:pubkey, :name, :description, :last_seen_at])
upsert?(true)
upsert_identity(:unique_pubkey)
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([:id, :pubkey, :name, :description, :last_seen_at])
upsert?(true)
end
end
policies do
bypass Tribes.Checks.SyncInteraction do
authorize_if(always())
end
bypass {Tribes.Checks.SystemInteraction, purposes: [:trust_plugin]} do
authorize_if(always())
end
policy action_type(:read) do
authorize_if(Tribes.Checks.ActorLocalTribeAdmin)
end
policy action(:upsert) do
authorize_if(Tribes.Checks.ActorLocalTribeAdmin)
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 :name, :string do
public?(true)
end
attribute :description, :string do
public?(true)
end
attribute :last_seen_at, :utc_datetime do
public?(true)
end
timestamps(type: :utc_datetime)
end
identities do
identity(:unique_pubkey, [:pubkey])
end
end
+189
View File
@@ -0,0 +1,189 @@
defmodule TribeOne.TribesPlugin.Trust.TribeRelationship do
@moduledoc false
use Ash.Resource,
otp_app: :tribe_one_trust,
domain: TribeOne.TribesPlugin.Trust.Domain,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshNostrSync]
postgres do
table("trust_tribe_relationships")
repo(Tribes.Repo)
custom_indexes do
index([:remote_tribe_pubkey], unique: true)
index([:status])
index([:trust_score])
end
end
nostr_sync do
namespace("plugins.trust.tribe_relationship")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_remote_pubkey do
get?(true)
argument :remote_tribe_pubkey, :string do
allow_nil?(false)
end
filter(expr(remote_tribe_pubkey == ^arg(:remote_tribe_pubkey)))
end
create :upsert do
accept([
:remote_tribe_pubkey,
:status,
:remote_api_url,
:remote_profile_url,
:remote_relay_urls,
:remote_capabilities,
:requested_capabilities,
:granted_capabilities,
:trust_score,
:trust_note,
:inbound_message,
:last_inbound_at,
:last_outbound_at
])
upsert?(true)
upsert_identity(:unique_remote_tribe_pubkey)
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([
:id,
:remote_tribe_pubkey,
:status,
:remote_api_url,
:remote_profile_url,
:remote_relay_urls,
:remote_capabilities,
:requested_capabilities,
:granted_capabilities,
:trust_score,
:trust_note,
:inbound_message,
:last_inbound_at,
:last_outbound_at
])
upsert?(true)
end
end
policies do
bypass Tribes.Checks.SyncInteraction do
authorize_if(always())
end
bypass {Tribes.Checks.SystemInteraction, purposes: [:trust_plugin]} do
authorize_if(always())
end
policy action_type(:read) do
authorize_if(Tribes.Checks.ActorLocalTribeAdmin)
end
policy action(:upsert) do
authorize_if(Tribes.Checks.ActorLocalTribeAdmin)
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 :remote_tribe_pubkey, :string do
allow_nil?(false)
public?(true)
end
attribute :status, :atom do
constraints(
one_of: [:observed, :pending_inbound, :pending_outbound, :active, :blocked, :revoked]
)
allow_nil?(false)
default(:observed)
public?(true)
end
attribute :remote_api_url, :string do
public?(true)
end
attribute :remote_profile_url, :string do
public?(true)
end
attribute :remote_relay_urls, {:array, :string} do
allow_nil?(false)
default([])
public?(true)
end
attribute :remote_capabilities, {:array, :string} do
allow_nil?(false)
default([])
public?(true)
end
attribute :requested_capabilities, {:array, :string} do
allow_nil?(false)
default([])
public?(true)
end
attribute :granted_capabilities, {:array, :string} do
allow_nil?(false)
default([])
public?(true)
end
attribute :trust_score, :integer do
allow_nil?(false)
default(0)
constraints(min: -100, max: 100)
public?(true)
end
attribute :trust_note, :string do
public?(true)
end
attribute :inbound_message, :string do
public?(true)
end
attribute :last_inbound_at, :utc_datetime do
public?(true)
end
attribute :last_outbound_at, :utc_datetime do
public?(true)
end
timestamps(type: :utc_datetime)
end
identities do
identity(:unique_remote_tribe_pubkey, [:remote_tribe_pubkey])
end
end
+321
View File
@@ -0,0 +1,321 @@
defmodule TribeOne.TribesPlugin.TrustWeb.HomeLive do
@moduledoc false
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :local_tribe_admin_required})
alias TribeOne.TribesPlugin.Trust
alias Tribes.Plugin.Layouts
import Tribes.Plugin.Gettext
import Tribes.UI.Components
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Trust")
|> assign(:rows, [])
|> assign(:federation_hidden?, Trust.federation_hidden?())
|> assign(:summary, %{active: 0, pending: 0, passive: 0, blocked: 0})
|> assign(:observe_form, observe_form(%{}))
|> load_relationships()}
end
def handle_event("validate-observe", %{"tribe" => params}, socket) do
{:noreply, assign(socket, :observe_form, observe_form(params))}
end
def handle_event("observe", %{"tribe" => params}, socket) do
case Trust.observe_remote_tribe(params) do
{:ok, _result} ->
{:noreply,
socket
|> put_flash(:info, "Remote tribe saved")
|> assign(:observe_form, observe_form(%{}))
|> load_relationships()}
{:error, :invalid_pubkey} ->
{:noreply, put_flash(socket, :error, "Tribe pubkey must be 64 lowercase hex chars")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to save remote tribe: #{inspect(reason)}")}
end
end
def handle_event(
"update-relationship",
%{"relationship" => %{"remote_tribe_pubkey" => pubkey} = params},
socket
) do
case Trust.update_relationship(pubkey, params) do
{:ok, _relationship} ->
{:noreply, socket |> put_flash(:info, "Relationship updated") |> load_relationships()}
{:error, :invalid_trust_score} ->
{:noreply, put_flash(socket, :error, "Trust score must be between -100 and 100")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to update relationship: #{inspect(reason)}")}
end
end
def handle_event("set-status", %{"pubkey" => pubkey, "status" => status}, socket) do
case Trust.update_relationship(pubkey, %{"status" => status}) do
{:ok, _relationship} ->
{:noreply,
socket |> put_flash(:info, "Relationship status updated") |> load_relationships()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to update relationship: #{inspect(reason)}")}
end
end
def handle_event("set-hidden", %{"hidden" => hidden}, socket) do
hidden? = hidden in ["true", "1", "on"]
case Trust.set_federation_hidden?(hidden?) do
{:ok, _entry} ->
{:noreply,
socket
|> put_flash(:info, "Federation visibility updated")
|> assign(:federation_hidden?, hidden?)}
{:error, reason} ->
{:noreply,
put_flash(socket, :error, "Failed to update federation visibility: #{inspect(reason)}")}
end
end
def handle_event("refresh", _params, socket), do: {:noreply, load_relationships(socket)}
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div id="trust-page" class="trust space-y-6 p-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="card-title">Trust</h1>
<p class="mt-1 text-sm text-base-content/70">
Tribe-to-tribe federation, relationships, and local trust assertions.
</p>
</div>
<button id="trust-refresh" class="btn btn-ghost btn-sm" phx-click="refresh" type="button">
{tgettext("Refresh")}
</button>
</div>
<div class="grid gap-3 sm:grid-cols-4">
<.summary_stat id="trust-active-count" label="Active" value={@summary.active} tone="success" />
<.summary_stat id="trust-pending-count" label="Pending" value={@summary.pending} tone="warning" />
<.summary_stat id="trust-passive-count" label="Passive" value={@summary.passive} tone="neutral" />
<.summary_stat id="trust-blocked-count" label="Blocked" value={@summary.blocked} tone="danger" />
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="card-title text-base">Federation discovery</h2>
<p class="text-sm text-base-content/70">
Hidden tribes do not answer unauthenticated well-known identity requests.
</p>
</div>
<div class="flex gap-2">
<button id="trust-visible" class={["btn btn-sm", !@federation_hidden? && "btn-primary", @federation_hidden? && "btn-ghost"]} phx-click="set-hidden" phx-value-hidden="false" type="button">
Visible
</button>
<button id="trust-hidden" class={["btn btn-sm", @federation_hidden? && "btn-primary", !@federation_hidden? && "btn-ghost"]} phx-click="set-hidden" phx-value-hidden="true" type="button">
Hidden
</button>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div>
<h2 class="card-title text-base">Observe a remote tribe</h2>
<p class="text-sm text-base-content/70">
Signed hello requests create pending inbound rows automatically.
</p>
</div>
<.form for={@observe_form} id="trust-observe-form" phx-change="validate-observe" phx-submit="observe" class="grid gap-3 md:grid-cols-2">
<.input field={@observe_form[:pubkey]} type="text" label="Tribe pubkey" required />
<.input field={@observe_form[:name]} type="text" label="Name" />
<.input field={@observe_form[:api_url]} type="url" label="API URL" placeholder="https://example.org/api/tribes/v1" />
<.input field={@observe_form[:profile_url]} type="url" label="Profile URL" />
<div class="md:col-span-2 flex justify-end">
<button id="trust-observe-submit" type="submit" class="btn btn-primary btn-sm">Save remote tribe</button>
</div>
</.form>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">Known remote tribes</h2>
<div id="trust-relationships-list" class="mt-3 space-y-3">
<%= for row <- @rows do %>
<div id={"relationship-row-#{row.relationship.remote_tribe_pubkey}"} class="rounded-box border border-base-300 p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold">{remote_name(row)}</h3>
<span class={status_badge_class(row.relationship.status)}>{status_label(row.relationship.status)}</span>
<span class={trust_badge_class(row.relationship.trust_score)}>trust {row.relationship.trust_score}</span>
</div>
<p class="break-all font-mono text-xs text-base-content/70">{row.relationship.remote_tribe_pubkey}</p>
<p :if={row.tribe && row.tribe.description} class="text-sm text-base-content/70">{row.tribe.description}</p>
<p :if={row.relationship.remote_api_url} class="break-all text-xs text-base-content/70">API: {row.relationship.remote_api_url}</p>
<p :if={row.relationship.inbound_message} class="rounded-box bg-base-200 p-2 text-sm text-base-content/80">{row.relationship.inbound_message}</p>
</div>
<div class="flex flex-wrap gap-2 lg:justify-end">
<button :if={row.relationship.status in [:observed, :pending_inbound, :pending_outbound, :revoked]} id={"activate-relationship-#{row.relationship.remote_tribe_pubkey}"} class="btn btn-success btn-xs" phx-click="set-status" phx-value-pubkey={row.relationship.remote_tribe_pubkey} phx-value-status="active" type="button">Activate</button>
<button :if={row.relationship.status != :blocked} id={"block-relationship-#{row.relationship.remote_tribe_pubkey}"} class="btn btn-ghost btn-xs text-error" phx-click="set-status" phx-value-pubkey={row.relationship.remote_tribe_pubkey} phx-value-status="blocked" type="button">Block</button>
<button :if={row.relationship.status in [:active, :blocked]} id={"revoke-relationship-#{row.relationship.remote_tribe_pubkey}"} class="btn btn-ghost btn-xs" phx-click="set-status" phx-value-pubkey={row.relationship.remote_tribe_pubkey} phx-value-status="revoked" type="button">Revoke</button>
</div>
</div>
<.form for={relationship_form(row.relationship)} id={"relationship-form-#{row.relationship.remote_tribe_pubkey}"} phx-submit="update-relationship" class="mt-4 grid gap-3 md:grid-cols-3">
<.input field={relationship_form(row.relationship)[:remote_tribe_pubkey]} type="hidden" />
<.input field={relationship_form(row.relationship)[:status]} type="select" label="Status" options={status_options()} />
<.input field={relationship_form(row.relationship)[:trust_score]} type="number" label="Trust score" min="-100" max="100" />
<.input field={relationship_form(row.relationship)[:granted_capabilities]} type="text" label="Granted capabilities" placeholder="org.tribes.kobold.dataset.discover@1" />
<div class="md:col-span-3">
<.input field={relationship_form(row.relationship)[:trust_note]} type="textarea" label="Trust note" rows="2" />
</div>
<div class="md:col-span-3 flex justify-end">
<button id={"save-relationship-#{row.relationship.remote_tribe_pubkey}"} type="submit" class="btn btn-primary btn-xs">Save trust</button>
</div>
</.form>
</div>
<% end %>
<p :if={@rows == []} class="text-sm text-base-content/70">
No remote tribes have contacted this tribe yet.
</p>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
attr(:id, :string, required: true)
attr(:label, :string, required: true)
attr(:value, :integer, required: true)
attr(:tone, :string, default: "neutral")
defp summary_stat(assigns) do
~H"""
<div id={@id} class="rounded-box border border-base-300 bg-base-200 p-3">
<p class="text-xs font-medium uppercase tracking-wide text-base-content/60">{@label}</p>
<p class={summary_value_class(@tone)}>{@value}</p>
</div>
"""
end
defp load_relationships(socket) do
case Trust.list_relationship_rows() do
{:ok, rows} ->
socket
|> assign(:rows, rows)
|> assign(:federation_hidden?, Trust.federation_hidden?())
|> assign(:summary, summarize(rows))
{:error, reason} ->
put_flash(socket, :error, "Failed to load trust relationships: #{inspect(reason)}")
end
end
defp summarize(rows) do
Enum.reduce(rows, %{active: 0, pending: 0, passive: 0, blocked: 0}, fn row, acc ->
case row.relationship.status do
:active ->
Map.update!(acc, :active, &(&1 + 1))
status when status in [:pending_inbound, :pending_outbound] ->
Map.update!(acc, :pending, &(&1 + 1))
:blocked ->
Map.update!(acc, :blocked, &(&1 + 1))
_status ->
Map.update!(acc, :passive, &(&1 + 1))
end
end)
end
defp observe_form(params), do: to_form(params, as: :tribe)
defp relationship_form(relationship) do
to_form(
%{
"remote_tribe_pubkey" => relationship.remote_tribe_pubkey,
"status" => to_string(relationship.status),
"trust_score" => relationship.trust_score,
"granted_capabilities" => Enum.join(relationship.granted_capabilities || [], ", "),
"trust_note" => relationship.trust_note || ""
},
as: :relationship
)
end
defp remote_name(%{tribe: %{name: name}}) when is_binary(name) and name != "", do: name
defp remote_name(_row), do: "Unnamed tribe"
defp status_options do
[
{"Observed", "observed"},
{"Pending inbound", "pending_inbound"},
{"Pending outbound", "pending_outbound"},
{"Active", "active"},
{"Blocked", "blocked"},
{"Revoked", "revoked"}
]
end
defp status_label(:observed), do: "observed"
defp status_label(:pending_inbound), do: "pending inbound"
defp status_label(:pending_outbound), do: "pending outbound"
defp status_label(:active), do: "active"
defp status_label(:blocked), do: "blocked"
defp status_label(:revoked), do: "revoked"
defp status_label(_status), do: "unknown"
defp status_badge_class(:active),
do: "rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700"
defp status_badge_class(status) when status in [:pending_inbound, :pending_outbound],
do: "rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700"
defp status_badge_class(:blocked),
do: "rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700"
defp status_badge_class(_status),
do: "rounded-full bg-base-300 px-2 py-0.5 text-[11px] font-medium text-base-content/70"
defp trust_badge_class(score) when is_integer(score) and score >= 60,
do: "rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700"
defp trust_badge_class(score) when is_integer(score) and score <= -60,
do: "rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700"
defp trust_badge_class(_score),
do: "rounded-full bg-base-300 px-2 py-0.5 text-[11px] font-medium text-base-content/70"
defp summary_value_class("success"), do: "mt-1 text-2xl font-semibold text-emerald-700"
defp summary_value_class("warning"), do: "mt-1 text-2xl font-semibold text-amber-700"
defp summary_value_class("danger"), do: "mt-1 text-2xl font-semibold text-rose-700"
defp summary_value_class(_tone), do: "mt-1 text-2xl font-semibold text-base-content"
end
+19
View File
@@ -0,0 +1,19 @@
{
"id": "org.tribe-one.plugins.trust",
"slug": "trust",
"display_name": "Trust",
"version": "0.1.0",
"description": "Tribe-to-tribe alliances and trust",
"entry_module": "TribeOne.TribesPlugin.Trust.Plugin",
"host_api": "1",
"otp_app": "tribe_one_trust",
"provides": ["org.tribes.alliance.trust@1", "org.tribes.federation.provider@1"],
"requires": ["org.tribe-one.caps.ui@1"],
"enhances_with": [],
"assets": {
"global_js": ["trust.js"],
"global_css": ["trust.css"]
},
"migrations": true,
"children": false
}
+112
View File
@@ -0,0 +1,112 @@
defmodule TribeOne.TribesPlugin.Trust.MixProject do
use Mix.Project
def project do
[
app: :tribe_one_trust,
version: "0.1.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
usage_rules: usage_rules()
]
end
def cli do
[
preferred_envs: [precommit: :test, raw_precommit: :test, raw_test: :test]
]
end
def application do
[
extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree:
# mod: {TribeOne.TribesPlugin.Trust.Application, []}
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp deps do
[
# Plugin API dependency for local development alongside a tribes checkout.
#
# For CI or standalone development, this can be replaced with a published
# package once tribes_plugin_api is released.
{:tribes_plugin_api, path: "../tribes/tribes_plugin_api", runtime: false},
{:tribes_plugin, path: "../tribes-plugin-new", only: [:dev, :test], runtime: false},
{:igniter, "~> 0.7", only: [:dev, :test], runtime: false},
{: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"},
{:usage_rules, "~> 1.2", only: :dev}
] ++ tribes_deps(Mix.env())
end
defp tribes_deps(:dev), do: [{:tribes, path: "../tribes", only: :dev, runtime: false}]
defp tribes_deps(:test), do: [{:tribes, path: "../tribes", only: :test}]
defp tribes_deps(_), do: []
defp usage_rules do
[
file: "AGENTS.md",
usage_rules: [
{:usage_rules, sub_rules: []},
{"usage_rules:elixir", main: false},
{"usage_rules:otp", main: false},
"phoenix:ecto",
"phoenix:html",
"phoenix:liveview",
"phoenix:phoenix",
{:ash, sub_rules: []},
{"ash:actions", link: :markdown, main: false},
{"ash:migrations", link: :markdown, main: false},
{"ash:testing", link: :markdown, main: false},
{:tribes_plugin_api, sub_rules: []},
{:tribes, sub_rules: []}
]
]
end
defp aliases do
[
test: &plugin_test_message/1,
raw_test: &raw_test/1,
lint: ["format --check-formatted", "credo"],
precommit: &plugin_precommit_message/1,
raw_precommit: &raw_precommit/1
]
end
defp plugin_test_message(_args) do
Mix.raise("""
This plugin test suite is host-backed. Use `plugin test` or `scripts/plugin test`.
For low-level debugging only, set up the host environment yourself and run `mix raw_test`.
""")
end
defp plugin_precommit_message(_args) do
Mix.raise("""
This plugin precommit is host-backed. Use `plugin precommit` or `scripts/plugin precommit`.
For low-level debugging only, set up the host environment yourself and run `mix raw_precommit`.
""")
end
defp raw_precommit(args) do
Mix.Task.run("format")
Mix.Task.run("compile", ["--warnings-as-errors"])
Mix.Task.run("credo", ["--strict", "--all"])
Mix.Task.run("deps.unlock", ["--unused"])
raw_test(args)
end
defp raw_test(args), do: Mix.Tasks.Test.run(args)
end
+104
View File
@@ -0,0 +1,104 @@
%{
"absinthe": {:hex, :absinthe, "1.10.2", "7951efe6d22c7524752dc5e3fd4289e5bfb1d1e9ee2da2c79431c585552ded2e", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.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", "3948d6948c45b5cfd375892e578943eac8642d0a34b15e2a92ffdcdda9d91a22"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.5", "1fdcfc59db241b1d5e45e4c8c9db162277e64ec5728e0fd6958a3fa491a05b82", [: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 or ~> 3.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", "086c6d4a1c32f7444713130d204c87b1b006169f5159026b73f02f7d38ccd05c"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.10", "c9e207235aaa8a086a5db6801a9bebaea035f7b5a2703cb98d962646ef70c76f", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "489ac1951c8e4128571141c60a0669a720619bc161f801a8c6be8cfaf7ab0979"},
"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.27.2", "5c69bf6c3f53bf46329af8689fdb85c620c42284d03e17d9dd7aca32a2468daf", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.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", "d7ac246e396fa1d22b9fdcd5e82914cf7763817e21664f87736a0f78fe5782e6"},
"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.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.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [: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", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
"ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [: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", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"},
"ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [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", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bech32": {:hex, :bech32, "1.0.0", "85a3bb58c408d735b5becb39e8a23536660ec0df1ef0afee72377c130939de1b", [:mix], [], "hexpm", "f781b8524c30a524922613d97c1858c27bd9f639b4e6b350f4a4843ee97607d3"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cldr_utils": {:hex, :cldr_utils, "2.29.7", "2189bc0117efe91c684558e79174b45eb43135595b7d1fe9b57f53917be195c1", [: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 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4bddcd597fee34e2d2829ae9ef62bcfef8d97ae5f6b75f0c6ee37a3db31aa73a"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [: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", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
"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, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"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.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 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", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [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", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
"ex_cldr": {:hex, :ex_cldr, "2.47.4", "64e1efda73d6c5fa0342d5b6554f39b740fa02ed540dc03997facf573101ad9e", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "918aabc032955f3eac70abbdf2c5469433132edfaaaccee55451f074ee1ccdba"},
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "2.4.3", "13ec2ab7e1738ec16ecb91c0edc3753e9925c2682f4184571368d183b41deb7e", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_numbers, "~> 2.36", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b46ef6bd74f7e2dc3de27366f79372b1e630563bcf09b7803fec162e28d4a85e"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.2", "4892b5255a205e8133297b702abbe13c9b51d0281fa8c2ea100f51df9657c3c7", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "797095c106a2fe6632981531e29cfb1d2f8ee7de626f4d6243f974d6f74a0112"},
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.25.6", "6db974ab2b430b5733994c2bfbe98a69e25eeb076b876a929791ff521f8fdd96", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_calendars, "~> 2.4", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:tz, "~> 0.26", [hex: :tz, repo: "hexpm", optional: true]}], "hexpm", "926ff5662b849f86088832ee66b61a96aab0fa5a54d5e14240e08ad3030663e2"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.2", "7fa47c9547aa5e6e14e952fa0f69e464a6e6328f0ceda84668b88efec4c5a3ae", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.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", "a7eda0b3c7b8700a290e4330c6e0f36239225b987ee0332ace19c364ed3a3618"},
"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.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, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {: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", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.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"},
"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"},
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"parrhesia": {:hex, :parrhesia, "0.14.0", "7ff96a6d645740a19b66567c3346162735ba821a90377616e74493637c35195e", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:lib_secp256k1, "~> 0.7", [hex: :lib_secp256k1, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus, "~> 1.1", [hex: :telemetry_metrics_prometheus, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: false]}], "hexpm", "2bda91e6f385b6990465f2ccc9e8e0266105a31f72e2b466ae247f846ec3ef28"},
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [: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", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
"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.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [: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", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [: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]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, 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", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [: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", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
"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.25.3", "c181e353138807e49cc761619466a4b2219714018d8af2e409f67786dfb132fa", [: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.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.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", "cc5d2cd7f24a3ee4249e02a60a0a5c2519d396a8b8798e1ec44ea78714d68d85"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
}
View File
@@ -0,0 +1,158 @@
defmodule Tribes.Repo.Migrations.CreateTrustResourcesExtensions1 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 ash_required(value ANYCOMPATIBLE, payload jsonb)
RETURNS ANYCOMPATIBLE AS $$
BEGIN
IF value IS NULL THEN
RETURN ash_raise_error(payload, value);
END IF;
RETURN value;
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(), 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[]), ash_required(ANYCOMPATIBLE, jsonb)"
)
# execute("DROP EXTENSION IF EXISTS \"citext\"")
end
end
@@ -0,0 +1,113 @@
defmodule Tribes.Repo.Migrations.CreateTrustResources 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(:trust_tribe_relationships, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:remote_tribe_pubkey, :text, null: false)
add(:status, :text, null: false, default: "observed")
add(:remote_api_url, :text)
add(:remote_profile_url, :text)
add(:remote_relay_urls, {:array, :text}, null: false, default: [])
add(:remote_capabilities, {:array, :text}, null: false, default: [])
add(:requested_capabilities, {:array, :text}, null: false, default: [])
add(:granted_capabilities, {:array, :text}, null: false, default: [])
add(:trust_score, :bigint, null: false, default: 0)
add(:trust_note, :text)
add(:inbound_message, :text)
add(:last_inbound_at, :utc_datetime)
add(:last_outbound_at, :utc_datetime)
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(:trust_tribe_relationships, [:trust_score], where: "deleted_at IS NULL"))
create(index(:trust_tribe_relationships, [:status], where: "deleted_at IS NULL"))
create(
index(:trust_tribe_relationships, [:remote_tribe_pubkey],
unique: true,
where: "deleted_at IS NULL"
)
)
create(
unique_index(:trust_tribe_relationships, [:remote_tribe_pubkey],
where: "(deleted_at IS NULL)",
name: "trust_tribe_relationships_unique_remote_tribe_pubkey_index"
)
)
create table(:trust_remote_tribes, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:pubkey, :text, null: false)
add(:name, :text)
add(:description, :text)
add(:last_seen_at, :utc_datetime)
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(:trust_remote_tribes, [:pubkey], unique: true, where: "deleted_at IS NULL"))
create(
unique_index(:trust_remote_tribes, [:pubkey],
where: "(deleted_at IS NULL)",
name: "trust_remote_tribes_unique_pubkey_index"
)
)
end
def down do
drop_if_exists(
unique_index(:trust_remote_tribes, [:pubkey],
name: "trust_remote_tribes_unique_pubkey_index"
)
)
drop_if_exists(index(:trust_remote_tribes, [:pubkey]))
drop(table(:trust_remote_tribes))
drop_if_exists(
unique_index(:trust_tribe_relationships, [:remote_tribe_pubkey],
name: "trust_tribe_relationships_unique_remote_tribe_pubkey_index"
)
)
drop_if_exists(index(:trust_tribe_relationships, [:remote_tribe_pubkey]))
drop_if_exists(index(:trust_tribe_relationships, [:status]))
drop_if_exists(index(:trust_tribe_relationships, [:trust_score]))
drop(table(:trust_tribe_relationships))
end
end
@@ -0,0 +1,7 @@
{
"ash_functions_version": 6,
"installed": [
"ash-functions",
"citext"
]
}
@@ -0,0 +1,154 @@
{
"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": "name",
"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?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_seen_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": "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": [
"pubkey"
],
"fields": [
{
"type": "atom",
"value": "pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": null
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "C71DB333F342D884DC0BE14D42FE6B95AE4E56E082AAAF7596F48F63A9989696",
"identities": [
{
"all_tenants?": false,
"base_filter": "deleted_at IS NULL",
"index_name": "trust_remote_tribes_unique_pubkey_index",
"keys": [
{
"type": "atom",
"value": "pubkey"
}
],
"name": "unique_pubkey",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "trust_remote_tribes"
}
@@ -0,0 +1,318 @@
{
"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": "remote_tribe_pubkey",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"observed\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "status",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "remote_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "remote_profile_url",
"type": "text"
},
{
"allow_nil?": false,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "remote_relay_urls",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "remote_capabilities",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "requested_capabilities",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "granted_capabilities",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "trust_score",
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "trust_note",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inbound_message",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_inbound_at",
"type": "utc_datetime"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_outbound_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": "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": [
"remote_tribe_pubkey"
],
"fields": [
{
"type": "atom",
"value": "remote_tribe_pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"status"
],
"fields": [
{
"type": "atom",
"value": "status"
}
],
"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": [
"trust_score"
],
"fields": [
{
"type": "atom",
"value": "trust_score"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "8F49418D629617A5E8D55FF6B3EC495A26E444F98016ECDCEC865A55D43CAEA4",
"identities": [
{
"all_tenants?": false,
"base_filter": "deleted_at IS NULL",
"index_name": "trust_tribe_relationships_unique_remote_tribe_pubkey_index",
"keys": [
{
"type": "atom",
"value": "remote_tribe_pubkey"
}
],
"name": "unique_remote_tribe_pubkey",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "trust_tribe_relationships"
}
View File
Executable
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
plugin validate
plugin test [mix test args...]
plugin precommit [mix precommit args...]
plugin ecto.migration <name> [mix ecto.gen.migration args...]
plugin ash.codegen <name> [mix ash_postgres.generate_migrations args...]
plugin smoke
plugin shell
EOF
}
fail() {
echo "plugin: $*" >&2
exit 1
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
plugin_root="$(cd "$script_dir/.." && pwd -P)"
host_root="${TRIBES_HOST_ROOT:-$plugin_root/../tribes}"
host_root="$(cd "$host_root" && pwd -P)"
host_script="$host_root/scripts/plugin"
json_field() {
local field="$1"
FIELD="$field" mix run --no-start -e '
field = System.fetch_env!("FIELD")
manifest = "manifest.json" |> File.read!() |> JSON.decode!()
value = Map.fetch!(manifest, field)
IO.write(value)
'
}
run_smoke() {
cd "$plugin_root"
mix compile
local otp_app
local entry_module
otp_app="$(json_field otp_app)"
entry_module="$(json_field entry_module)"
local beam_path="_build/dev/lib/$otp_app/ebin/Elixir.$entry_module.beam"
[[ -f "$beam_path" ]] || fail "expected entry module beam at $beam_path"
cd "$host_root"
PLUGIN_ROOT="$plugin_root" ENTRY_MODULE="$entry_module" mix run --no-start -e '
plugin_root = System.fetch_env!("PLUGIN_ROOT")
entry_module = System.fetch_env!("ENTRY_MODULE")
plugin_root
|> Path.join("_build/dev/lib/*/ebin")
|> Path.wildcard()
|> Enum.each(&(:code.add_patha(String.to_charlist(&1))))
module = String.to_atom("Elixir." <> entry_module)
case Code.ensure_loaded(module) do
{:module, ^module} ->
IO.puts("Loaded #{entry_module}")
other ->
raise "failed to load #{entry_module}: #{inspect(other)}"
end
'
}
command_name="${1:-}"
if [[ -z "$command_name" ]]; then
usage
exit 1
fi
shift
case "$command_name" in
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
-h | --help | help)
usage
exit 0
;;
*)
usage
fail "unknown command: $command_name"
;;
esac
[[ -x "$host_script" || -f "$host_script" ]] || fail "expected host plugin script at $host_script"
if [[ "$command_name" == "smoke" ]]; then
run_smoke "$@"
exit 0
fi
if [[ "${DEVENV_ROOT:-}" == "$plugin_root" ]]; then
command -v devenv >/dev/null 2>&1 || fail "devenv is required when running from the plugin devenv shell"
cd "$host_root"
exec devenv shell -- bash ./scripts/plugin "$command_name" "$plugin_root" "$@"
fi
exec bash "$host_script" "$command_name" "$plugin_root" "$@"
+1
View File
@@ -0,0 +1 @@
ExUnit.start()
+18
View File
@@ -0,0 +1,18 @@
defmodule TribeOne.TribesPlugin.Trust.HomePageTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Trust.Plugin
test "renders the trust page for signed-in tribe admins", %{signed_in_conn: conn} do
{:ok, view, html} = live(conn, "/trust")
assert html =~ "Trust"
assert has_element?(view, "#plugin-nav-trust", "Trust")
assert has_element?(view, "#trust-page")
assert has_element?(view, "#trust-observe-form")
end
test "dispatches top-level subpaths to the plugin page", %{signed_in_conn: conn} do
{:ok, _view, html} = live(conn, "/trust/example")
assert html =~ "Trust"
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule TribeOne.TribesPlugin.Trust.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Trust.Plugin
end
+40
View File
@@ -0,0 +1,40 @@
defmodule TribeOne.TribesPlugin.TrustTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Trust.Plugin
alias TribeOne.TribesPlugin.Trust
test "receive_hello stores a pending inbound relationship" do
pubkey = String.duplicate("a", 64)
assert {:ok, result} =
Trust.receive_hello(%{
"from_tribe" => pubkey,
"name" => "Remote Seeds",
"api_url" => "https://remote.example/api/tribes/v1",
"relay_urls" => ["wss://remote.example/relay"],
"capabilities" => ["org.tribes.kobold.dataset.discover@1"],
"requested_capabilities" => ["org.tribes.kobold.dataset.sync@1"],
"message" => "Can we mirror your catalog?"
})
assert result.tribe.pubkey == pubkey
assert result.tribe.name == "Remote Seeds"
assert result.relationship.remote_tribe_pubkey == pubkey
assert result.relationship.status == :pending_inbound
assert result.relationship.remote_api_url == "https://remote.example/api/tribes/v1"
assert result.relationship.remote_relay_urls == ["wss://remote.example/relay"]
assert result.relationship.requested_capabilities == ["org.tribes.kobold.dataset.sync@1"]
end
test "relationship trust score is bounded" do
pubkey = String.duplicate("b", 64)
assert {:ok, _result} = Trust.receive_hello(%{"from_tribe" => pubkey})
assert {:error, :invalid_trust_score} =
Trust.update_relationship(pubkey, %{"trust_score" => "101"})
assert {:ok, relationship} = Trust.update_relationship(pubkey, %{"trust_score" => "60"})
assert relationship.trust_score == 60
end
end