Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add reproducible build pipeline
Merged did:key:z6MkqkGM...vjpV opened 1 year ago
148 files changed +12938 -2311 6d87823c 06e33f4a
added .github/workflows/check-http-client-unit-test.yml
@@ -0,0 +1,21 @@
+
name: check-http-client-unit-test
+
on: push
+

+
jobs:
+
  check-http-client-unit-test:
+
    runs-on: ubuntu-latest
+
    steps:
+
      - name: Setup Node
+
        uses: actions/setup-node@v4
+
        with:
+
          node-version: "20.9.0"
+
      - name: Checkout
+
        uses: actions/checkout@v4
+
      - run: npm ci
+
      - name: Install Radicle binaries
+
        run: |
+
          mkdir -p tests/artifacts;
+
          ./scripts/install-binaries;
+
          ./scripts/install-binaries --show-path >> $GITHUB_PATH;
+
      - run: |
+
          npm run test:http-client:unit
added .github/workflows/check-http-server.yml
@@ -0,0 +1,41 @@
+
name: check-http-server
+
on: push
+

+
jobs:
+
  test:
+
    name: Build & Test
+
    runs-on: ubuntu-latest
+
    defaults:
+
      run:
+
        working-directory: ./http-server
+
    steps:
+
      - uses: actions/checkout@v4
+
      - uses: dtolnay/rust-toolchain@stable
+
      - name: Build
+
        run: cargo build --all-features
+
      - name: Run tests
+
        run: cargo test --all-features
+
  docs:
+
    name: Docs
+
    runs-on: ubuntu-latest
+
    defaults:
+
      run:
+
        working-directory: ./http-server
+
    steps:
+
      - uses: actions/checkout@v4
+
      - uses: dtolnay/rust-toolchain@stable
+
      - name: Docs
+
        run: cargo doc --all --all-features
+
  lint:
+
    name: Lint
+
    runs-on: ubuntu-latest
+
    defaults:
+
      run:
+
        working-directory: ./http-server
+
    steps:
+
      - uses: actions/checkout@v4
+
      - uses: dtolnay/rust-toolchain@stable
+
      - name: Run clippy
+
        run: cargo clippy --all --tests
+
      - name: Check formatting
+
        run: cargo fmt --all --check
deleted .github/workflows/check-httpd-api-unit-test.yml
@@ -1,21 +0,0 @@
-
name: check-httpd-api-unit-test
-
on: push
-

-
jobs:
-
  check-httpd-api-unit-test:
-
    runs-on: ubuntu-latest
-
    steps:
-
      - name: Setup Node
-
        uses: actions/setup-node@v4
-
        with:
-
          node-version: "20.9.0"
-
      - name: Checkout
-
        uses: actions/checkout@v4
-
      - run: npm ci
-
      - name: Install Radicle binaries
-
        run: |
-
          mkdir -p tests/artifacts;
-
          ./scripts/install-binaries;
-
          ./scripts/install-binaries --show-path >> $GITHUB_PATH;
-
      - run: |
-
          npm run test:httpd-api:unit
modified .gitignore
@@ -2,6 +2,8 @@
node_modules/
NOTES
config/local*
+
http-server/target
+
http-server/build/artifacts

# KaTeX files
*.min.css
modified eslint.config.js
@@ -133,6 +133,11 @@ export default [
    },
  },
  {
-
    ignores: ["node_modules/**/*", "build/**/*", "public/**/*"],
+
    ignores: [
+
      "node_modules/**/*",
+
      "build/**/*",
+
      "public/**/*",
+
      "http-server/**/*",
+
    ],
  },
];
modified flake.nix
@@ -93,7 +93,7 @@
            scripts/check
            {
              npm run test:unit
-
              npm run test:httpd-api:unit
+
              npm run test:http-client:unit
            } | tee /dev/null
            runHook postCheck
          '';
added http-client/index.ts
@@ -0,0 +1,248 @@
+
import type { BaseUrl } from "./lib/fetcher.js";
+
import type {
+
  Blob,
+
  DiffResponse,
+
  Project,
+
  ProjectListQuery,
+
  Remote,
+
  Tree,
+
  TreeStats,
+
} from "./lib/project.js";
+
import type {
+
  SuccessResponse,
+
  CodeLocation,
+
  Range,
+
  Policy,
+
  Scope,
+
} from "./lib/shared.js";
+
import type { Comment, Embed, Reaction } from "./lib/project/comment.js";
+
import type {
+
  Commit,
+
  CommitBlob,
+
  CommitHeader,
+
  ChangesetWithDiff,
+
  ChangesetWithoutDiff,
+
  Diff,
+
  DiffBlob,
+
  DiffContent,
+
  DiffFile,
+
  HunkLine,
+
} from "./lib/project/commit.js";
+
import type { Issue, IssueState } from "./lib/project/issue.js";
+
import type {
+
  LifecycleState,
+
  Merge,
+
  Patch,
+
  PatchState,
+
  PatchUpdateAction,
+
  Review,
+
  Revision,
+
  Verdict,
+
} from "./lib/project/patch.js";
+
import type { RequestOptions } from "./lib/fetcher.js";
+
import type { ZodSchema } from "zod";
+

+
import { z, array, literal, number, object, string, union } from "zod";
+

+
import * as project from "./lib/project.js";
+
import * as profile from "./lib/profile.js";
+
import * as session from "./lib/session.js";
+
import { Fetcher } from "./lib/fetcher.js";
+
import { nodeConfigSchema, successResponseSchema } from "./lib/shared.js";
+

+
export type {
+
  BaseUrl,
+
  Blob,
+
  ChangesetWithDiff,
+
  ChangesetWithoutDiff,
+
  CodeLocation,
+
  Comment,
+
  Commit,
+
  CommitBlob,
+
  CommitHeader,
+
  Diff,
+
  DiffBlob,
+
  DiffContent,
+
  DiffFile,
+
  DiffResponse,
+
  Embed,
+
  HunkLine,
+
  Issue,
+
  IssueState,
+
  LifecycleState,
+
  Merge,
+
  Patch,
+
  PatchState,
+
  PatchUpdateAction,
+
  Policy,
+
  Project,
+
  ProjectListQuery,
+
  Range,
+
  Reaction,
+
  Remote,
+
  Review,
+
  Revision,
+
  Scope,
+
  TreeStats,
+
  Tree,
+
  Verdict,
+
};
+

+
export type Node = z.infer<typeof nodeSchema>;
+

+
const nodeSchema = object({
+
  id: string(),
+
  version: string(),
+
  config: nodeConfigSchema.nullable(),
+
  state: union([literal("running"), literal("stopped")]),
+
});
+

+
export type NodeInfo = z.infer<typeof nodeInfoSchema>;
+

+
const nodeInfoSchema = object({
+
  message: string(),
+
  service: string(),
+
  version: string(),
+
  apiVersion: string(),
+
  nid: string(),
+
  path: string(),
+
  links: array(
+
    object({
+
      href: string(),
+
      rel: string(),
+
      type: union([
+
        literal("GET"),
+
        literal("POST"),
+
        literal("PUT"),
+
        literal("DELETE"),
+
      ]),
+
    }),
+
  ),
+
});
+

+
export type NodeTracking = z.infer<typeof nodeTrackingSchema>;
+

+
const nodeTrackingSchema = array(
+
  object({
+
    id: string(),
+
    scope: string(),
+
    policy: string(),
+
  }),
+
);
+

+
export interface NodeStats {
+
  repos: { total: number };
+
}
+

+
const nodeStatsSchema = object({
+
  repos: object({ total: number() }),
+
}) satisfies ZodSchema<NodeStats>;
+

+
export class HttpdClient {
+
  #fetcher: Fetcher;
+

+
  public baseUrl: BaseUrl;
+
  public project: project.Client;
+
  public profile: profile.Client;
+
  public session: session.Client;
+

+
  public constructor(baseUrl: BaseUrl) {
+
    this.baseUrl = baseUrl;
+
    this.#fetcher = new Fetcher(this.baseUrl);
+

+
    this.project = new project.Client(this.#fetcher);
+
    this.profile = new profile.Client(this.#fetcher);
+
    this.session = new session.Client(this.#fetcher);
+
  }
+

+
  public changePort(port: number): void {
+
    this.baseUrl.port = port;
+
  }
+

+
  public get url(): string {
+
    return `${this.baseUrl.scheme}://${this.baseUrl.hostname}:${this.baseUrl.port}`;
+
  }
+

+
  public get hostname(): string {
+
    return this.baseUrl.hostname;
+
  }
+

+
  public get port(): string {
+
    return this.baseUrl.port.toString();
+
  }
+

+
  public async getNodeInfo(options?: RequestOptions): Promise<NodeInfo> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        options,
+
      },
+
      nodeInfoSchema,
+
    );
+
  }
+

+
  public async getStats(options?: RequestOptions): Promise<NodeStats> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "stats",
+
        options,
+
      },
+
      nodeStatsSchema,
+
    );
+
  }
+

+
  public async getTracking(options?: RequestOptions): Promise<NodeTracking> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "node/policies/repos",
+
        options,
+
      },
+
      nodeTrackingSchema,
+
    );
+
  }
+

+
  public async seedById(
+
    id: string,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PUT",
+
        path: `node/policies/repos/${id}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async stopSeedingById(
+
    id: string,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "DELETE",
+
        path: `node/policies/repos/${id}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async getNode(options?: RequestOptions): Promise<Node> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "node",
+
        options,
+
      },
+
      nodeSchema,
+
    );
+
  }
+
}
added http-client/lib/fetcher.ts
@@ -0,0 +1,182 @@
+
// This module provides low-level capabilities to interact with a typed
+
// JSON HTTP API.
+

+
import type { ZodIssue, ZodType, TypeOf } from "zod";
+

+
import config from "virtual:config";
+
import { compare } from "compare-versions";
+

+
export interface BaseUrl {
+
  hostname: string;
+
  port: number;
+
  scheme: string;
+
}
+

+
// Error that is thrown by `Fetcher` methods.
+
export class ResponseError extends Error {
+
  public method: string;
+
  public url: string;
+
  public status: number;
+
  public body: unknown;
+

+
  public constructor(method: string, response: Response, body_: unknown) {
+
    const body: unknown = body_;
+
    if (
+
      typeof body === "object" &&
+
      body !== null &&
+
      "message" in body &&
+
      typeof body.message === "string"
+
    ) {
+
      super(body.message);
+
    } else {
+
      super("Response error");
+
    }
+

+
    this.method = method;
+
    this.body = body_;
+
    this.status = response.status;
+
    this.url = response.url;
+
  }
+
}
+

+
// Error that is thrown by `Fetcher` methods when parsing the response
+
// body fails.
+
export class ResponseParseError extends Error {
+
  public method: string;
+
  public body: unknown;
+
  public description: string;
+
  public apiVersion: string | undefined;
+
  public zodIssues: ZodIssue[];
+
  public path?: string;
+

+
  public constructor(
+
    method: string,
+
    body: unknown,
+
    apiVersion: string | undefined,
+
    zodIssues: ZodIssue[],
+
    path?: string,
+
  ) {
+
    super("Failed to parse response body");
+

+
    let description: string;
+
    if (
+
      apiVersion === undefined ||
+
      compare(apiVersion, config.nodes.apiVersion, "<")
+
    ) {
+
      description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.nodes.apiVersion} currently ${apiVersion ?? "unknown"}.`;
+
    } else if (
+
      config.nodes.apiVersion === undefined ||
+
      compare(apiVersion, config.nodes.apiVersion, ">")
+
    ) {
+
      description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.nodes.apiVersion ?? "unknown"}.`;
+
    } else {
+
      description =
+
        "This is usually due to a version mismatch between the seed and the web interface.";
+
    }
+
    this.apiVersion = apiVersion;
+
    this.description =
+
      "The response received from the seed does not match the expected schema.<br/>".concat(
+
        description,
+
      );
+

+
    this.method = method;
+
    this.path = path;
+
    this.body = body;
+
    this.zodIssues = zodIssues;
+
  }
+
}
+

+
export interface RequestOptions {
+
  abort?: AbortSignal;
+
}
+

+
export interface FetchParams {
+
  method: Method;
+
  // Path to append to the `Fetcher`s base URL to get the final URL.
+
  path?: string;
+
  // Object that is serialized into JSON and sent as the data.
+
  body?: unknown;
+
  // Query parameters to be serialized with URLSearchParams.
+
  query?: Record<string, string | number | boolean>;
+
  options?: RequestOptions;
+
  headers?: Record<string, string>;
+
}
+

+
type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
+

+
export class Fetcher {
+
  #baseUrl: BaseUrl;
+

+
  public constructor(baseUrl: BaseUrl) {
+
    this.#baseUrl = baseUrl;
+
  }
+

+
  // Execute a fetch and parse the result with the provided schema.
+
  // Return the parsed payload.
+
  //
+
  // Throws `ResponseError` if the response status code is not `200`.
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  public async fetchOk<T extends ZodType<any>>(
+
    params: FetchParams,
+
    schema: T,
+
  ): Promise<TypeOf<T>> {
+
    const response = await this.fetch({
+
      ...params,
+
      query: { ...params.query, v: config.nodes.apiVersion },
+
    });
+

+
    if (!response.ok) {
+
      let responseBody = await response.text();
+
      try {
+
        responseBody = JSON.parse(responseBody);
+
      } catch (_e: unknown) {
+
        // We keep the original text response body.
+
      }
+
      throw new ResponseError(params.method, response, responseBody);
+
    }
+

+
    const responseBody = await response.json();
+
    const result = schema.safeParse(responseBody);
+
    if (result.success) {
+
      return result.data;
+
    } else {
+
      const response = await this.fetch({ method: "GET" });
+
      const info = await response.json();
+
      throw new ResponseParseError(
+
        params.method,
+
        responseBody,
+
        info.apiVersion,
+
        result.error.errors,
+
        params.path,
+
      );
+
    }
+
  }
+

+
  private async fetch({
+
    method,
+
    path,
+
    body,
+
    options = {},
+
    query,
+
    headers = {},
+
  }: FetchParams): Promise<Response> {
+
    if (body !== undefined && headers["content-type"] === undefined) {
+
      headers["content-type"] = "application/json";
+
    }
+

+
    const pathSegment = path === undefined ? "" : `/${path}`;
+

+
    let url = `${this.#baseUrl.scheme}://${this.#baseUrl.hostname}:${this.#baseUrl.port}/api/v1${pathSegment}`;
+

+
    if (query) {
+
      const searchparams = new URLSearchParams(query as Record<string, string>);
+
      url = `${url}?${searchparams.toString()}`;
+
    }
+
    return globalThis.fetch(url, {
+
      method,
+
      headers,
+
      body: body === undefined ? undefined : JSON.stringify(body),
+
      signal: options.abort,
+
    });
+
  }
+
}
added http-client/lib/profile.ts
@@ -0,0 +1,36 @@
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { z } from "zod";
+

+
import { array, boolean, object, string } from "zod";
+
import { nodeConfigSchema } from "./shared.js";
+

+
const profileSchema = object({
+
  config: object({
+
    publicExplorer: string(),
+
    preferredSeeds: array(string()),
+
    cli: object({ hints: boolean() }),
+
    node: nodeConfigSchema,
+
  }),
+
  home: string(),
+
});
+

+
export type Profile = z.infer<typeof profileSchema>;
+

+
export class Client {
+
  #fetcher: Fetcher;
+

+
  public constructor(fetcher: Fetcher) {
+
    this.#fetcher = fetcher;
+
  }
+

+
  public async getProfile(options?: RequestOptions): Promise<Profile> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `profile`,
+
        options,
+
      },
+
      profileSchema,
+
    );
+
  }
+
}
added http-client/lib/project.ts
@@ -0,0 +1,503 @@
+
import type { Commit, Commits } from "./project/commit.js";
+
import type { Embed } from "./project/comment.js";
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type {
+
  Issue,
+
  IssueCreated,
+
  IssueUpdateAction,
+
} from "./project/issue.js";
+
import type {
+
  Patch,
+
  PatchCreate,
+
  PatchCreated,
+
  PatchUpdateAction,
+
} from "./project/patch.js";
+
import type { SuccessResponse } from "./shared.js";
+
import type { ZodSchema } from "zod";
+

+
import { successResponseSchema } from "./shared.js";
+
import {
+
  array,
+
  boolean,
+
  literal,
+
  number,
+
  optional,
+
  record,
+
  object,
+
  string,
+
  union,
+
  z,
+
} from "zod";
+

+
import {
+
  diffBlobSchema,
+
  commitHeaderSchema,
+
  commitSchema,
+
  commitsSchema,
+
  diffSchema,
+
} from "./project/commit.js";
+

+
import {
+
  issueCreatedSchema,
+
  issueSchema,
+
  issuesSchema,
+
} from "./project/issue.js";
+

+
import {
+
  patchSchema,
+
  patchesSchema,
+
  patchCreatedSchema,
+
} from "./project/patch.js";
+

+
const projectSchema = object({
+
  id: string(),
+
  name: string(),
+
  description: string(),
+
  defaultBranch: string(),
+
  delegates: array(object({ id: string(), alias: optional(string()) })),
+
  head: string(),
+
  threshold: number(),
+
  visibility: union([
+
    object({ type: literal("public") }),
+
    object({ type: literal("private"), allow: optional(array(string())) }),
+
  ]).optional(),
+
  patches: object({
+
    open: number(),
+
    draft: number(),
+
    archived: number(),
+
    merged: number(),
+
  }),
+
  issues: object({
+
    open: number(),
+
    closed: number(),
+
  }),
+
  seeding: number(),
+
});
+
const projectsSchema = array(projectSchema);
+

+
export type Project = z.infer<typeof projectSchema>;
+

+
const activitySchema = object({
+
  activity: array(number()),
+
});
+

+
export type Activity = z.infer<typeof activitySchema>;
+

+
const blobSchema = object({
+
  binary: boolean(),
+
  content: optional(string()),
+
  name: string(),
+
  path: string(),
+
  lastCommit: commitHeaderSchema,
+
});
+

+
export type Blob = z.infer<typeof blobSchema>;
+

+
const treeEntrySchema = object({
+
  path: string(),
+
  name: string(),
+
  oid: string(),
+
  kind: union([literal("blob"), literal("tree"), literal("submodule")]),
+
});
+

+
export type TreeEntry = z.infer<typeof treeEntrySchema>;
+

+
const treeStatsSchema = object({
+
  commits: number(),
+
  branches: number(),
+
  contributors: number(),
+
});
+

+
export type TreeStats = z.infer<typeof treeStatsSchema>;
+

+
export type Tree = z.infer<typeof treeSchema>;
+

+
const treeSchema = object({
+
  entries: array(treeEntrySchema),
+
  lastCommit: commitHeaderSchema,
+
  name: string(),
+
  path: string(),
+
});
+

+
export type Remote = z.infer<typeof remoteSchema>;
+

+
const remoteSchema = object({
+
  id: string(),
+
  alias: string().optional(),
+
  heads: record(string(), string()),
+
  delegate: boolean(),
+
});
+

+
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
+

+
export type DiffResponse = z.infer<typeof diffResponseSchema>;
+

+
const diffResponseSchema = object({
+
  commits: array(commitHeaderSchema),
+
  diff: diffSchema,
+
  files: record(string(), diffBlobSchema),
+
});
+

+
export type ProjectListQuery = {
+
  page?: number;
+
  perPage?: number;
+
  show?: "pinned" | "all";
+
};
+
export class Client {
+
  #fetcher: Fetcher;
+

+
  public constructor(fetcher: Fetcher) {
+
    this.#fetcher = fetcher;
+
  }
+

+
  public async getByDelegate(
+
    delegateId: string,
+
    options?: RequestOptions,
+
  ): Promise<Project[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `delegates/${delegateId}/projects`,
+
        options,
+
      },
+
      projectsSchema,
+
    );
+
  }
+

+
  public async getById(id: string, options?: RequestOptions): Promise<Project> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}`,
+
        options,
+
      },
+
      projectSchema,
+
    );
+
  }
+

+
  public async getAll(
+
    query?: ProjectListQuery,
+
    options?: RequestOptions,
+
  ): Promise<Project[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "projects",
+
        query,
+
        options,
+
      },
+
      projectsSchema,
+
    );
+
  }
+

+
  public async getActivity(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Activity> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/activity`,
+
        options,
+
      },
+
      activitySchema,
+
    );
+
  }
+

+
  public async getReadme(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<Blob> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/readme/${sha}`,
+
        options,
+
      },
+
      blobSchema,
+
    );
+
  }
+

+
  public async getBlob(
+
    id: string,
+
    sha: string,
+
    path: string,
+
    options?: RequestOptions,
+
  ): Promise<Blob> {
+
    const blob = await this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/blob/${sha}/${path}`,
+
        options,
+
      },
+
      blobSchema,
+
    );
+
    return blob;
+
  }
+

+
  public async getTree(
+
    id: string,
+
    sha: string,
+
    path?: string,
+
    options?: RequestOptions,
+
  ): Promise<Tree> {
+
    const tree = await this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/tree/${sha}/${path ?? ""}`,
+
        options,
+
      },
+
      treeSchema,
+
    );
+
    return tree;
+
  }
+

+
  public async getTreeStatsBySha(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<TreeStats> {
+
    const tree = await this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/stats/tree/${sha}`,
+
        options,
+
      },
+
      treeStatsSchema,
+
    );
+
    return tree;
+
  }
+

+
  public async getAllRemotes(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<Remote[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/remotes`,
+
        options,
+
      },
+
      remotesSchema,
+
    );
+
  }
+

+
  public async getRemoteByPeer(
+
    id: string,
+
    peer: string,
+
    options?: RequestOptions,
+
  ): Promise<Remote> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/remotes/${peer}`,
+
        options,
+
      },
+
      remoteSchema,
+
    );
+
  }
+

+
  public async getAllCommits(
+
    id: string,
+
    query?: {
+
      parent?: string;
+
      since?: number;
+
      until?: number;
+
      page?: number;
+
      perPage?: number;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<Commits> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/commits`,
+
        query,
+
        options,
+
      },
+
      commitsSchema,
+
    );
+
  }
+

+
  public async getCommitBySha(
+
    id: string,
+
    sha: string,
+
    options?: RequestOptions,
+
  ): Promise<Commit> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/commits/${sha}`,
+
        options,
+
      },
+
      commitSchema,
+
    );
+
  }
+

+
  public async getDiff(
+
    id: string,
+
    revisionBase: string,
+
    revisionOid: string,
+
    options?: RequestOptions,
+
  ): Promise<DiffResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/diff/${revisionBase}/${revisionOid}`,
+
        options,
+
      },
+
      diffResponseSchema,
+
    );
+
  }
+

+
  public async getIssueById(
+
    id: string,
+
    issueId: string,
+
    options?: RequestOptions,
+
  ): Promise<Issue> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/issues/${issueId}`,
+
        options,
+
      },
+
      issueSchema,
+
    );
+
  }
+

+
  public async getAllIssues(
+
    id: string,
+
    query?: {
+
      page?: number;
+
      perPage?: number;
+
      state?: string;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<Issue[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/issues`,
+
        query,
+
        options,
+
      },
+
      issuesSchema,
+
    );
+
  }
+

+
  public async createIssue(
+
    id: string,
+
    body: {
+
      title: string;
+
      description: string;
+
      assignees: string[];
+
      embeds: Embed[];
+
      labels: string[];
+
    },
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<IssueCreated> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "POST",
+
        path: `projects/${id}/issues`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      issueCreatedSchema,
+
    );
+
  }
+

+
  public async updateIssue(
+
    id: string,
+
    issueId: string,
+
    body: IssueUpdateAction,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PATCH",
+
        path: `projects/${id}/issues/${issueId}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async getPatchById(
+
    id: string,
+
    patchId: string,
+
    options?: RequestOptions,
+
  ): Promise<Patch> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/patches/${patchId}`,
+
        options,
+
      },
+
      patchSchema,
+
    );
+
  }
+

+
  public async getAllPatches(
+
    id: string,
+
    query?: {
+
      page?: number;
+
      perPage?: number;
+
      state?: string;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<Patch[]> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `projects/${id}/patches`,
+
        query,
+
        options,
+
      },
+
      patchesSchema,
+
    );
+
  }
+

+
  public async createPatch(
+
    id: string,
+
    body: PatchCreate,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<PatchCreated> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "POST",
+
        path: `projects/${id}/patches`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      patchCreatedSchema,
+
    );
+
  }
+

+
  public async updatePatch(
+
    id: string,
+
    patchId: string,
+
    body: PatchUpdateAction,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PATCH",
+
        path: `projects/${id}/patches/${patchId}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+
}
added http-client/lib/project/comment.ts
@@ -0,0 +1,27 @@
+
import type { z } from "zod";
+
import { array, boolean, number, object, string } from "zod";
+
import { authorSchema, codeLocationSchema } from "../shared";
+

+
export type Comment = z.infer<typeof commentSchema>;
+
export type Embed = Comment["embeds"][0];
+
export type Reaction = Comment["reactions"][0];
+

+
export const commentSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  body: string(),
+
  edits: array(
+
    object({
+
      author: authorSchema,
+
      body: string(),
+
      embeds: array(object({ name: string(), content: string() })),
+
      timestamp: number(),
+
    }),
+
  ),
+
  embeds: array(object({ name: string(), content: string() })),
+
  reactions: array(object({ emoji: string(), authors: array(authorSchema) })),
+
  timestamp: number(),
+
  location: codeLocationSchema.nullable().optional(),
+
  resolved: boolean(),
+
  replyTo: string().nullable(),
+
});
added http-client/lib/project/commit.ts
@@ -0,0 +1,239 @@
+
import type { z } from "zod";
+
export type {
+
  Commit,
+
  CommitHeader,
+
  Commits,
+
  Diff,
+
  DiffContent,
+
  DiffFile,
+
  ChangesetWithDiff,
+
  ChangesetWithoutDiff,
+
  HunkLine,
+
  Hunks,
+
};
+

+
import {
+
  array,
+
  boolean,
+
  discriminatedUnion,
+
  literal,
+
  number,
+
  object,
+
  record,
+
  string,
+
  union,
+
} from "zod";
+
export {
+
  commitBlobSchema,
+
  commitHeaderSchema,
+
  commitSchema,
+
  commitsSchema,
+
  diffBlobSchema,
+
  diffSchema,
+
};
+

+
const gitPersonSchema = object({
+
  name: string(),
+
  email: string(),
+
});
+

+
type CommitHeader = z.infer<typeof commitHeaderSchema>;
+

+
const commitHeaderSchema = object({
+
  id: string(),
+
  author: gitPersonSchema,
+
  summary: string(),
+
  description: string(),
+
  parents: array(string()),
+
  committer: gitPersonSchema.merge(object({ time: number() })),
+
});
+

+
const diffBlobSchema = object({
+
  binary: boolean(),
+
  content: string(),
+
  id: string(),
+
});
+

+
export type DiffBlob = z.infer<typeof diffBlobSchema>;
+

+
const commitBlobSchema = object({
+
  binary: boolean(),
+
  content: string(),
+
});
+

+
export type CommitBlob = z.infer<typeof commitBlobSchema>;
+

+
type AdditionHunkLine = z.infer<typeof additionHunkLineSchema>;
+

+
const additionHunkLineSchema = object({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("addition"),
+
});
+

+
type DeletionHunkLine = z.infer<typeof deletionHunkLineSchema>;
+

+
const deletionHunkLineSchema = object({
+
  line: string(),
+
  lineNo: number(),
+
  type: literal("deletion"),
+
});
+

+
type DiffFile = z.infer<typeof diffFileSchema>;
+

+
const diffFileSchema = object({
+
  oid: string(),
+
  mode: union([
+
    literal("blob"),
+
    literal("blobExecutable"),
+
    literal("tree"),
+
    literal("link"),
+
    literal("commit"),
+
  ]),
+
});
+

+
type ContextHunkLine = z.infer<typeof contextHunkLineSchema>;
+

+
const contextHunkLineSchema = object({
+
  line: string(),
+
  lineNoNew: number(),
+
  lineNoOld: number(),
+
  type: literal("context"),
+
});
+

+
type HunkLine = AdditionHunkLine | DeletionHunkLine | ContextHunkLine;
+

+
const hunkLineSchema = union([
+
  additionHunkLineSchema,
+
  deletionHunkLineSchema,
+
  contextHunkLineSchema,
+
]);
+

+
type Hunks = z.infer<typeof changesetHunkSchema>;
+

+
const changesetHunkSchema = object({
+
  header: string(),
+
  lines: array(hunkLineSchema),
+
});
+

+
type DiffContent = z.infer<typeof diffContentSchema>;
+

+
const diffContentSchema = discriminatedUnion("type", [
+
  object({
+
    type: literal("plain"),
+
    stats: object({
+
      additions: number(),
+
      deletions: number(),
+
    }),
+
    hunks: array(changesetHunkSchema),
+
    eof: union([
+
      literal("noneMissing"),
+
      literal("oldMissing"),
+
      literal("newMissing"),
+
      literal("bothMissing"),
+
    ]),
+
  }),
+
  object({ type: literal("binary") }),
+
  object({ type: literal("empty") }),
+
]);
+
const diffChangesetSchema = object({
+
  path: string(),
+
  diff: diffContentSchema,
+
});
+

+
const diffAddedChangesetSchema = diffChangesetSchema.merge(
+
  object({ state: literal("added"), new: diffFileSchema }),
+
);
+

+
const diffDeletedChangesetSchema = diffChangesetSchema.merge(
+
  object({ state: literal("deleted"), old: diffFileSchema }),
+
);
+

+
const diffModifiedChangesetSchema = diffChangesetSchema.merge(
+
  object({
+
    state: literal("modified"),
+
    new: diffFileSchema,
+
    old: diffFileSchema,
+
  }),
+
);
+

+
const diffCopiedChangesetSchema = object({
+
  state: literal("copied"),
+
  newPath: string(),
+
  oldPath: string(),
+
});
+

+
const diffCopiedWithModificationsChangesetSchema =
+
  diffCopiedChangesetSchema.merge(
+
    object({
+
      old: diffFileSchema,
+
      new: diffFileSchema,
+
      diff: diffContentSchema,
+
    }),
+
  );
+

+
const diffMovedChangesetSchema = object({
+
  state: literal("moved"),
+
  newPath: string(),
+
  oldPath: string(),
+
  current: diffFileSchema,
+
});
+

+
const diffMovedWithModificationsChangesetSchema = diffMovedChangesetSchema
+
  .omit({ current: true })
+
  .merge(
+
    object({
+
      old: diffFileSchema,
+
      new: diffFileSchema,
+
      diff: diffContentSchema,
+
    }),
+
  );
+

+
type Diff = z.infer<typeof diffSchema>;
+

+
type ChangesetWithDiff = z.infer<typeof changesetWithDiffSchema>;
+
type ChangesetWithoutDiff = z.infer<typeof changesetWithoutDiffSchema>;
+

+
const changesetWithDiffSchema = union([
+
  diffAddedChangesetSchema,
+
  diffDeletedChangesetSchema,
+
  diffModifiedChangesetSchema,
+
  diffMovedWithModificationsChangesetSchema,
+
  diffCopiedWithModificationsChangesetSchema,
+
]);
+
const changesetWithoutDiffSchema = union([
+
  diffMovedChangesetSchema,
+
  diffCopiedChangesetSchema,
+
]);
+

+
const diffSchema = object({
+
  files: array(
+
    union([
+
      diffAddedChangesetSchema,
+
      diffDeletedChangesetSchema,
+
      diffModifiedChangesetSchema,
+
      diffMovedChangesetSchema,
+
      diffMovedWithModificationsChangesetSchema,
+
      diffCopiedChangesetSchema,
+
      diffCopiedWithModificationsChangesetSchema,
+
    ]),
+
  ),
+
  stats: object({
+
    filesChanged: number(),
+
    insertions: number(),
+
    deletions: number(),
+
  }),
+
});
+

+
type Commit = z.infer<typeof commitSchema>;
+

+
const commitSchema = object({
+
  commit: commitHeaderSchema,
+
  diff: diffSchema,
+
  branches: array(string()),
+
  files: record(string(), commitBlobSchema),
+
});
+

+
type Commits = z.infer<typeof commitsSchema>;
+

+
const commitsSchema = array(commitHeaderSchema);
added http-client/lib/project/issue.ts
@@ -0,0 +1,70 @@
+
import type { Embed } from "./comment.js";
+
import type { ZodSchema, z } from "zod";
+
import { array, boolean, literal, object, string, union } from "zod";
+

+
import { commentSchema } from "./comment.js";
+
import { authorSchema } from "../shared.js";
+

+
export type IssueState =
+
  | { status: "open" }
+
  | { status: "closed"; reason: "other" | "solved" };
+

+
const issueStateSchema = union([
+
  object({ status: literal("open") }),
+
  object({
+
    status: literal("closed"),
+
    reason: union([literal("other"), literal("solved")]),
+
  }),
+
]) satisfies ZodSchema<IssueState>;
+

+
export const issueSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  title: string(),
+
  state: issueStateSchema,
+
  discussion: array(commentSchema),
+
  labels: array(string()),
+
  assignees: array(authorSchema),
+
});
+

+
export type Issue = z.infer<typeof issueSchema>;
+

+
export interface IssueCreated {
+
  success: boolean;
+
  id: string;
+
}
+

+
export const issueCreatedSchema = object({
+
  success: boolean(),
+
  id: string(),
+
}) satisfies ZodSchema<IssueCreated>;
+

+
export const issuesSchema = array(issueSchema) satisfies ZodSchema<Issue[]>;
+

+
export type IssueUpdateAction =
+
  | { type: "edit"; title: string }
+
  | { type: "label"; labels: string[] }
+
  | {
+
      type: "assign";
+
      assignees: string[];
+
    }
+
  | { type: "lifecycle"; state: IssueState }
+
  | {
+
      type: "comment";
+
      body: string;
+
      embeds?: Embed[];
+
      replyTo?: string;
+
    }
+
  | {
+
      type: "comment.edit";
+
      id: string;
+
      body: string;
+
      embeds: Embed[];
+
    }
+
  | { type: "comment.redact"; id: string }
+
  | {
+
      type: "comment.react";
+
      id: string;
+
      reaction: string;
+
      active: boolean;
+
    };
added http-client/lib/project/patch.ts
@@ -0,0 +1,209 @@
+
import type { Embed } from "./comment.js";
+
import type { ZodSchema, z } from "zod";
+
import type { CodeLocation } from "../shared.js";
+

+
import { commentSchema } from "./comment.js";
+

+
import {
+
  array,
+
  boolean,
+
  literal,
+
  number,
+
  optional,
+
  object,
+
  string,
+
  tuple,
+
  union,
+
} from "zod";
+
import { authorSchema } from "../shared.js";
+

+
export type PatchState = z.infer<typeof patchStateSchema>;
+

+
const patchStateSchema = union([
+
  object({
+
    status: literal("draft"),
+
  }),
+
  object({
+
    status: literal("open"),
+
    conflicts: array(tuple([string(), string()])).optional(),
+
  }),
+
  object({
+
    status: literal("archived"),
+
  }),
+
  object({
+
    status: literal("merged"),
+
    revision: string(),
+
    commit: string(),
+
  }),
+
]);
+

+
export type Merge = z.infer<typeof mergeSchema>;
+

+
const mergeSchema = object({
+
  author: authorSchema,
+
  revision: string(),
+
  commit: string(),
+
  timestamp: number(),
+
});
+

+
export type Verdict = "accept" | "reject";
+

+
const reviewSchema = object({
+
  author: authorSchema,
+
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
+
  comments: array(commentSchema),
+
  summary: string().nullable(),
+
  timestamp: number(),
+
});
+

+
export type Review = z.infer<typeof reviewSchema>;
+

+
const revisionSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  description: string(),
+
  edits: array(
+
    object({
+
      author: authorSchema,
+
      body: string(),
+
      embeds: array(object({ name: string(), content: string() })),
+
      timestamp: number(),
+
    }),
+
  ),
+
  reactions: array(
+
    object({
+
      emoji: string(),
+
      authors: array(authorSchema),
+
    }),
+
  ),
+
  base: string(),
+
  oid: string(),
+
  refs: array(string()),
+
  discussions: array(commentSchema),
+
  reviews: array(reviewSchema),
+
  timestamp: number(),
+
});
+

+
export type Revision = z.infer<typeof revisionSchema>;
+

+
export const patchSchema = object({
+
  id: string(),
+
  author: authorSchema,
+
  title: string(),
+
  state: patchStateSchema,
+
  target: string(),
+
  labels: array(string()),
+
  merges: array(mergeSchema),
+
  assignees: array(string()),
+
  revisions: array(revisionSchema),
+
});
+

+
export type Patch = z.infer<typeof patchSchema>;
+

+
export const patchesSchema = array(patchSchema) satisfies ZodSchema<Patch[]>;
+

+
export type LifecycleState =
+
  | { status: "draft" }
+
  | { status: "open" }
+
  | { status: "archived" };
+

+
export type PatchUpdateAction =
+
  | { type: "edit"; title: string; target: "delegates" }
+
  | { type: "label"; labels: string[] }
+
  | { type: "assign"; assignees: string[] }
+
  | { type: "merge"; revision: string; commit: string }
+
  | { type: "lifecycle"; state: LifecycleState }
+
  | {
+
      type: "review";
+
      revision: string;
+
      summary?: string;
+
      verdict?: Verdict | null;
+
    }
+
  | { type: "review.edit"; review: string; summary?: string }
+
  | { type: "review.redact"; review: string }
+
  | {
+
      type: "review.comment";
+
      review: string;
+
      body: string;
+
      location: CodeLocation;
+
      replyTo?: string;
+
      embeds?: Embed[];
+
    }
+
  | {
+
      type: "review.comment.edit";
+
      review: string;
+
      comment: string;
+
      body: string;
+
      embeds: Embed[];
+
    }
+
  | {
+
      type: "review.comment.redact";
+
      review: string;
+
      comment: string;
+
    }
+
  | {
+
      type: "review.comment.react";
+
      review: string;
+
      comment: string;
+
      reaction: string;
+
      active: boolean;
+
    }
+
  | { type: "revision"; description: string; base: string; oid: string }
+
  | {
+
      type: "revision.edit";
+
      revision: string;
+
      description: string;
+
      embeds?: Embed[];
+
    }
+
  | {
+
      type: "revision.react";
+
      revision: string;
+
      reaction: string;
+
      location?: CodeLocation;
+
      active: boolean;
+
    }
+
  | { type: "revision.redact"; revision: string }
+
  | {
+
      type: "revision.comment";
+
      revision: string;
+
      body: string;
+
      embeds?: Embed[];
+
      location?: CodeLocation;
+
      replyTo?: string;
+
    }
+
  | {
+
      type: "revision.comment.edit";
+
      revision: string;
+
      comment: string;
+
      body: string;
+
      embeds: Embed[];
+
    }
+
  | {
+
      type: "revision.comment.redact";
+
      revision: string;
+
      comment: string;
+
    }
+
  | {
+
      type: "revision.comment.react";
+
      revision: string;
+
      comment: string;
+
      reaction: string;
+
      active: boolean;
+
    };
+

+
export const patchCreateSchema = object({
+
  title: string(),
+
  description: string(),
+
  target: string(),
+
  oid: string(),
+
  labels: array(string()),
+
});
+

+
export type PatchCreate = z.infer<typeof patchCreateSchema>;
+

+
export const patchCreatedSchema = object({
+
  success: boolean(),
+
  id: string(),
+
});
+

+
export type PatchCreated = z.infer<typeof patchCreatedSchema>;
added http-client/lib/session.ts
@@ -0,0 +1,90 @@
+
import type { Fetcher, RequestOptions } from "./fetcher.js";
+
import type { SuccessResponse } from "./shared.js";
+
import type { z } from "zod";
+

+
import { number, object, string } from "zod";
+

+
import { successResponseSchema } from "./shared.js";
+

+
export const sessionPayloadSchema = object({
+
  sessionId: string(),
+
  signature: string(),
+
  publicKey: string(),
+
});
+

+
export type SessionPayload = z.infer<typeof sessionPayloadSchema>;
+

+
const sessionSchema = object({
+
  sessionId: string(),
+
  status: string(),
+
  publicKey: string(),
+
  alias: string(),
+
  issuedAt: number(),
+
  expiresAt: number(),
+
});
+

+
export type Session = z.infer<typeof sessionSchema>;
+

+
export class Client {
+
  #fetcher: Fetcher;
+

+
  public constructor(fetcher: Fetcher) {
+
    this.#fetcher = fetcher;
+
  }
+

+
  public async getById(id: string, options?: RequestOptions): Promise<Session> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: `sessions/${id}`,
+
        options,
+
      },
+
      sessionSchema,
+
    );
+
  }
+

+
  public async create(options?: RequestOptions): Promise<Session> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "POST",
+
        path: "sessions",
+
        options,
+
      },
+
      sessionSchema,
+
    );
+
  }
+

+
  public async update(
+
    id: string,
+
    body: {
+
      sig: string;
+
      pk: string;
+
    },
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PUT",
+
        path: `sessions/${id}`,
+
        body,
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async delete(
+
    id: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "DELETE",
+
        path: `sessions/${id}`,
+
        headers: { Authorization: `Bearer ${id}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+
}
added http-client/lib/shared.ts
@@ -0,0 +1,77 @@
+
import type { ZodSchema, z } from "zod";
+

+
import { array, literal, number, object, string, union } from "zod";
+

+
export interface SuccessResponse {
+
  success: true;
+
}
+

+
export const successResponseSchema = object({
+
  success: literal(true),
+
}) satisfies ZodSchema<SuccessResponse>;
+

+
const policySchema = union([literal("allow"), literal("block")]);
+
const scopeSchema = union([literal("followed"), literal("all")]);
+

+
export const nodeConfigSchema = object({
+
  alias: string(),
+
  peers: union([
+
    object({ type: literal("static") }),
+
    object({ type: literal("dynamic"), target: number() }),
+
  ]),
+
  listen: array(string()),
+
  connect: array(string()),
+
  externalAddresses: array(string()),
+
  network: union([literal("main"), literal("test")]),
+
  relay: union([literal("always"), literal("never"), literal("auto")]),
+
  limits: object({
+
    routingMaxSize: number(),
+
    routingMaxAge: number(),
+
    fetchConcurrency: number(),
+
    gossipMaxAge: number(),
+
    maxOpenFiles: number(),
+
    rate: object({
+
      inbound: object({
+
        fillRate: number(),
+
        capacity: number(),
+
      }),
+
      outbound: object({
+
        fillRate: number(),
+
        capacity: number(),
+
      }),
+
    }),
+
  }),
+
  policy: policySchema,
+
  scope: scopeSchema,
+
});
+

+
export type Policy = z.infer<typeof policySchema>;
+
export type Scope = z.infer<typeof scopeSchema>;
+

+
export const rangeSchema = union([
+
  object({
+
    type: literal("lines"),
+
    range: object({ start: number(), end: number() }),
+
  }),
+
  object({
+
    type: literal("chars"),
+
    line: number(),
+
    range: object({ start: number(), end: number() }),
+
  }),
+
]);
+

+
export type Range = z.infer<typeof rangeSchema>;
+

+
export const codeLocationSchema = object({
+
  commit: string(),
+
  path: string(),
+
  old: rangeSchema.nullable(),
+
  new: rangeSchema.nullable(),
+
});
+

+
export type CodeLocation = z.infer<typeof codeLocationSchema>;
+

+
export const authorSchema = object({
+
  id: string(),
+
  alias: string().optional(),
+
});
added http-client/tests/client.test.ts
@@ -0,0 +1,24 @@
+
import { describe, test } from "vitest";
+

+
import { HttpdClient } from "@http-client";
+
import { defaultHttpdPort } from "@tests/support/fixtures";
+

+
const api = new HttpdClient({
+
  hostname: "127.0.0.1",
+
  port: defaultHttpdPort,
+
  scheme: "http",
+
});
+

+
describe("client", () => {
+
  test("#getNodeInfo()", async () => {
+
    await api.getNodeInfo();
+
  });
+

+
  test("#getStats()", async () => {
+
    await api.getStats();
+
  });
+

+
  test("#getNode()", async () => {
+
    await api.getNode();
+
  });
+
});
added http-client/tests/project.test.ts
@@ -0,0 +1,291 @@
+
import { describe, test } from "vitest";
+

+
import { HttpdClient } from "@http-client";
+
import {
+
  aliceMainHead,
+
  aliceRemote,
+
  bobRemote,
+
  cobRid,
+
  defaultHttpdPort,
+
  sourceBrowsingRid,
+
} from "@tests/support/fixtures.js";
+
import {
+
  assertIssue,
+
  assertPatch,
+
  createIssueToBeModified,
+
  createPatchToBeModified,
+
} from "@http-client/tests/support/support";
+
import { authenticate } from "@http-client/tests/support/httpd.js";
+
import { testFixture as testWithAPI } from "@http-client/tests/support/fixtures.js";
+

+
describe("project", () => {
+
  const api = new HttpdClient({
+
    hostname: "127.0.0.1",
+
    port: defaultHttpdPort,
+
    scheme: "http",
+
  });
+

+
  test("#getByDelegate(delegateId)", async () => {
+
    await api.project.getByDelegate(aliceRemote);
+
  });
+

+
  test("#getAll()", async () => {
+
    await api.project.getAll();
+
  });
+

+
  test("#getById(id)", async () => {
+
    await api.project.getById(sourceBrowsingRid);
+
  });
+

+
  test("#getActivity(id)", async () => {
+
    await api.project.getActivity(sourceBrowsingRid);
+
  });
+

+
  test("#getReadme(id, sha)", async () => {
+
    await api.project.getReadme(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getBlob(id, sha, path)", async () => {
+
    await api.project.getBlob(sourceBrowsingRid, aliceMainHead, "src/true.c");
+
  });
+

+
  test("#getTree(id, sha)", async () => {
+
    await api.project.getTree(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getTreeStats(id, sha)", async () => {
+
    await api.project.getTreeStatsBySha(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getTree(id, sha, path)", async () => {
+
    await api.project.getTree(sourceBrowsingRid, aliceMainHead, "src");
+
  });
+

+
  test("#getAllRemotes(id)", async () => {
+
    await api.project.getAllRemotes(sourceBrowsingRid);
+
  });
+

+
  test("#getRemoteByPeer(id, peer)", async () => {
+
    await api.project.getRemoteByPeer(
+
      sourceBrowsingRid,
+
      aliceRemote.substring(8),
+
    );
+
  });
+

+
  test("#getAllCommits(id)", async () => {
+
    await api.project.getAllCommits(sourceBrowsingRid);
+
  });
+

+
  // TODO: test since/until properly.
+
  test("#getAllCommits(id, {parent, since, until, page, perPage})", async () => {
+
    await api.project.getAllCommits(sourceBrowsingRid, {
+
      parent: aliceMainHead,
+
      since: 1679065819581,
+
      until: 1679065819590,
+
      page: 1,
+
      perPage: 2,
+
    });
+
  });
+

+
  test("#getCommitBySha(id, sha)", async () => {
+
    await api.project.getCommitBySha(sourceBrowsingRid, aliceMainHead);
+
  });
+

+
  test("#getDiff(id, revisionBase, revisionOid)", async () => {
+
    await api.project.getDiff(
+
      sourceBrowsingRid,
+
      "90f6d058ece12f75f349bc7bbe88142187fe0379",
+
      aliceMainHead,
+
    );
+
  });
+

+
  test("#getIssueById(id, issueId)", async () => {
+
    await api.project.getIssueById(
+
      cobRid,
+
      "d481fe6e562dd78129589d4738f171a8380fcb19",
+
    );
+
  });
+

+
  test("#getAllIssues(id)", async () => {
+
    await api.project.getAllIssues(cobRid, {
+
      page: 0,
+
      perPage: 5,
+
      state: "open",
+
    });
+
  });
+

+
  testWithAPI(
+
    "#createIssue(id, { title, description, assignees, labels })",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const { id: issueId } = await api.project.createIssue(
+
        cobRid,
+
        {
+
          title: "aaa",
+
          description: "bbb",
+
          assignees: [],
+
          embeds: [],
+
          labels: ["bug", "documentation"],
+
        },
+
        sessionId,
+
      );
+
      await assertIssue(
+
        issueId,
+
        {
+
          title: "aaa",
+
          discussion: [{ body: "bbb" }],
+
          assignees: [],
+
          labels: ["bug", "documentation"],
+
        },
+
        api,
+
      );
+
    },
+
  );
+

+
  testWithAPI(
+
    "#updateIssue(id, issueId, { type: 'edit' }, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const issueId = await createIssueToBeModified(api, sessionId);
+
      await api.project.updateIssue(
+
        cobRid,
+
        issueId,
+
        { type: "edit", title: "ccc" },
+
        sessionId,
+
      );
+
      await assertIssue(issueId, { title: "ccc" }, api);
+
    },
+
  );
+

+
  testWithAPI(
+
    "#updateIssue(id, issueId, { type: 'label' }, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const issueId = await createIssueToBeModified(api, sessionId);
+
      await api.project.updateIssue(
+
        cobRid,
+
        issueId,
+
        { type: "label", labels: ["bug"] },
+
        sessionId,
+
      );
+
      await assertIssue(issueId, { labels: ["bug"] }, api);
+
    },
+
  );
+

+
  testWithAPI(
+
    "#updateIssue(id, issueId, { type: 'assign' }, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const issueId = await createIssueToBeModified(api, sessionId);
+
      await api.project.updateIssue(
+
        cobRid,
+
        issueId,
+
        {
+
          type: "assign",
+
          assignees: [bobRemote],
+
        },
+
        sessionId,
+
      );
+
      await assertIssue(
+
        issueId,
+
        {
+
          assignees: [
+
            {
+
              id: "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5",
+
              alias: "bob",
+
            },
+
          ],
+
        },
+
        api,
+
      );
+
    },
+
  );
+

+
  testWithAPI(
+
    "#updateIssue(id, issueId, { type: 'lifecycle' }, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const issueId = await createIssueToBeModified(api, sessionId);
+
      await api.project.updateIssue(
+
        cobRid,
+
        issueId,
+
        { type: "lifecycle", state: { status: "closed", reason: "solved" } },
+
        sessionId,
+
      );
+
      await assertIssue(
+
        issueId,
+
        {
+
          state: { status: "closed", reason: "solved" },
+
        },
+
        api,
+
      );
+
    },
+
  );
+

+
  test("#getPatchById(id, patchId)", async () => {
+
    await api.project.getPatchById(
+
      cobRid,
+
      "59a0821edc73630bce540596cffc7854da557365",
+
    );
+
  });
+

+
  test("#getAllPatches(id)", async () => {
+
    await api.project.getAllPatches(cobRid);
+
  });
+

+
  testWithAPI(
+
    "#createPatch(id, patchCreate, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const { id: oid } = await api.project.createPatch(
+
        cobRid,
+
        {
+
          title: "ppp",
+
          description: "qqq",
+
          target: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
+
          oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
+
          labels: [],
+
        },
+
        sessionId,
+
      );
+
      await assertPatch(
+
        oid,
+
        {
+
          title: "ppp",
+
          state: { status: "open" },
+
          target: "delegates",
+
          labels: [],
+
          revisions: [
+
            {
+
              description: "qqq",
+
              base: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
+
              oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
+
            },
+
          ],
+
        },
+
        api,
+
      );
+
    },
+
  );
+

+
  testWithAPI(
+
    "#updatePatch(id, patchId, { type: 'edit' }, authToken)",
+
    async ({ httpd: { api, peer } }) => {
+
      const sessionId = await authenticate(api, peer);
+
      const patchId = await createPatchToBeModified(api, sessionId);
+
      await api.project.updatePatch(
+
        cobRid,
+
        patchId,
+
        { type: "label", labels: ["bug"] },
+
        sessionId,
+
      );
+
      await assertPatch(
+
        patchId,
+
        {
+
          labels: ["bug"],
+
        },
+
        api,
+
      );
+
    },
+
  );
+
});
added http-client/tests/session.test.ts
@@ -0,0 +1,38 @@
+
import * as FsSync from "node:fs";
+
import * as Path from "node:path";
+
import { describe, test } from "vitest";
+

+
import { HttpdClient } from "@http-client";
+
import { authenticate } from "@http-client/tests/support/httpd.js";
+
import { createPeerManager } from "@tests/support/peerManager.js";
+
import { gitOptions } from "@tests/support/fixtures.js";
+
import { tmpDir } from "@tests/support/support.js";
+

+
describe("session", async () => {
+
  const peerManager = await createPeerManager({
+
    dataDir: Path.resolve(Path.join(tmpDir, "peers")),
+
    outputLog: FsSync.createWriteStream(
+
      Path.join(tmpDir, "peerManager.log"),
+
    ).setMaxListeners(16),
+
  });
+
  const peer = await peerManager.createPeer({
+
    name: "session",
+
    gitOptions: gitOptions["alice"],
+
  });
+
  await peer.startHttpd();
+
  const api = new HttpdClient(peer.httpdBaseUrl);
+

+
  test("#getById(id)", async () => {
+
    const id = await authenticate(api, peer);
+
    await api.session.getById(id);
+
  });
+

+
  test("#update(id, {sig, pk})", async () => {
+
    await authenticate(api, peer);
+
  });
+

+
  test("#delete(id)", async () => {
+
    const id = await authenticate(api, peer);
+
    await api.session.delete(id);
+
  });
+
});
added http-client/tests/support/fixtures.ts
@@ -0,0 +1,32 @@
+
import * as FsSync from "node:fs";
+
import * as Path from "node:path";
+
import { test } from "vitest";
+

+
import { HttpdClient } from "@http-client";
+
import { RadiclePeer, createPeerManager } from "@tests/support/peerManager.js";
+
import { gitOptions } from "@tests/support/fixtures.js";
+
import { tmpDir } from "@tests/support/support.js";
+

+
interface TestFixtures {
+
  httpd: { api: HttpdClient; peer: RadiclePeer };
+
}
+

+
export const testFixture = test.extend<TestFixtures>({
+
  // eslint-disable-next-line no-empty-pattern
+
  httpd: async ({}, use) => {
+
    const peerManager = await createPeerManager({
+
      dataDir: Path.resolve(Path.join(tmpDir, "peers")),
+
      outputLog: FsSync.createWriteStream(
+
        Path.join(tmpDir, "peerManager.log"),
+
      ).setMaxListeners(16),
+
    });
+
    const peer = await peerManager.createPeer({
+
      name: "palm",
+
      gitOptions: gitOptions["alice"],
+
    });
+
    await peer.startHttpd();
+
    const api = new HttpdClient(peer.httpdBaseUrl);
+
    await use({ api, peer });
+
    await peer.shutdown();
+
  },
+
});
added http-client/tests/support/httpd.ts
@@ -0,0 +1,30 @@
+
import type { HttpdClient } from "@http-client";
+
import type { RadiclePeer } from "@tests/support/peerManager.js";
+

+
import assert from "node:assert";
+

+
export async function authenticate(
+
  api: HttpdClient,
+
  peer: RadiclePeer,
+
): Promise<string> {
+
  const { stdout } = await peer.spawn("rad-web", [
+
    "http://localhost:3001",
+
    "--no-open",
+
    "--connect",
+
    `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
+
  ]);
+
  const match = stdout.match(/Visit (http:\/\/\S+) to connect/);
+
  assert(
+
    match !== null && match[1],
+
    `Failed to get authentication URL from: ${stdout}`,
+
  );
+

+
  const authUrl = new URL(match[1]);
+
  const sessionId = authUrl.pathname.split("/")[2];
+

+
  await api.session.update(sessionId, {
+
    sig: authUrl.searchParams.get("sig")!,
+
    pk: authUrl.searchParams.get("pk")!,
+
  });
+
  return sessionId;
+
}
added http-client/tests/support/support.ts
@@ -0,0 +1,59 @@
+
import type { HttpdClient } from "@http-client";
+

+
import { expect } from "vitest";
+
import isMatch from "lodash/isMatch";
+

+
import { cobRid } from "@tests/support/fixtures";
+

+
export async function createIssueToBeModified(
+
  api: HttpdClient,
+
  sessionId: string,
+
) {
+
  const { id } = await api.project.createIssue(
+
    cobRid,
+
    { title: "aaa", description: "bbb", embeds: [], assignees: [], labels: [] },
+
    sessionId,
+
  );
+

+
  return id;
+
}
+

+
export async function createPatchToBeModified(
+
  api: HttpdClient,
+
  sessionId: string,
+
) {
+
  const { id } = await api.project.createPatch(
+
    cobRid,
+
    {
+
      title: "rrr",
+
      description: "ttt",
+
      target: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
+
      oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
+
      labels: [],
+
    },
+
    sessionId,
+
  );
+

+
  return id;
+
}
+
export async function assertIssue(
+
  oid: string,
+
  change: Record<string, unknown>,
+
  api: HttpdClient,
+
) {
+
  expect(
+
    //@prettier-ignore looks more readable than what prettier suggests.
+
    isMatch(await api.project.getIssueById(cobRid, oid), change),
+
  ).toBe(true);
+
}
+

+
export async function assertPatch(
+
  oid: string,
+
  change: Record<string, unknown>,
+
  api: HttpdClient,
+
) {
+
  expect(
+
    //@prettier-ignore looks more readable than what prettier suggests.
+
    isMatch(await api.project.getPatchById(cobRid, oid), change),
+
  ).toBe(true);
+
}
added http-client/vite.config.ts
@@ -0,0 +1,25 @@
+
import nodeConfig from "config";
+
import path from "node:path";
+
import virtual from "vite-plugin-virtual";
+
import { defineConfig } from "vite";
+

+
export default defineConfig({
+
  plugins: [
+
    virtual({
+
      "virtual:config": nodeConfig.util.toObject(),
+
    }),
+
  ],
+
  test: {
+
    environment: "node",
+
    include: ["http-client/tests/*.test.ts"],
+
    reporters: "verbose",
+
    globalSetup: "./tests/support/globalSetup",
+
  },
+
  resolve: {
+
    alias: {
+
      "@tests": path.resolve("./tests"),
+
      "@app": path.resolve("./src"),
+
      "@http-client": path.resolve("./http-client"),
+
    },
+
  },
+
});
added http-server/Cargo.lock
@@ -0,0 +1,3248 @@
+
# This file is automatically @generated by Cargo.
+
# It is not intended for manual editing.
+
version = 3
+

+
[[package]]
+
name = "addr2line"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+
dependencies = [
+
 "gimli",
+
]
+

+
[[package]]
+
name = "adler"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+

+
[[package]]
+
name = "aead"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+
dependencies = [
+
 "crypto-common",
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "aes"
+
version = "0.8.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+
dependencies = [
+
 "cfg-if",
+
 "cipher",
+
 "cpufeatures",
+
]
+

+
[[package]]
+
name = "aes-gcm"
+
version = "0.10.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+
dependencies = [
+
 "aead",
+
 "aes",
+
 "cipher",
+
 "ctr",
+
 "ghash",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "ahash"
+
version = "0.8.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+
dependencies = [
+
 "cfg-if",
+
 "once_cell",
+
 "version_check",
+
 "zerocopy",
+
]
+

+
[[package]]
+
name = "aho-corasick"
+
version = "1.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+
dependencies = [
+
 "memchr",
+
]
+

+
[[package]]
+
name = "allocator-api2"
+
version = "0.2.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
+

+
[[package]]
+
name = "amplify"
+
version = "4.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e711289a6cb28171b4f0e6c8019c69ff9476050508dc082167575d458ff74d0"
+
dependencies = [
+
 "amplify_derive",
+
 "amplify_num",
+
 "ascii",
+
 "wasm-bindgen",
+
]
+

+
[[package]]
+
name = "amplify_derive"
+
version = "4.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "759dcbfaf94d838367a86d493ec34ccc8aa6fe365cb7880d6bf89006de24d9c1"
+
dependencies = [
+
 "amplify_syn",
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "amplify_num"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "04c009c5c4de814911b177e2ea59e4930bb918978ed3cce4900d846a6ceb0838"
+
dependencies = [
+
 "wasm-bindgen",
+
]
+

+
[[package]]
+
name = "amplify_syn"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "android-tzdata"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+

+
[[package]]
+
name = "android_system_properties"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "anstream"
+
version = "0.6.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb"
+
dependencies = [
+
 "anstyle",
+
 "anstyle-parse",
+
 "anstyle-query",
+
 "anstyle-wincon",
+
 "colorchoice",
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
+

+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
+
dependencies = [
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle-query"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
+
dependencies = [
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "anstyle-wincon"
+
version = "3.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+
dependencies = [
+
 "anstyle",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "anyhow"
+
version = "1.0.82"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
+

+
[[package]]
+
name = "ascii"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+

+
[[package]]
+
name = "async-trait"
+
version = "0.1.80"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "autocfg"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
+

+
[[package]]
+
name = "axum"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
+
dependencies = [
+
 "async-trait",
+
 "axum-core",
+
 "bytes",
+
 "futures-util",
+
 "http",
+
 "http-body",
+
 "http-body-util",
+
 "hyper",
+
 "hyper-util",
+
 "itoa",
+
 "matchit",
+
 "memchr",
+
 "mime",
+
 "percent-encoding",
+
 "pin-project-lite",
+
 "rustversion",
+
 "serde",
+
 "serde_json",
+
 "serde_path_to_error",
+
 "serde_urlencoded",
+
 "sync_wrapper 1.0.1",
+
 "tokio",
+
 "tower",
+
 "tower-layer",
+
 "tower-service",
+
]
+

+
[[package]]
+
name = "axum-auth"
+
version = "0.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a"
+
dependencies = [
+
 "async-trait",
+
 "axum-core",
+
 "base64 0.21.7",
+
 "http",
+
]
+

+
[[package]]
+
name = "axum-core"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3"
+
dependencies = [
+
 "async-trait",
+
 "bytes",
+
 "futures-util",
+
 "http",
+
 "http-body",
+
 "http-body-util",
+
 "mime",
+
 "pin-project-lite",
+
 "rustversion",
+
 "sync_wrapper 0.1.2",
+
 "tower-layer",
+
 "tower-service",
+
]
+

+
[[package]]
+
name = "axum-server"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c1ad46c3ec4e12f4a4b6835e173ba21c25e484c9d02b49770bf006ce5367c036"
+
dependencies = [
+
 "bytes",
+
 "futures-util",
+
 "http",
+
 "http-body",
+
 "http-body-util",
+
 "hyper",
+
 "hyper-util",
+
 "pin-project-lite",
+
 "tokio",
+
 "tower",
+
 "tower-service",
+
]
+

+
[[package]]
+
name = "backtrace"
+
version = "0.3.71"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d"
+
dependencies = [
+
 "addr2line",
+
 "cc",
+
 "cfg-if",
+
 "libc",
+
 "miniz_oxide",
+
 "object",
+
 "rustc-demangle",
+
]
+

+
[[package]]
+
name = "base-x"
+
version = "0.2.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+

+
[[package]]
+
name = "base16ct"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+

+
[[package]]
+
name = "base32"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
+

+
[[package]]
+
name = "base64"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+

+
[[package]]
+
name = "base64"
+
version = "0.21.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+

+
[[package]]
+
name = "base64ct"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+

+
[[package]]
+
name = "bcrypt-pbkdf"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
+
dependencies = [
+
 "blowfish",
+
 "pbkdf2",
+
 "sha2",
+
]
+

+
[[package]]
+
name = "bitflags"
+
version = "1.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+

+
[[package]]
+
name = "bitflags"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
+

+
[[package]]
+
name = "block-buffer"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "block-padding"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "blowfish"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+
dependencies = [
+
 "byteorder",
+
 "cipher",
+
]
+

+
[[package]]
+
name = "bumpalo"
+
version = "3.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+

+
[[package]]
+
name = "byteorder"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+

+
[[package]]
+
name = "bytes"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
+

+
[[package]]
+
name = "cbc"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+
dependencies = [
+
 "cipher",
+
]
+

+
[[package]]
+
name = "cc"
+
version = "1.0.95"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b"
+
dependencies = [
+
 "jobserver",
+
 "libc",
+
 "once_cell",
+
]
+

+
[[package]]
+
name = "cfg-if"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+

+
[[package]]
+
name = "chacha20"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+
dependencies = [
+
 "cfg-if",
+
 "cipher",
+
 "cpufeatures",
+
]
+

+
[[package]]
+
name = "chrono"
+
version = "0.4.38"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+
dependencies = [
+
 "android-tzdata",
+
 "iana-time-zone",
+
 "num-traits",
+
 "windows-targets 0.52.5",
+
]
+

+
[[package]]
+
name = "cipher"
+
version = "0.4.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+
dependencies = [
+
 "crypto-common",
+
 "inout",
+
]
+

+
[[package]]
+
name = "colorchoice"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+

+
[[package]]
+
name = "colored"
+
version = "2.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8"
+
dependencies = [
+
 "lazy_static",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
+
name = "const-oid"
+
version = "0.9.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+

+
[[package]]
+
name = "core-foundation-sys"
+
version = "0.8.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+

+
[[package]]
+
name = "cpufeatures"
+
version = "0.2.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "crc32fast"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
+
dependencies = [
+
 "cfg-if",
+
]
+

+
[[package]]
+
name = "crossbeam-channel"
+
version = "0.5.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95"
+
dependencies = [
+
 "crossbeam-utils",
+
]
+

+
[[package]]
+
name = "crossbeam-utils"
+
version = "0.8.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
+

+
[[package]]
+
name = "crypto-bigint"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+
dependencies = [
+
 "generic-array",
+
 "rand_core",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "crypto-common"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+
dependencies = [
+
 "generic-array",
+
 "typenum",
+
]
+

+
[[package]]
+
name = "ct-codecs"
+
version = "1.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df"
+

+
[[package]]
+
name = "ctr"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+
dependencies = [
+
 "cipher",
+
]
+

+
[[package]]
+
name = "cypheraddr"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba5c54d2ad4ab9941383519471b75d12abc1a7b4779265e233168f2703a730d9"
+
dependencies = [
+
 "amplify",
+
 "base32",
+
 "cyphergraphy",
+
 "sha3",
+
]
+

+
[[package]]
+
name = "cyphergraphy"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b67c16c8ef5ddcdab57aab83fd8e770540ea3682ccdae09642c63575b0da2184"
+
dependencies = [
+
 "amplify",
+
 "ec25519",
+
]
+

+
[[package]]
+
name = "cyphernet"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ac949369884a7a1d802cc669821269c707be8cec4d65043382e253733d2e62e1"
+
dependencies = [
+
 "cypheraddr",
+
 "cyphergraphy",
+
 "socks5-client",
+
]
+

+
[[package]]
+
name = "data-encoding"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
+

+
[[package]]
+
name = "data-encoding-macro"
+
version = "0.1.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "20c01c06f5f429efdf2bae21eb67c28b3df3cf85b7dd2d8ef09c0838dac5d33e"
+
dependencies = [
+
 "data-encoding",
+
 "data-encoding-macro-internal",
+
]
+

+
[[package]]
+
name = "data-encoding-macro-internal"
+
version = "0.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0047d07f2c89b17dd631c80450d69841a6b5d7fb17278cbc43d7e4cfcf2576f3"
+
dependencies = [
+
 "data-encoding",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "der"
+
version = "0.7.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
+
dependencies = [
+
 "const-oid",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "deranged"
+
version = "0.3.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+
dependencies = [
+
 "powerfmt",
+
 "serde",
+
]
+

+
[[package]]
+
name = "diff"
+
version = "0.1.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+

+
[[package]]
+
name = "digest"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+
dependencies = [
+
 "block-buffer",
+
 "const-oid",
+
 "crypto-common",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "dyn-clone"
+
version = "1.0.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
+

+
[[package]]
+
name = "ec25519"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bdfd533a2fc01178c738c99412ae1f7e1ad2cb37c2e14bfd87e9d4618171c825"
+
dependencies = [
+
 "ct-codecs",
+
 "ed25519",
+
 "getrandom",
+
]
+

+
[[package]]
+
name = "ecdsa"
+
version = "0.16.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+
dependencies = [
+
 "der",
+
 "digest",
+
 "elliptic-curve",
+
 "rfc6979",
+
 "signature 2.2.0",
+
 "spki",
+
]
+

+
[[package]]
+
name = "ed25519"
+
version = "1.5.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
+
dependencies = [
+
 "signature 1.6.4",
+
]
+

+
[[package]]
+
name = "elliptic-curve"
+
version = "0.13.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+
dependencies = [
+
 "base16ct",
+
 "crypto-bigint",
+
 "digest",
+
 "ff",
+
 "generic-array",
+
 "group",
+
 "pkcs8",
+
 "rand_core",
+
 "sec1",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "equivalent"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+

+
[[package]]
+
name = "errno"
+
version = "0.3.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
+
dependencies = [
+
 "libc",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "escargot"
+
version = "0.5.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4f474c6844cbd04e783d0f25757583db4f491770ca618bedf2fb01815fc79939"
+
dependencies = [
+
 "log",
+
 "once_cell",
+
 "serde",
+
 "serde_json",
+
]
+

+
[[package]]
+
name = "fastrand"
+
version = "2.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
+

+
[[package]]
+
name = "ff"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449"
+
dependencies = [
+
 "rand_core",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "filetime"
+
version = "0.2.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "redox_syscall",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "flate2"
+
version = "1.0.28"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
+
dependencies = [
+
 "crc32fast",
+
 "miniz_oxide",
+
]
+

+
[[package]]
+
name = "fnv"
+
version = "1.0.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+

+
[[package]]
+
name = "form_urlencoded"
+
version = "1.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+
dependencies = [
+
 "percent-encoding",
+
]
+

+
[[package]]
+
name = "futures-channel"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+
dependencies = [
+
 "futures-core",
+
]
+

+
[[package]]
+
name = "futures-core"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+

+
[[package]]
+
name = "futures-sink"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+

+
[[package]]
+
name = "futures-task"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+

+
[[package]]
+
name = "futures-util"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+
dependencies = [
+
 "futures-core",
+
 "futures-task",
+
 "pin-project-lite",
+
 "pin-utils",
+
]
+

+
[[package]]
+
name = "fxhash"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+
dependencies = [
+
 "byteorder",
+
]
+

+
[[package]]
+
name = "generic-array"
+
version = "0.14.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+
dependencies = [
+
 "typenum",
+
 "version_check",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "getrandom"
+
version = "0.2.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi",
+
]
+

+
[[package]]
+
name = "ghash"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+
dependencies = [
+
 "opaque-debug",
+
 "polyval",
+
]
+

+
[[package]]
+
name = "gimli"
+
version = "0.28.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+

+
[[package]]
+
name = "git-ref-format"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "137adab7111fcb575db6f07dae3a7d37f3c2630878954c9931f7135dfa33eeef"
+
dependencies = [
+
 "git-ref-format-core",
+
 "git-ref-format-macro",
+
]
+

+
[[package]]
+
name = "git-ref-format-core"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebb6549ddc63ba5722acb98c823b0eccb7f8b979407bd2a8fd616f581ae50982"
+
dependencies = [
+
 "serde",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "git-ref-format-macro"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "18ffd0101a3bd9a3aba39602b8b20751ddb7ee11596debb58be3074209dad2ae"
+
dependencies = [
+
 "git-ref-format-core",
+
 "proc-macro-error",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "git2"
+
version = "0.18.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70"
+
dependencies = [
+
 "bitflags 2.5.0",
+
 "libc",
+
 "libgit2-sys",
+
 "log",
+
 "url",
+
]
+

+
[[package]]
+
name = "group"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+
dependencies = [
+
 "ff",
+
 "rand_core",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "h2"
+
version = "0.4.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
+
dependencies = [
+
 "bytes",
+
 "fnv",
+
 "futures-core",
+
 "futures-sink",
+
 "futures-util",
+
 "http",
+
 "indexmap",
+
 "slab",
+
 "tokio",
+
 "tokio-util",
+
 "tracing",
+
]
+

+
[[package]]
+
name = "hashbrown"
+
version = "0.14.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
+
dependencies = [
+
 "ahash",
+
 "allocator-api2",
+
]
+

+
[[package]]
+
name = "hermit-abi"
+
version = "0.3.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+

+
[[package]]
+
name = "hmac"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+
dependencies = [
+
 "digest",
+
]
+

+
[[package]]
+
name = "http"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+
dependencies = [
+
 "bytes",
+
 "fnv",
+
 "itoa",
+
]
+

+
[[package]]
+
name = "http-body"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
+
dependencies = [
+
 "bytes",
+
 "http",
+
]
+

+
[[package]]
+
name = "http-body-util"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
+
dependencies = [
+
 "bytes",
+
 "futures-core",
+
 "http",
+
 "http-body",
+
 "pin-project-lite",
+
]
+

+
[[package]]
+
name = "httparse"
+
version = "1.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
+

+
[[package]]
+
name = "httpdate"
+
version = "1.0.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+

+
[[package]]
+
name = "hyper"
+
version = "1.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d"
+
dependencies = [
+
 "bytes",
+
 "futures-channel",
+
 "futures-util",
+
 "h2",
+
 "http",
+
 "http-body",
+
 "httparse",
+
 "httpdate",
+
 "itoa",
+
 "pin-project-lite",
+
 "smallvec",
+
 "tokio",
+
 "want",
+
]
+

+
[[package]]
+
name = "hyper-util"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
+
dependencies = [
+
 "bytes",
+
 "futures-util",
+
 "http",
+
 "http-body",
+
 "hyper",
+
 "pin-project-lite",
+
 "socket2",
+
 "tokio",
+
]
+

+
[[package]]
+
name = "iana-time-zone"
+
version = "0.1.60"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+
dependencies = [
+
 "android_system_properties",
+
 "core-foundation-sys",
+
 "iana-time-zone-haiku",
+
 "js-sys",
+
 "wasm-bindgen",
+
 "windows-core",
+
]
+

+
[[package]]
+
name = "iana-time-zone-haiku"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+
dependencies = [
+
 "cc",
+
]
+

+
[[package]]
+
name = "idna"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+
dependencies = [
+
 "unicode-bidi",
+
 "unicode-normalization",
+
]
+

+
[[package]]
+
name = "indexmap"
+
version = "2.2.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+
dependencies = [
+
 "equivalent",
+
 "hashbrown",
+
]
+

+
[[package]]
+
name = "inout"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+
dependencies = [
+
 "block-padding",
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "inquire"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
+
dependencies = [
+
 "bitflags 2.5.0",
+
 "dyn-clone",
+
 "fxhash",
+
 "newline-converter",
+
 "once_cell",
+
 "tempfile",
+
 "termion 2.0.3",
+
 "unicode-segmentation",
+
 "unicode-width",
+
]
+

+
[[package]]
+
name = "itoa"
+
version = "1.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+

+
[[package]]
+
name = "jobserver"
+
version = "0.1.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "js-sys"
+
version = "0.3.69"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+
dependencies = [
+
 "wasm-bindgen",
+
]
+

+
[[package]]
+
name = "keccak"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
+
dependencies = [
+
 "cpufeatures",
+
]
+

+
[[package]]
+
name = "lazy_static"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
dependencies = [
+
 "spin",
+
]
+

+
[[package]]
+
name = "lexopt"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"
+

+
[[package]]
+
name = "libc"
+
version = "0.2.155"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+

+
[[package]]
+
name = "libgit2-sys"
+
version = "0.16.2+1.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8"
+
dependencies = [
+
 "cc",
+
 "libc",
+
 "libz-sys",
+
 "pkg-config",
+
]
+

+
[[package]]
+
name = "libm"
+
version = "0.2.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
+

+
[[package]]
+
name = "libredox"
+
version = "0.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607"
+
dependencies = [
+
 "bitflags 2.5.0",
+
 "libc",
+
 "redox_syscall",
+
]
+

+
[[package]]
+
name = "libz-sys"
+
version = "1.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9"
+
dependencies = [
+
 "cc",
+
 "libc",
+
 "pkg-config",
+
 "vcpkg",
+
]
+

+
[[package]]
+
name = "linux-raw-sys"
+
version = "0.4.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
+

+
[[package]]
+
name = "localtime"
+
version = "1.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "016a009e0bb8ba6e3229fb74bf11a8fe6ef24542cc6ef35ef38863ac13f96d87"
+
dependencies = [
+
 "serde",
+
]
+

+
[[package]]
+
name = "log"
+
version = "0.4.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+

+
[[package]]
+
name = "lru"
+
version = "0.12.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc"
+
dependencies = [
+
 "hashbrown",
+
]
+

+
[[package]]
+
name = "matchers"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+
dependencies = [
+
 "regex-automata 0.1.10",
+
]
+

+
[[package]]
+
name = "matchit"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+

+
[[package]]
+
name = "memchr"
+
version = "2.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
+

+
[[package]]
+
name = "mime"
+
version = "0.3.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+

+
[[package]]
+
name = "miniz_oxide"
+
version = "0.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
+
dependencies = [
+
 "adler",
+
]
+

+
[[package]]
+
name = "mio"
+
version = "0.8.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+
dependencies = [
+
 "libc",
+
 "wasi",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
+
name = "multibase"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404"
+
dependencies = [
+
 "base-x",
+
 "data-encoding",
+
 "data-encoding-macro",
+
]
+

+
[[package]]
+
name = "newline-converter"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
+
dependencies = [
+
 "unicode-segmentation",
+
]
+

+
[[package]]
+
name = "nonempty"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ff7ac1e5ea23db6d61ad103e91864675049644bf47c35912336352fa4e9c109"
+

+
[[package]]
+
name = "nonempty"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "995defdca0a589acfdd1bd2e8e3b896b4d4f7675a31fd14c32611440c7f608e6"
+
dependencies = [
+
 "serde",
+
]
+

+
[[package]]
+
name = "normalize-line-endings"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+

+
[[package]]
+
name = "nu-ansi-term"
+
version = "0.46.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+
dependencies = [
+
 "overload",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "num-bigint-dig"
+
version = "0.8.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
+
dependencies = [
+
 "byteorder",
+
 "lazy_static",
+
 "libm",
+
 "num-integer",
+
 "num-iter",
+
 "num-traits",
+
 "rand",
+
 "smallvec",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "num-conv"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+

+
[[package]]
+
name = "num-integer"
+
version = "0.1.46"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+
dependencies = [
+
 "num-traits",
+
]
+

+
[[package]]
+
name = "num-iter"
+
version = "0.1.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9"
+
dependencies = [
+
 "autocfg",
+
 "num-integer",
+
 "num-traits",
+
]
+

+
[[package]]
+
name = "num-traits"
+
version = "0.2.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
+
dependencies = [
+
 "autocfg",
+
 "libm",
+
]
+

+
[[package]]
+
name = "num_cpus"
+
version = "1.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+
dependencies = [
+
 "hermit-abi",
+
 "libc",
+
]
+

+
[[package]]
+
name = "numtoa"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
+

+
[[package]]
+
name = "object"
+
version = "0.32.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+
dependencies = [
+
 "memchr",
+
]
+

+
[[package]]
+
name = "once_cell"
+
version = "1.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+

+
[[package]]
+
name = "opaque-debug"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+

+
[[package]]
+
name = "overload"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+

+
[[package]]
+
name = "p256"
+
version = "0.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+
dependencies = [
+
 "ecdsa",
+
 "elliptic-curve",
+
 "primeorder",
+
 "sha2",
+
]
+

+
[[package]]
+
name = "p384"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209"
+
dependencies = [
+
 "ecdsa",
+
 "elliptic-curve",
+
 "primeorder",
+
 "sha2",
+
]
+

+
[[package]]
+
name = "p521"
+
version = "0.13.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+
dependencies = [
+
 "base16ct",
+
 "ecdsa",
+
 "elliptic-curve",
+
 "primeorder",
+
 "rand_core",
+
 "sha2",
+
]
+

+
[[package]]
+
name = "pbkdf2"
+
version = "0.12.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+
dependencies = [
+
 "digest",
+
]
+

+
[[package]]
+
name = "pem-rfc7468"
+
version = "0.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+
dependencies = [
+
 "base64ct",
+
]
+

+
[[package]]
+
name = "percent-encoding"
+
version = "2.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+

+
[[package]]
+
name = "pin-project"
+
version = "1.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3"
+
dependencies = [
+
 "pin-project-internal",
+
]
+

+
[[package]]
+
name = "pin-project-internal"
+
version = "1.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "pin-project-lite"
+
version = "0.2.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+

+
[[package]]
+
name = "pin-utils"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+

+
[[package]]
+
name = "pkcs1"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+
dependencies = [
+
 "der",
+
 "pkcs8",
+
 "spki",
+
]
+

+
[[package]]
+
name = "pkcs8"
+
version = "0.10.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+
dependencies = [
+
 "der",
+
 "spki",
+
]
+

+
[[package]]
+
name = "pkg-config"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+

+
[[package]]
+
name = "poly1305"
+
version = "0.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+
dependencies = [
+
 "cpufeatures",
+
 "opaque-debug",
+
 "universal-hash",
+
]
+

+
[[package]]
+
name = "polyval"
+
version = "0.6.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+
dependencies = [
+
 "cfg-if",
+
 "cpufeatures",
+
 "opaque-debug",
+
 "universal-hash",
+
]
+

+
[[package]]
+
name = "powerfmt"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+

+
[[package]]
+
name = "ppv-lite86"
+
version = "0.2.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+

+
[[package]]
+
name = "pretty_assertions"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
+
dependencies = [
+
 "diff",
+
 "yansi",
+
]
+

+
[[package]]
+
name = "primeorder"
+
version = "0.13.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+
dependencies = [
+
 "elliptic-curve",
+
]
+

+
[[package]]
+
name = "proc-macro-error"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+
dependencies = [
+
 "proc-macro-error-attr",
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
 "version_check",
+
]
+

+
[[package]]
+
name = "proc-macro-error-attr"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "version_check",
+
]
+

+
[[package]]
+
name = "proc-macro2"
+
version = "1.0.81"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
+
dependencies = [
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "qcheck"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b439bd4242da51d62d18c95e6a6add749346756b0d1a587dfd0cc22fa6b5f3f0"
+
dependencies = [
+
 "rand",
+
]
+

+
[[package]]
+
name = "quote"
+
version = "1.0.36"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+
dependencies = [
+
 "proc-macro2",
+
]
+

+
[[package]]
+
name = "radicle"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2c96b3901ca5b7bfe06da3fb18105c32dc5f9f5c48a217cfc7104385a687195"
+
dependencies = [
+
 "amplify",
+
 "base64 0.21.7",
+
 "chrono",
+
 "colored",
+
 "crossbeam-channel",
+
 "cyphernet",
+
 "fastrand",
+
 "git2",
+
 "libc",
+
 "localtime",
+
 "log",
+
 "multibase",
+
 "nonempty 0.9.0",
+
 "once_cell",
+
 "qcheck",
+
 "radicle-cob",
+
 "radicle-crypto",
+
 "radicle-git-ext",
+
 "radicle-ssh",
+
 "serde",
+
 "serde_json",
+
 "siphasher",
+
 "sqlite",
+
 "tempfile",
+
 "thiserror",
+
 "unicode-normalization",
+
]
+

+
[[package]]
+
name = "radicle-cli"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5347c326bec844b7ced9c8f8bcfec88104ea2066c029d49d5128dd09a4148c50"
+
dependencies = [
+
 "anyhow",
+
 "chrono",
+
 "git-ref-format",
+
 "lexopt",
+
 "localtime",
+
 "log",
+
 "nonempty 0.9.0",
+
 "radicle",
+
 "radicle-cli-test",
+
 "radicle-cob",
+
 "radicle-crypto",
+
 "radicle-git-ext",
+
 "radicle-surf",
+
 "radicle-term",
+
 "serde",
+
 "serde_json",
+
 "shlex",
+
 "tempfile",
+
 "thiserror",
+
 "timeago",
+
 "tree-sitter",
+
 "tree-sitter-bash",
+
 "tree-sitter-c",
+
 "tree-sitter-css",
+
 "tree-sitter-go",
+
 "tree-sitter-highlight",
+
 "tree-sitter-html",
+
 "tree-sitter-json",
+
 "tree-sitter-md",
+
 "tree-sitter-python",
+
 "tree-sitter-ruby",
+
 "tree-sitter-rust",
+
 "tree-sitter-toml",
+
 "tree-sitter-typescript",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-cli-test"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d5bbd1dc7cb2801693d6d00f937021adb0d398e9fec6b998e4830ebba32fdfdd"
+
dependencies = [
+
 "escargot",
+
 "log",
+
 "pretty_assertions",
+
 "radicle",
+
 "shlex",
+
 "snapbox",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-cob"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "36d8268661b22cec768bdf687aa9d98db2dcd9c8f974e8208f8658244074b539"
+
dependencies = [
+
 "fastrand",
+
 "git2",
+
 "log",
+
 "nonempty 0.9.0",
+
 "once_cell",
+
 "radicle-crypto",
+
 "radicle-dag",
+
 "radicle-git-ext",
+
 "serde",
+
 "serde_json",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-crypto"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fb86116dc5d9daa0d0b8e07fb71c9887d537b3fecebffc0cde6624b07176c711"
+
dependencies = [
+
 "amplify",
+
 "cyphernet",
+
 "ec25519",
+
 "fastrand",
+
 "multibase",
+
 "qcheck",
+
 "radicle-git-ext",
+
 "radicle-ssh",
+
 "serde",
+
 "sqlite",
+
 "ssh-key",
+
 "thiserror",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-dag"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2a678c3049a88ae6a34dd9f52ea9a5f9f066a0af63466b75cf8c48840303067"
+
dependencies = [
+
 "fastrand",
+
]
+

+
[[package]]
+
name = "radicle-git-ext"
+
version = "0.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7ba40f0288f73b9464c0f52c09261593777ed6f195ef3be6a12d8f8e920d7885"
+
dependencies = [
+
 "git-ref-format",
+
 "git2",
+
 "percent-encoding",
+
 "radicle-std-ext",
+
 "serde",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-httpd"
+
version = "0.10.0"
+
dependencies = [
+
 "anyhow",
+
 "axum",
+
 "axum-auth",
+
 "axum-server",
+
 "base64 0.21.7",
+
 "chrono",
+
 "fastrand",
+
 "flate2",
+
 "hyper",
+
 "lexopt",
+
 "lru",
+
 "nonempty 0.9.0",
+
 "pretty_assertions",
+
 "radicle",
+
 "radicle-cli",
+
 "radicle-crypto",
+
 "radicle-surf",
+
 "radicle-term",
+
 "serde",
+
 "serde_json",
+
 "tempfile",
+
 "thiserror",
+
 "time",
+
 "tokio",
+
 "tower",
+
 "tower-http",
+
 "tracing",
+
 "tracing-logfmt",
+
 "tracing-subscriber",
+
 "ureq",
+
 "url",
+
]
+

+
[[package]]
+
name = "radicle-signals"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0633d483e40eb96a8e57264727f1c4f0d188348eb5c155cf1369469c121c6c87"
+
dependencies = [
+
 "crossbeam-channel",
+
 "libc",
+
]
+

+
[[package]]
+
name = "radicle-ssh"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fbee758010fb64482be4b18591fbeb3cbc15b16450d143edf4edb5484c7366c6"
+
dependencies = [
+
 "byteorder",
+
 "log",
+
 "thiserror",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-std-ext"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db20136bbc9ae63f3fec8e5a6c369f4902fac2244501b5dfc6d668e43475aaa4"
+

+
[[package]]
+
name = "radicle-surf"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5fae23233ff8be809a822d923e93e56e5282179039b19f345f39f592c1ca0765"
+
dependencies = [
+
 "anyhow",
+
 "base64 0.13.1",
+
 "flate2",
+
 "git2",
+
 "log",
+
 "nonempty 0.5.0",
+
 "radicle-git-ext",
+
 "radicle-std-ext",
+
 "serde",
+
 "tar",
+
 "thiserror",
+
 "url",
+
]
+

+
[[package]]
+
name = "radicle-term"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a46c7b39b0fabe11cbb1f697979f1e1021122aef76b476f5d385c48a02400310"
+
dependencies = [
+
 "anstyle-query",
+
 "anyhow",
+
 "crossbeam-channel",
+
 "git2",
+
 "inquire",
+
 "libc",
+
 "once_cell",
+
 "radicle-signals",
+
 "shlex",
+
 "termion 3.0.0",
+
 "thiserror",
+
 "unicode-display-width",
+
 "unicode-segmentation",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
 "rand_chacha",
+
 "rand_core",
+
]
+

+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
 "ppv-lite86",
+
 "rand_core",
+
]
+

+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
 "getrandom",
+
]
+

+
[[package]]
+
name = "redox_syscall"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+
dependencies = [
+
 "bitflags 1.3.2",
+
]
+

+
[[package]]
+
name = "redox_termios"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"
+

+
[[package]]
+
name = "regex"
+
version = "1.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
+
dependencies = [
+
 "aho-corasick",
+
 "memchr",
+
 "regex-automata 0.4.6",
+
 "regex-syntax 0.8.3",
+
]
+

+
[[package]]
+
name = "regex-automata"
+
version = "0.1.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+
dependencies = [
+
 "regex-syntax 0.6.29",
+
]
+

+
[[package]]
+
name = "regex-automata"
+
version = "0.4.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
+
dependencies = [
+
 "aho-corasick",
+
 "memchr",
+
 "regex-syntax 0.8.3",
+
]
+

+
[[package]]
+
name = "regex-syntax"
+
version = "0.6.29"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+

+
[[package]]
+
name = "regex-syntax"
+
version = "0.8.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
+

+
[[package]]
+
name = "rfc6979"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+
dependencies = [
+
 "hmac",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "rsa"
+
version = "0.9.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
+
dependencies = [
+
 "const-oid",
+
 "digest",
+
 "num-bigint-dig",
+
 "num-integer",
+
 "num-traits",
+
 "pkcs1",
+
 "pkcs8",
+
 "rand_core",
+
 "sha2",
+
 "signature 2.2.0",
+
 "spki",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "rustc-demangle"
+
version = "0.1.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+

+
[[package]]
+
name = "rustix"
+
version = "0.38.34"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+
dependencies = [
+
 "bitflags 2.5.0",
+
 "errno",
+
 "libc",
+
 "linux-raw-sys",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "rustversion"
+
version = "1.0.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47"
+

+
[[package]]
+
name = "ryu"
+
version = "1.0.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
+

+
[[package]]
+
name = "sec1"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+
dependencies = [
+
 "base16ct",
+
 "der",
+
 "generic-array",
+
 "pkcs8",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "serde"
+
version = "1.0.198"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc"
+
dependencies = [
+
 "serde_derive",
+
]
+

+
[[package]]
+
name = "serde_derive"
+
version = "1.0.198"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "serde_json"
+
version = "1.0.116"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
+
dependencies = [
+
 "indexmap",
+
 "itoa",
+
 "ryu",
+
 "serde",
+
]
+

+
[[package]]
+
name = "serde_path_to_error"
+
version = "0.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6"
+
dependencies = [
+
 "itoa",
+
 "serde",
+
]
+

+
[[package]]
+
name = "serde_urlencoded"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+
dependencies = [
+
 "form_urlencoded",
+
 "itoa",
+
 "ryu",
+
 "serde",
+
]
+

+
[[package]]
+
name = "sha2"
+
version = "0.10.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+
dependencies = [
+
 "cfg-if",
+
 "cpufeatures",
+
 "digest",
+
]
+

+
[[package]]
+
name = "sha3"
+
version = "0.10.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+
dependencies = [
+
 "digest",
+
 "keccak",
+
]
+

+
[[package]]
+
name = "sharded-slab"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+
dependencies = [
+
 "lazy_static",
+
]
+

+
[[package]]
+
name = "shlex"
+
version = "1.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+

+
[[package]]
+
name = "signature"
+
version = "1.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+

+
[[package]]
+
name = "signature"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+
dependencies = [
+
 "digest",
+
 "rand_core",
+
]
+

+
[[package]]
+
name = "similar"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640"
+

+
[[package]]
+
name = "siphasher"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+

+
[[package]]
+
name = "slab"
+
version = "0.4.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+
dependencies = [
+
 "autocfg",
+
]
+

+
[[package]]
+
name = "smallvec"
+
version = "1.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+

+
[[package]]
+
name = "snapbox"
+
version = "0.4.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4b831b6e80fbcd2889efa75b185d24005f85981431495f995292b25836519d84"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "normalize-line-endings",
+
 "similar",
+
 "snapbox-macros",
+
]
+

+
[[package]]
+
name = "snapbox-macros"
+
version = "0.3.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e1c4b838b05d15ab22754068cb73500b2f3b07bf09d310e15b27f88160f1de40"
+
dependencies = [
+
 "anstream",
+
]
+

+
[[package]]
+
name = "socket2"
+
version = "0.5.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+
dependencies = [
+
 "libc",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "socks5-client"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ffc7dcf6fab1d65d82d633006a4cc658d76ce436e01cf1a7c71873c0eeba324c"
+
dependencies = [
+
 "amplify",
+
 "cypheraddr",
+
]
+

+
[[package]]
+
name = "spin"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+

+
[[package]]
+
name = "spki"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+
dependencies = [
+
 "base64ct",
+
 "der",
+
]
+

+
[[package]]
+
name = "sqlite"
+
version = "0.32.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "03801c10193857d6a4a71ec46cee198a15cbc659622aabe1db0d0bdbefbcf8e6"
+
dependencies = [
+
 "libc",
+
 "sqlite3-sys",
+
]
+

+
[[package]]
+
name = "sqlite3-src"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfc95a51a1ee38839599371685b9d4a926abb51791f0bc3bf8c3bb7867e6e454"
+
dependencies = [
+
 "cc",
+
 "pkg-config",
+
]
+

+
[[package]]
+
name = "sqlite3-sys"
+
version = "0.15.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2752c669433e40ebb08fde824146f50d9628aa0b66a3b7fc6be34db82a8063b"
+
dependencies = [
+
 "libc",
+
 "sqlite3-src",
+
]
+

+
[[package]]
+
name = "ssh-cipher"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
+
dependencies = [
+
 "aes",
+
 "aes-gcm",
+
 "cbc",
+
 "chacha20",
+
 "cipher",
+
 "ctr",
+
 "poly1305",
+
 "ssh-encoding",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "ssh-encoding"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
+
dependencies = [
+
 "base64ct",
+
 "pem-rfc7468",
+
 "sha2",
+
]
+

+
[[package]]
+
name = "ssh-key"
+
version = "0.6.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc"
+
dependencies = [
+
 "bcrypt-pbkdf",
+
 "p256",
+
 "p384",
+
 "p521",
+
 "rand_core",
+
 "rsa",
+
 "sec1",
+
 "sha2",
+
 "signature 2.2.0",
+
 "ssh-cipher",
+
 "ssh-encoding",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "subtle"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
+

+
[[package]]
+
name = "syn"
+
version = "1.0.109"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "syn"
+
version = "2.0.60"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "sync_wrapper"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+

+
[[package]]
+
name = "sync_wrapper"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
+

+
[[package]]
+
name = "tar"
+
version = "0.4.40"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb"
+
dependencies = [
+
 "filetime",
+
 "libc",
+
 "xattr",
+
]
+

+
[[package]]
+
name = "tempfile"
+
version = "3.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+
dependencies = [
+
 "cfg-if",
+
 "fastrand",
+
 "rustix",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
+
name = "termion"
+
version = "2.0.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d"
+
dependencies = [
+
 "libc",
+
 "libredox",
+
 "numtoa",
+
 "redox_termios",
+
]
+

+
[[package]]
+
name = "termion"
+
version = "3.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "417813675a504dfbbf21bfde32c03e5bf9f2413999962b479023c02848c1c7a5"
+
dependencies = [
+
 "libc",
+
 "libredox",
+
 "numtoa",
+
 "redox_termios",
+
]
+

+
[[package]]
+
name = "thiserror"
+
version = "1.0.59"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
+
dependencies = [
+
 "thiserror-impl",
+
]
+

+
[[package]]
+
name = "thiserror-impl"
+
version = "1.0.59"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "thread_local"
+
version = "1.1.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+
dependencies = [
+
 "cfg-if",
+
 "once_cell",
+
]
+

+
[[package]]
+
name = "time"
+
version = "0.3.36"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+
dependencies = [
+
 "deranged",
+
 "itoa",
+
 "num-conv",
+
 "powerfmt",
+
 "serde",
+
 "time-core",
+
 "time-macros",
+
]
+

+
[[package]]
+
name = "time-core"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+

+
[[package]]
+
name = "time-macros"
+
version = "0.2.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+
dependencies = [
+
 "num-conv",
+
 "time-core",
+
]
+

+
[[package]]
+
name = "timeago"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1710e589de0a76aaf295cd47a6699f6405737dbfd3cf2b75c92d000b548d0e6"
+

+
[[package]]
+
name = "tinyvec"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+
dependencies = [
+
 "tinyvec_macros",
+
]
+

+
[[package]]
+
name = "tinyvec_macros"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+

+
[[package]]
+
name = "tokio"
+
version = "1.37.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
+
dependencies = [
+
 "backtrace",
+
 "bytes",
+
 "libc",
+
 "mio",
+
 "num_cpus",
+
 "pin-project-lite",
+
 "socket2",
+
 "tokio-macros",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
+
name = "tokio-macros"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "tokio-util"
+
version = "0.7.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
+
dependencies = [
+
 "bytes",
+
 "futures-core",
+
 "futures-sink",
+
 "pin-project-lite",
+
 "tokio",
+
 "tracing",
+
]
+

+
[[package]]
+
name = "tower"
+
version = "0.4.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+
dependencies = [
+
 "futures-core",
+
 "futures-util",
+
 "pin-project",
+
 "pin-project-lite",
+
 "tokio",
+
 "tower-layer",
+
 "tower-service",
+
 "tracing",
+
]
+

+
[[package]]
+
name = "tower-http"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
+
dependencies = [
+
 "bitflags 2.5.0",
+
 "bytes",
+
 "http",
+
 "http-body",
+
 "http-body-util",
+
 "pin-project-lite",
+
 "tower-layer",
+
 "tower-service",
+
 "tracing",
+
]
+

+
[[package]]
+
name = "tower-layer"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+

+
[[package]]
+
name = "tower-service"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+

+
[[package]]
+
name = "tracing"
+
version = "0.1.40"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+
dependencies = [
+
 "log",
+
 "pin-project-lite",
+
 "tracing-attributes",
+
 "tracing-core",
+
]
+

+
[[package]]
+
name = "tracing-attributes"
+
version = "0.1.27"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "tracing-core"
+
version = "0.1.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+
dependencies = [
+
 "once_cell",
+
 "valuable",
+
]
+

+
[[package]]
+
name = "tracing-logfmt"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "22b8e455f6caa5212a102ec530bf86b8dc5a4c536299bffd84b238fed9119be7"
+
dependencies = [
+
 "time",
+
 "tracing",
+
 "tracing-core",
+
 "tracing-subscriber",
+
]
+

+
[[package]]
+
name = "tracing-subscriber"
+
version = "0.3.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
+
dependencies = [
+
 "matchers",
+
 "nu-ansi-term",
+
 "once_cell",
+
 "regex",
+
 "sharded-slab",
+
 "thread_local",
+
 "tracing",
+
 "tracing-core",
+
]
+

+
[[package]]
+
name = "tree-sitter"
+
version = "0.20.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d"
+
dependencies = [
+
 "cc",
+
 "regex",
+
]
+

+
[[package]]
+
name = "tree-sitter-bash"
+
version = "0.20.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "57da2032c37eb2ce29fd18df7d3b94355fec8d6d854d8f80934955df542b5906"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-c"
+
version = "0.20.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4bbd5f3d8658c08581f8f2adac6c391c2e9fa00fe9246bf6c5f52213b9cc6b72"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-css"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c3306ddefa1d2681adda2613d11974ffabfbeb215e23235da6c862f3493a04fd"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-go"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ad6d11f19441b961af2fda7f12f5d0dac325f6d6de83836a1d3750018cc5114"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-highlight"
+
version = "0.20.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "042342584c5a7a0b833d9fc4e2bdab3f9868ddc6c4b339a1e01451c6720868bc"
+
dependencies = [
+
 "regex",
+
 "thiserror",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-html"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "017822b6bd42843c4bd67fabb834f61ce23254e866282dd93871350fd6b7fa1d"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-json"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a9a38a9c679b55cc8d17350381ec08d69fa1a17a53fcf197f344516e485ed4d"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-md"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c20d3ef8d202430b644a307e6299d84bf8ed87fa1b796e4638f8805a595060c"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-python"
+
version = "0.20.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c93b1b1fbd0d399db3445f51fd3058e43d0b4dcff62ddbdb46e66550978aa5"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-ruby"
+
version = "0.20.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "44d50ef383469df8485f024c5fb01faced8cb90368192a7ba02605b43b2427fe"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-rust"
+
version = "0.20.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b0832309b0b2b6d33760ce5c0e818cb47e1d72b468516bfe4134408926fa7594"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-toml"
+
version = "0.20.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca517f578a98b23d20780247cc2688407fa81effad5b627a5a364ec3339b53e8"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "tree-sitter-typescript"
+
version = "0.20.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8bc1d2c24276a48ef097a71b56888ac9db63717e8f8d0b324668a27fd619670"
+
dependencies = [
+
 "cc",
+
 "tree-sitter",
+
]
+

+
[[package]]
+
name = "try-lock"
+
version = "0.2.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+

+
[[package]]
+
name = "typenum"
+
version = "1.17.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+

+
[[package]]
+
name = "unicode-bidi"
+
version = "0.3.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+

+
[[package]]
+
name = "unicode-display-width"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9a43273b656140aa2bb8e65351fe87c255f0eca706b2538a9bd4a590a3490bf3"
+
dependencies = [
+
 "unicode-segmentation",
+
]
+

+
[[package]]
+
name = "unicode-ident"
+
version = "1.0.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+

+
[[package]]
+
name = "unicode-normalization"
+
version = "0.1.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+
dependencies = [
+
 "tinyvec",
+
]
+

+
[[package]]
+
name = "unicode-segmentation"
+
version = "1.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+

+
[[package]]
+
name = "unicode-width"
+
version = "0.1.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
+

+
[[package]]
+
name = "universal-hash"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+
dependencies = [
+
 "crypto-common",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "ureq"
+
version = "2.9.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "11f214ce18d8b2cbe84ed3aa6486ed3f5b285cf8d8fbdbce9f3f767a724adc35"
+
dependencies = [
+
 "base64 0.21.7",
+
 "log",
+
 "once_cell",
+
 "serde",
+
 "serde_json",
+
 "url",
+
]
+

+
[[package]]
+
name = "url"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
+
dependencies = [
+
 "form_urlencoded",
+
 "idna",
+
 "percent-encoding",
+
 "serde",
+
]
+

+
[[package]]
+
name = "utf8parse"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+

+
[[package]]
+
name = "valuable"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+

+
[[package]]
+
name = "vcpkg"
+
version = "0.2.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+

+
[[package]]
+
name = "version_check"
+
version = "0.9.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+

+
[[package]]
+
name = "want"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+
dependencies = [
+
 "try-lock",
+
]
+

+
[[package]]
+
name = "wasi"
+
version = "0.11.0+wasi-snapshot-preview1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+

+
[[package]]
+
name = "wasm-bindgen"
+
version = "0.2.92"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+
dependencies = [
+
 "cfg-if",
+
 "wasm-bindgen-macro",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-backend"
+
version = "0.2.92"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+
dependencies = [
+
 "bumpalo",
+
 "log",
+
 "once_cell",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
 "wasm-bindgen-shared",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-macro"
+
version = "0.2.92"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+
dependencies = [
+
 "quote",
+
 "wasm-bindgen-macro-support",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-macro-support"
+
version = "0.2.92"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
 "wasm-bindgen-backend",
+
 "wasm-bindgen-shared",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-shared"
+
version = "0.2.92"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+

+
[[package]]
+
name = "winapi"
+
version = "0.3.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+
dependencies = [
+
 "winapi-i686-pc-windows-gnu",
+
 "winapi-x86_64-pc-windows-gnu",
+
]
+

+
[[package]]
+
name = "winapi-i686-pc-windows-gnu"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+

+
[[package]]
+
name = "winapi-x86_64-pc-windows-gnu"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+

+
[[package]]
+
name = "windows-core"
+
version = "0.52.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+
dependencies = [
+
 "windows-targets 0.52.5",
+
]
+

+
[[package]]
+
name = "windows-sys"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+
dependencies = [
+
 "windows-targets 0.48.5",
+
]
+

+
[[package]]
+
name = "windows-sys"
+
version = "0.52.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+
dependencies = [
+
 "windows-targets 0.52.5",
+
]
+

+
[[package]]
+
name = "windows-targets"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+
dependencies = [
+
 "windows_aarch64_gnullvm 0.48.5",
+
 "windows_aarch64_msvc 0.48.5",
+
 "windows_i686_gnu 0.48.5",
+
 "windows_i686_msvc 0.48.5",
+
 "windows_x86_64_gnu 0.48.5",
+
 "windows_x86_64_gnullvm 0.48.5",
+
 "windows_x86_64_msvc 0.48.5",
+
]
+

+
[[package]]
+
name = "windows-targets"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
+
dependencies = [
+
 "windows_aarch64_gnullvm 0.52.5",
+
 "windows_aarch64_msvc 0.52.5",
+
 "windows_i686_gnu 0.52.5",
+
 "windows_i686_gnullvm",
+
 "windows_i686_msvc 0.52.5",
+
 "windows_x86_64_gnu 0.52.5",
+
 "windows_x86_64_gnullvm 0.52.5",
+
 "windows_x86_64_msvc 0.52.5",
+
]
+

+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+

+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
+

+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+

+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
+

+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+

+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
+

+
[[package]]
+
name = "windows_i686_gnullvm"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
+

+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+

+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
+

+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+

+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
+

+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+

+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
+

+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+

+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.52.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
+

+
[[package]]
+
name = "xattr"
+
version = "1.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
+
dependencies = [
+
 "libc",
+
 "linux-raw-sys",
+
 "rustix",
+
]
+

+
[[package]]
+
name = "yansi"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
+

+
[[package]]
+
name = "zerocopy"
+
version = "0.7.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
+
dependencies = [
+
 "zerocopy-derive",
+
]
+

+
[[package]]
+
name = "zerocopy-derive"
+
version = "0.7.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.60",
+
]
+

+
[[package]]
+
name = "zeroize"
+
version = "1.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
added http-server/Cargo.toml
@@ -0,0 +1,67 @@
+
[package]
+
name = "radicle-httpd"
+
description = "Radicle HTTP daemon"
+
homepage = "https://radicle.xyz"
+
license = "MIT OR Apache-2.0"
+
version = "0.10.0"
+
authors = ["cloudhead <cloudhead@radicle.xyz>"]
+
edition = "2021"
+
default-run = "radicle-httpd"
+
build = "build.rs"
+

+
[features]
+
default = []
+
logfmt = [
+
  "tracing-logfmt",
+
  "tracing-subscriber/env-filter"
+
]
+

+
[[bin]]
+
name = "radicle-httpd"
+
path = "src/main.rs"
+

+
[[bin]]
+
name = "rad-web"
+
path = "src/bin/rad-web.rs"
+

+
[dependencies]
+
anyhow = { version = "1" }
+
axum = { version = "0.7.2", default-features = false, features = ["json", "query", "tokio", "http1"] }
+
axum-auth = { version= "0.7.0", default-features = false, features = ["auth-bearer"] }
+
axum-server = { version = "0.6.0", default-features = false }
+
base64 = "0.21.3"
+
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
+
fastrand = { version = "2.0.0" }
+
flate2 = { version = "1" }
+
hyper = { version = "1.0.1", default-features = false }
+
lexopt = { version = "0.3.0" }
+
lru = { version = "0.12.0" }
+
nonempty = { version = "0.9.0", features = ["serialize"] }
+
radicle-surf = { version = "0.21.0", default-features = false, features = ["serde"] }
+
serde = { version = "1", features = ["derive"] }
+
serde_json = { version = "1", features = ["preserve_order"] }
+
thiserror = { version = "1" }
+
time = { version = "0.3.17", features = ["parsing", "serde"] }
+
tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] }
+
tower-http = { version = "0.5", default-features = false, features = ["trace", "cors", "set-header"] }
+
tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] }
+
tracing-logfmt = { version = "0.3", optional = true }
+
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] }
+
ureq = { version = "2.9", default-features = false, features = ["json"] }
+
url = { version = "2.5.0" }
+

+
[dependencies.radicle]
+
version = "0.11.0"
+

+
[dependencies.radicle-term]
+
version = "0.10.0"
+

+
[dependencies.radicle-cli]
+
version = "0.10.0"
+

+
[dev-dependencies]
+
hyper = { version = "1.0.1", default-features = false, features = ["client"] }
+
pretty_assertions = { version = "1.3.0" }
+
radicle-crypto = { version = "0.10.0", features = ["test"] }
+
tempfile = { version = "3.3.0" }
+
tower = { version = "0.4", features = ["util"] }
added http-server/build.rs
@@ -0,0 +1,54 @@
+
use std::env;
+
use std::process::Command;
+

+
fn main() -> Result<(), Box<dyn std::error::Error>> {
+
    // Set a build-time `GIT_HEAD` env var which includes the commit id;
+
    // such that we can tell which code is running.
+
    let hash = env::var("GIT_HEAD").unwrap_or_else(|_| {
+
        Command::new("git")
+
            .arg("rev-parse")
+
            .arg("--short")
+
            .arg("HEAD")
+
            .output()
+
            .ok()
+
            .and_then(|output| {
+
                if output.status.success() {
+
                    String::from_utf8(output.stdout).ok()
+
                } else {
+
                    None
+
                }
+
            })
+
            .unwrap_or("unknown".into())
+
    });
+

+
    let version = if let Ok(version) = env::var("RADICLE_VERSION") {
+
        version
+
    } else {
+
        "pre-release".to_owned()
+
    };
+

+
    // Set a build-time `GIT_COMMIT_TIME` env var which includes the commit time.
+
    let commit_time = env::var("GIT_COMMIT_TIME").unwrap_or_else(|_| {
+
        Command::new("git")
+
            .arg("log")
+
            .arg("-1")
+
            .arg("--pretty=%ct")
+
            .arg("HEAD")
+
            .output()
+
            .ok()
+
            .and_then(|output| {
+
                if output.status.success() {
+
                    String::from_utf8(output.stdout).ok()
+
                } else {
+
                    None
+
                }
+
            })
+
            .unwrap_or(0.to_string())
+
    });
+

+
    println!("cargo::rustc-env=RADICLE_VERSION={version}");
+
    println!("cargo::rustc-env=GIT_COMMIT_TIME={commit_time}");
+
    println!("cargo::rustc-env=GIT_HEAD={hash}");
+

+
    Ok(())
+
}
added http-server/build/Dockerfile
@@ -0,0 +1,92 @@
+
# Builds release binaries for Radicle.
+
FROM rust:1.77.2-alpine3.19 as builder
+
LABEL maintainer="Radicle Team <team@radicle.xyz>"
+
WORKDIR /src
+
COPY . .
+

+
# Copy cargo configuration we're going to use to specify compiler options.
+
RUN mkdir -p .cargo && cp build/config.toml .cargo/config.toml
+
# Install dependencies.
+
RUN apk update && apk add --no-cache git musl-dev minisign curl xz asciidoctor
+
# Build man pages and strip metadata. Removes all comments, since they include
+
# non-reproducible information, such as version numbers.
+
RUN asciidoctor --doctype manpage --backend manpage --destination-dir . *.1.adoc && \
+
    find . -maxdepth 1 -type f -name '*.1' -exec sed -i '/^.\\\"/d' '{}' \;
+
# Add cargo targets.
+
RUN rustup target add \
+
    x86_64-unknown-linux-musl \
+
    aarch64-unknown-linux-musl \
+
    x86_64-apple-darwin \
+
    aarch64-apple-darwin
+

+
# Install dependencies for cross-compiling to macOS.
+
# We use Zig as the linker to perform the compilation from a Linux host.
+
# Zig is not yet available on Debian, so we download the official binary.
+
# Compilation is done via `cargo-zigbuild` which is a wrapper around `zig`.
+
RUN curl -sSf -o zig.tar.xz         https://ziglang.org/download/0.12.0/zig-linux-x86_64-0.12.0.tar.xz && \
+
    curl -sSf -o zig.tar.xz.minisig https://ziglang.org/download/0.12.0/zig-linux-x86_64-0.12.0.tar.xz.minisig && \
+
    minisign -Vm zig.tar.xz -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U && \
+
    xz -d -c zig.tar.xz | tar -x && \
+
    mv zig-linux-x86_64-0.12.0/zig /usr/bin/zig && \
+
    mv zig-linux-x86_64-0.12.0/lib /usr/lib/zig && \
+
    cargo install cargo-zigbuild@0.18.3
+

+

+
# Parts of the macOS SDK are required to build Radicle, we make these available
+
# here. So far only `CoreFoundation` and `Security` frameworks are needed.
+
RUN xz -d -c build/macos-sdk-11.3.tar.xz | tar -x
+
# This env var is used by `cargo-zigbuild` to find the SDK.
+
ENV SDKROOT /src/macos-sdk-11.3
+

+
# Build binaries.
+
RUN cargo zigbuild --locked --release \
+
    --target=x86_64-apple-darwin \
+
    --target=aarch64-apple-darwin \
+
    --target=aarch64-unknown-linux-musl \
+
    --target=x86_64-unknown-linux-musl \
+
    -p radicle-cli \
+
    -p radicle-httpd 
+

+
# Now copy the files to a new image without all the intermediary artifacts to
+
# save some space.
+
FROM alpine:3.19 as packager
+
COPY --from=builder \
+
    /src/target/x86_64-unknown-linux-musl/release/rad \
+
    /src/target/x86_64-unknown-linux-musl/release/radicle-httpd \
+
    /builds/x86_64-unknown-linux-musl/bin/
+
COPY --from=builder \
+
    /src/target/aarch64-unknown-linux-musl/release/rad \
+
    /src/target/aarch64-unknown-linux-musl/release/radicle-httpd \
+
    /builds/aarch64-unknown-linux-musl/bin/
+
COPY --from=builder \
+
    /src/target/aarch64-apple-darwin/release/rad \
+
    /src/target/aarch64-apple-darwin/release/radicle-httpd \
+
    /builds/aarch64-apple-darwin/bin/
+
COPY --from=builder \
+
    /src/target/x86_64-apple-darwin/release/rad \
+
    /src/target/x86_64-apple-darwin/release/radicle-httpd \
+
    /builds/x86_64-apple-darwin/bin/
+
COPY --from=builder /src/*.1 /builds/x86_64-unknown-linux-musl/man/man1/
+
COPY --from=builder /src/*.1 /builds/aarch64-unknown-linux-musl/man/man1/
+
COPY --from=builder /src/*.1 /builds/aarch64-apple-darwin/man/man1/
+
COPY --from=builder /src/*.1 /builds/x86_64-apple-darwin/man/man1/
+

+
# Create and compress reproducible archive.
+
WORKDIR /builds
+
RUN x86_64-unknown-linux-musl/bin/rad version --json > radicle.json
+
RUN apk update && apk add --no-cache tar xz
+
RUN find * -maxdepth 0 -type d -exec mv '{}' "radicle-$RADICLE_VERSION-{}" \; && \
+
    find * -maxdepth 0 -type d -exec tar \
+
    --sort=name \
+
    --verbose \
+
    --mtime="@$GIT_COMMIT_TIME" \
+
    --owner=0 \
+
    --group=0 \
+
    --numeric-owner \
+
    --format=posix \
+
    --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
+
    --mode='go+u,go-w' \
+
    --remove-files \
+
    --create --xz \
+
    --file="{}.tar.xz" \
+
    '{}' \;
added http-server/build/README.md
@@ -0,0 +1,70 @@
+
# Builds
+

+
Radicle uses a [reproducible build][rb] pipeline to make binary verification
+
easier and more secure.
+

+
[rb]: https://reproducible-builds.org/
+

+
This build pipeline is designed to be run on an x86_64 machine running Linux.
+
The output is a set of `.tar.xz` archives containing binaries for the supported
+
platforms and signed by the user's Radicle key.
+

+
These binaries are statically linked to be maximally portable, and designed to
+
be reproducible, byte for byte.
+

+
To run the build, simply enter the following command from the repository root:
+

+
    build/build
+

+
This will build all targets and place the output in `build/artifacts` with
+
one sub-directory per build target.
+

+
Note that it will use `git describe` to get a version number for the build.
+
You *must* have a commit tagged with a version in your history or the build
+
will fail, eg. `v1.0.0`.
+

+
When the build completes, the SHA-256 checksums of the artifacts are output.
+
For a given Radicle version and source tree, the same set of checksums should
+
always be output, no matter where or when the build is run. If they do not
+
match, either the build pipeline has a bug, making it non-reproducible, or one
+
of the machines is compromised.
+

+
Here's an example output for a development version of Radicle:
+

+
    b9aa75bba175e18e05df4f6b39ec097414bbf56ccdeb4a2229b557f8b8e05404  radicle-1.0.0-rc.4-3-gb299f3b5-aarch64-apple-darwin.tar.xz
+
    c7070806bf2d17a8a0d3b329e4d57b1e544b7b82cb58e2863074d96348a2ab0d  radicle-1.0.0-rc.4-3-gb299f3b5-aarch64-unknown-linux-musl.tar.xz
+
    1a8327854f16ea90491fb90e0c3291a63c4b2ab01742c8435faec7d370cacb79  radicle-1.0.0-rc.4-3-gb299f3b5-x86_64-apple-darwin.tar.xz
+
    709ac67541ff0c0c570ac22ab2de9f98320e0cc2cc9b67f1909c014a2bb5bd49  radicle-1.0.0-rc.4-3-gb299f3b5-x86_64-unknown-linux-musl.tar.xz
+

+
A script is included in `build/checksums` to output these checksums after
+
the artifacts are built.
+

+
## Requirements
+

+
The following software is required for the build:
+

+
  * `podman`
+
  * `rad` (The Radicle CLI)
+
  * `sha256sum`
+

+
## macOS
+

+
macOS binaries are not signed or notarized, so they have to be downloaded via
+
the CLI to avoid issues. A copy of a small subset of the Apple SDK is included
+
here to be able to cross-compile.
+

+
## Podman
+

+
We use `podman` to make the build reproducible on any machine by controlling
+
the build environment. We prefer `podman` to `docker` because it doesn't
+
require a background process to run and can be run without root access out of
+
the box.
+

+
The first time you run `podman`, you may have to give yourself some extra UIDs
+
for `podman` to use, with:
+

+
    sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
+

+
Then update `podman` with:
+

+
    podman system migrate
added http-server/build/TARGETS
@@ -0,0 +1,4 @@
+
x86_64-unknown-linux-musl
+
aarch64-unknown-linux-musl
+
x86_64-apple-darwin
+
aarch64-apple-darwin
added http-server/build/build
@@ -0,0 +1,112 @@
+
#!/bin/sh
+
set -e
+

+
main() {
+
  # Use UTC time for everything.
+
  export TZ=UTC0
+
  # Set minimal locale.
+
  export LC_ALL=C
+
  # Set source date. This is honored by `asciidoctor` and other tools.
+
  export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
+
  # Define user OS for podman
+
  export OS=$(uname)
+

+
  if ! command -v rad >/dev/null; then
+
    echo "fatal: rad is not installed" >&2
+
    exit 1
+
  fi
+

+
  if ! command -v podman >/dev/null; then
+
    echo "fatal: podman is not installed" >&2
+
    exit 1
+
  fi
+

+
  if ! command -v sha256sum >/dev/null; then
+
    echo "fatal: sha256sum is not installed" >&2
+
    exit 1
+
  fi
+

+
  rev="$(git rev-parse --short HEAD)"
+
  tempdir="$(mktemp -d)"
+
  gitarchive="$tempdir/heartwood-$rev.tar.gz"
+
  keypath="$(rad path)/keys/radicle.pub"
+
  version="$(build/version)"
+
  image=radicle-build-$version
+

+
  if [ ! -f "$keypath" ]; then
+
    echo "fatal: no key found at $keypath" >&2
+
    exit 1
+
  fi
+
  # Authenticate user for signing
+
  rad auth
+

+
  echo "Building Radicle $version.."
+
  echo "Creating archive of repository at $rev in $gitarchive.."
+
  git archive --format tar.gz -o "$gitarchive" HEAD
+

+
  echo "Building image ($image).."
+

+
  case "$OS" in
+
  Darwin)
+
    podman build \
+
      --env SOURCE_DATE_EPOCH \
+
      --env TZ \
+
      --env LC_ALL \
+
      --env GIT_COMMIT_TIME=$SOURCE_DATE_EPOCH \
+
      --env GIT_HEAD=$rev \
+
      --env RADICLE_VERSION=$version \
+
      --arch aarch64 --tag $image -f ./build/Dockerfile - <$gitarchive
+
    ;;
+
  *)
+
    podman --cgroup-manager=cgroupfs build \
+
      --env SOURCE_DATE_EPOCH \
+
      --env TZ \
+
      --env LC_ALL \
+
      --env GIT_COMMIT_TIME=$SOURCE_DATE_EPOCH \
+
      --env GIT_HEAD=$rev \
+
      --env RADICLE_VERSION=$version \
+
      --arch amd64 --tag $image -f ./build/Dockerfile - <$gitarchive
+
    ;;
+
  esac
+

+
  echo "Creating container (radicle-build-container).."
+
  podman --cgroup-manager=cgroupfs create --ulimit=host --replace --name radicle-build-container $image
+

+
  # Copy build artifacts to output folder.
+
  outdir=build/artifacts
+
  mkdir -p $outdir
+
  podman cp --overwrite radicle-build-container:/builds/. $outdir/
+

+
  for target in $(cat build/TARGETS); do
+
    echo "Signing artifacts for $target.."
+

+
    filename="radicle-$version-$target.tar.xz"
+
    filepath="$outdir/$filename"
+

+
    # Output SHA256 digest of archive.
+
    checksum="$(cd $outdir && sha256sum $filename)"
+
    echo "Checksum of $filepath is $(echo "$checksum" | cut -d' ' -f1)"
+
    echo "$checksum" >$filepath.sha256
+

+
    # Sign archive and verify archive.
+
    rm -f $filepath.sig # Delete existing signature
+
    ssh-keygen -Y sign -n file -f $keypath $filepath
+
    ssh-keygen -Y check-novalidate -n file -s $filepath.sig <$filepath
+
  done
+

+
  # Remove build artifacts that aren't needed anymore.
+
  rm -f $gitarchive
+
  podman rm radicle-build-container >/dev/null
+
  podman rmi --ignore localhost/$image
+
}
+

+
# Run build.
+
echo "Running build.."
+
main "$@"
+

+
# Show artifact checksums.
+
echo
+
build/checksums
+
echo
+

+
echo "Build successful."
added http-server/build/checksums
@@ -0,0 +1,2 @@
+
#!/bin/sh
+
find build/artifacts -type f -name '*.sha256' -exec cat {} +
added http-server/build/config.toml
@@ -0,0 +1,13 @@
+
[target.x86_64-unknown-linux-musl]
+
rustflags = [
+
    "-C", "codegen-units=1",
+
    "-C", "incremental=false",
+
    "-C", "opt-level=3",
+
]
+

+
[target.aarch64-unknown-linux-musl]
+
rustflags = [
+
    "-C", "codegen-units=1",
+
    "-C", "incremental=false",
+
    "-C", "opt-level=3",
+
]
added http-server/build/macos-sdk-11.3.tar.xz
added http-server/build/tag
@@ -0,0 +1,38 @@
+
#!/bin/sh
+
set -e
+

+
if [ $# -ne 1 ]; then
+
  echo "Usage: $0 <version-number>"
+
  exit 1
+
fi
+

+
version="$1"
+
tag="v$version"
+
commit="$(git rev-parse HEAD)"
+
signing_key=$(git config user.signingKey)
+

+
git show "$commit"
+

+
if [ "$signing_key" != "$(rad self --ssh-key)" ]; then
+
  echo "The Git signing key does not match the output of 'rad self --ssh-key'."
+
  exit 1
+
fi
+

+
printf "\n"
+
printf "Tag the above commit with \033[35m$tag\033[0m, using \033[35m$(rad self --did)\033[0m? [y/N] "
+
read confirmation
+
rad auth
+

+
case "$confirmation" in
+
  [Yy]*)
+
    if git tag --annotate --sign "$tag" -m "Release $version" "$commit"; then
+
      echo "Tag $tag created and signed over $commit."
+
    else
+
      echo "Failed to create tag."
+
      exit 1
+
    fi ;;
+
  *)
+
    echo "Operation aborted."
+
    exit 1 ;;
+
esac
+

added http-server/build/upload
@@ -0,0 +1,47 @@
+
#!/bin/sh
+
set -e
+

+
SSH_LOGIN=${SSH_LOGIN:-release}
+
SSH_ADDRESS=${SSH_ADDRESS:-$SSH_LOGIN@files.radicle.xyz}
+
SSH_KEY="$(rad path)/keys/radicle"
+

+
main() {
+
  version="$(build/version)"
+

+
  echo "Uploading Radicle $version..."
+

+
  if [ -z "$version" ]; then
+
    echo "fatal: empty version number" >&2 ; exit 1
+
  fi
+

+
  # Create remote folder.
+
  ssh -i $SSH_KEY $SSH_ADDRESS mkdir -p /mnt/radicle/files/releases/$version
+
  # Copy files over.
+
  scp -i $SSH_KEY build/artifacts/radicle-$version* $SSH_ADDRESS:/mnt/radicle/files/releases/$version
+
  scp -i $SSH_KEY build/artifacts/radicle.json $SSH_ADDRESS:/mnt/radicle/files/releases/$version
+

+
  for target in $(cat build/TARGETS); do
+
    archive=/mnt/radicle/files/releases/$version/radicle-$version-$target.tar.xz
+
    symlink=/mnt/radicle/files/releases/$version/radicle-$target.tar.xz
+

+
    echo "Creating symlinks for $target.."
+

+
    ssh -i $SSH_KEY $SSH_ADDRESS ln -snf $archive $symlink
+
    ssh -i $SSH_KEY $SSH_ADDRESS ln -snf $archive.sig $symlink.sig
+
    ssh -i $SSH_KEY $SSH_ADDRESS ln -snf $archive.sha256 $symlink.sha256
+
  done
+

+
  if git describe --exact-match --match='v*' 2>/dev/null; then
+
    echo "Creating 'latest' symlink.."
+
    ssh -i $SSH_KEY $SSH_ADDRESS ln -snf /mnt/radicle/files/releases/$version /mnt/radicle/files/releases/latest
+
  else
+
    echo "Skipping 'latest' symlink creation for development build."
+
  fi
+

+
  echo "Pushing tags.."
+
  git push rad --tags
+

+
  echo "Done."
+
}
+

+
main "$@"
added http-server/build/version
@@ -0,0 +1,9 @@
+
#!/bin/sh
+

+
if ! version="$(git describe --match='v*' --candidates=1 2>/dev/null)"; then
+
  echo "fatal: no version tag found by 'git describe'" >&2 ; exit 1
+
fi
+
# Remove `v` prefix from version.
+
version=${version#v}
+

+
echo $version
added http-server/radicle-httpd.1.adoc
@@ -0,0 +1,25 @@
+
= radicle-httpd(1)
+
The Radicle Team <team@radicle.xyz>
+
:doctype: manpage
+
:revnumber: 1.0.0
+
:revdate: 2024-04-22
+
:mansource: rad {revnumber}
+
:manmanual: Radicle CLI Manual
+

+
== Name
+

+
radicle-httpd - Radicle HTTP daemon
+

+
== Synopsis
+

+
*radicle-httpd* --help
+

+
== Description
+

+
A Radicle HTTP daemon exposing a JSON HTTP API that allows someone to browse local
+
repositories on a Radicle node via their web browser. This manual page is a
+
placeholder to point you at the *--help* option.
+

+
== SEE ALSO ==
+

+
*rad*(1)
added http-server/src/api.rs
@@ -0,0 +1,261 @@
+
pub mod auth;
+

+
use std::collections::HashMap;
+
use std::sync::Arc;
+
use std::time::Duration;
+

+
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE};
+
use axum::http::Method;
+
use axum::response::{IntoResponse, Json};
+
use axum::routing::get;
+
use axum::Router;
+
use radicle::issue::cache::Issues as _;
+
use radicle::patch::cache::Patches as _;
+
use radicle::storage::git::Repository;
+
use serde::{Deserialize, Serialize};
+
use serde_json::json;
+
use tokio::sync::RwLock;
+
use tower_http::cors::{self, CorsLayer};
+

+
use radicle::cob::{issue, patch, Author};
+
use radicle::identity::{DocAt, RepoId};
+
use radicle::node::policy::Scope;
+
use radicle::node::routing::Store;
+
use radicle::node::AliasStore;
+
use radicle::node::{Handle, NodeId};
+
use radicle::storage::{ReadRepository, ReadStorage};
+
use radicle::{Node, Profile};
+

+
mod error;
+
mod json;
+
mod v1;
+

+
use crate::api::error::Error;
+
use crate::cache::Cache;
+
use crate::Options;
+

+
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
+
// This version has to be updated on every breaking change to the radicle-httpd API.
+
pub const API_VERSION: &str = "0.1.0";
+

+
/// Identifier for sessions
+
type SessionId = String;
+

+
#[derive(Clone)]
+
pub struct Context {
+
    profile: Arc<Profile>,
+
    sessions: Arc<RwLock<HashMap<SessionId, auth::Session>>>,
+
    cache: Option<Cache>,
+
}
+

+
impl Context {
+
    pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
+
        Self {
+
            profile,
+
            sessions: Default::default(),
+
            cache: options.cache.map(Cache::new),
+
        }
+
    }
+

+
    pub fn project_info<R: ReadRepository + radicle::cob::Store>(
+
        &self,
+
        repo: &R,
+
        doc: DocAt,
+
    ) -> Result<project::Info, error::Error> {
+
        let (_, head) = repo.head()?;
+
        let DocAt { doc, .. } = doc;
+
        let id = repo.id();
+

+
        let payload = doc.project()?;
+
        let aliases = self.profile.aliases();
+
        let delegates = doc
+
            .delegates
+
            .into_iter()
+
            .map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
+
            .collect::<Vec<_>>();
+
        let issues = self.profile.issues(repo)?.counts()?;
+
        let patches = self.profile.patches(repo)?.counts()?;
+
        let db = &self.profile.database()?;
+
        let seeding = db.count(&id).unwrap_or_default();
+

+
        Ok(project::Info {
+
            payload,
+
            delegates,
+
            threshold: doc.threshold,
+
            visibility: doc.visibility,
+
            head,
+
            issues,
+
            patches,
+
            id,
+
            seeding,
+
        })
+
    }
+

+
    /// Get a repository by RID, checking to make sure we're allowed to view it.
+
    pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
+
        let repo = self.profile.storage.repository(rid)?;
+
        let doc = repo.identity_doc()?;
+
        // Don't allow accessing private repos.
+
        if doc.visibility.is_private() {
+
            return Err(Error::NotFound);
+
        }
+
        Ok((repo, doc))
+
    }
+

+
    #[cfg(test)]
+
    pub fn profile(&self) -> &Arc<Profile> {
+
        &self.profile
+
    }
+

+
    #[cfg(test)]
+
    pub fn sessions(&self) -> &Arc<RwLock<HashMap<SessionId, auth::Session>>> {
+
        &self.sessions
+
    }
+
}
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/", get(root_handler))
+
        .merge(v1::router(ctx))
+
        .layer(
+
            CorsLayer::new()
+
                .max_age(Duration::from_secs(86400))
+
                .allow_origin(cors::Any)
+
                .allow_methods([
+
                    Method::GET,
+
                    Method::POST,
+
                    Method::PATCH,
+
                    Method::PUT,
+
                    Method::DELETE,
+
                ])
+
                .allow_headers([CONTENT_TYPE, AUTHORIZATION]),
+
        )
+
}
+

+
async fn root_handler() -> impl IntoResponse {
+
    let response = json!({
+
        "path": "/api",
+
        "links": [
+
            {
+
                "href": "/v1",
+
                "rel": "v1",
+
                "type": "GET"
+
            }
+
        ]
+
    });
+

+
    Json(response)
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct PaginationQuery {
+
    #[serde(default)]
+
    pub show: ProjectQuery,
+
    pub page: Option<usize>,
+
    pub per_page: Option<usize>,
+
}
+

+
#[derive(Serialize, Deserialize, Clone, Default)]
+
#[serde(rename_all = "camelCase")]
+
pub enum ProjectQuery {
+
    All,
+
    #[default]
+
    Pinned,
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RawQuery {
+
    pub mime: Option<String>,
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct CobsQuery<T> {
+
    pub page: Option<usize>,
+
    pub per_page: Option<usize>,
+
    pub state: Option<T>,
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct PoliciesQuery {
+
    /// The NID from which to fetch from after tracking a repo.
+
    pub from: Option<NodeId>,
+
    pub scope: Option<Scope>,
+
}
+

+
#[derive(Default, Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub enum IssueState {
+
    Closed,
+
    #[default]
+
    Open,
+
}
+

+
impl IssueState {
+
    pub fn matches(&self, issue: &issue::State) -> bool {
+
        match self {
+
            Self::Open => matches!(issue, issue::State::Open),
+
            Self::Closed => matches!(issue, issue::State::Closed { .. }),
+
        }
+
    }
+
}
+

+
#[derive(Default, Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub enum PatchState {
+
    #[default]
+
    Open,
+
    Draft,
+
    Archived,
+
    Merged,
+
}
+

+
impl PatchState {
+
    pub fn matches(&self, patch: &patch::State) -> bool {
+
        match self {
+
            Self::Open => matches!(patch, patch::State::Open { .. }),
+
            Self::Draft => matches!(patch, patch::State::Draft),
+
            Self::Archived => matches!(patch, patch::State::Archived),
+
            Self::Merged => matches!(patch, patch::State::Merged { .. }),
+
        }
+
    }
+
}
+

+
mod project {
+
    use serde::Serialize;
+
    use serde_json::Value;
+

+
    use radicle::cob;
+
    use radicle::git::Oid;
+
    use radicle::identity::project::Project;
+
    use radicle::identity::{RepoId, Visibility};
+

+
    /// Project info.
+
    #[derive(Serialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct Info {
+
        /// Project metadata.
+
        #[serde(flatten)]
+
        pub payload: Project,
+
        pub delegates: Vec<Value>,
+
        pub threshold: usize,
+
        pub visibility: Visibility,
+
        pub head: Oid,
+
        pub patches: cob::patch::PatchCounts,
+
        pub issues: cob::issue::IssueCounts,
+
        pub id: RepoId,
+
        pub seeding: usize,
+
    }
+
}
+

+
/// Announce refs to the network for the given RID.
+
pub fn announce_refs(mut node: Node, rid: RepoId) -> Result<(), Error> {
+
    match node.announce_refs(rid) {
+
        Ok(_) => Ok(()),
+
        Err(e) if e.is_connection_err() => Ok(()),
+
        Err(e) => Err(e.into()),
+
    }
+
}
added http-server/src/api/auth.rs
@@ -0,0 +1,44 @@
+
use serde::{Deserialize, Serialize};
+
use time::serde::timestamp;
+
use time::{Duration, OffsetDateTime};
+

+
use radicle::crypto::PublicKey;
+
use radicle::node::Alias;
+

+
use crate::api::error::Error;
+
use crate::api::Context;
+

+
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
+
pub const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);
+

+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+
#[serde(rename_all = "lowercase")]
+
pub enum AuthState {
+
    Authorized,
+
    Unauthorized,
+
}
+

+
#[derive(Clone, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Session {
+
    pub status: AuthState,
+
    pub public_key: PublicKey,
+
    pub alias: Alias,
+
    #[serde(with = "timestamp")]
+
    pub issued_at: OffsetDateTime,
+
    #[serde(with = "timestamp")]
+
    pub expires_at: OffsetDateTime,
+
}
+

+
pub async fn validate(ctx: &Context, token: &str) -> Result<(), Error> {
+
    let sessions_store = ctx.sessions.read().await;
+
    let session = sessions_store
+
        .get(token)
+
        .ok_or(Error::Auth("Unauthorized"))?;
+

+
    if session.status != AuthState::Authorized || session.expires_at <= OffsetDateTime::now_utc() {
+
        return Err(Error::Auth("Unauthorized"));
+
    }
+

+
    Ok(())
+
}
added http-server/src/api/error.rs
@@ -0,0 +1,158 @@
+
use axum::http::StatusCode;
+
use axum::response::{IntoResponse, Response};
+
use axum::Json;
+
use serde_json::json;
+

+
/// Errors relating to the API backend.
+
#[derive(Debug, thiserror::Error)]
+
pub enum Error {
+
    /// The entity was not found.
+
    #[error("entity not found")]
+
    NotFound,
+

+
    /// An error occurred during an authentication process.
+
    #[error("could not authenticate: {0}")]
+
    Auth(&'static str),
+

+
    /// An error occurred with env variables.
+
    #[error(transparent)]
+
    Env(#[from] std::env::VarError),
+

+
    /// Profile error.
+
    #[error(transparent)]
+
    Profile(#[from] radicle::profile::Error),
+

+
    /// Crypto error.
+
    #[error(transparent)]
+
    Crypto(#[from] radicle::crypto::Error),
+

+
    /// Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+

+
    /// Cob cache error.
+
    #[error(transparent)]
+
    CobCache(#[from] radicle::cob::cache::Error),
+

+
    /// Cob issue cache error.
+
    #[error(transparent)]
+
    CacheIssue(#[from] radicle::cob::issue::cache::Error),
+

+
    /// Cob issue error.
+
    #[error(transparent)]
+
    CobIssue(#[from] radicle::cob::issue::Error),
+

+
    /// Cob patch error.
+
    #[error(transparent)]
+
    CobPatch(#[from] radicle::cob::patch::Error),
+

+
    /// Cob patch cache error.
+
    #[error(transparent)]
+
    CachePatch(#[from] radicle::cob::patch::cache::Error),
+

+
    /// Cob store error.
+
    #[error(transparent)]
+
    CobStore(#[from] radicle::cob::store::Error),
+

+
    /// Repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
+

+
    /// Routing error.
+
    #[error(transparent)]
+
    Routing(#[from] radicle::node::routing::Error),
+

+
    /// Project doc error.
+
    #[error(transparent)]
+
    ProjectDoc(#[from] radicle::identity::doc::PayloadError),
+

+
    /// Surf directory error.
+
    #[error(transparent)]
+
    SurfDir(#[from] radicle_surf::fs::error::Directory),
+

+
    /// Surf error.
+
    #[error(transparent)]
+
    Surf(#[from] radicle_surf::Error),
+

+
    /// Git2 error.
+
    #[error(transparent)]
+
    Git2(#[from] radicle::git::raw::Error),
+

+
    /// Storage refs error.
+
    #[error(transparent)]
+
    StorageRef(#[from] radicle::storage::refs::Error),
+

+
    /// Identity doc error.
+
    #[error(transparent)]
+
    IdentityDoc(#[from] radicle::identity::doc::DocError),
+

+
    /// Tracking store error.
+
    #[error(transparent)]
+
    TrackingStore(#[from] radicle::node::policy::store::Error),
+

+
    /// Node database error.
+
    #[error(transparent)]
+
    Database(#[from] radicle::node::db::Error),
+

+
    /// Node error.
+
    #[error(transparent)]
+
    Node(#[from] radicle::node::Error),
+

+
    /// Invalid update to issue or patch.
+
    #[error("{0}")]
+
    BadRequest(String),
+
}
+

+
impl IntoResponse for Error {
+
    fn into_response(self) -> Response {
+
        let message = self.to_string();
+
        let (status, msg) = match self {
+
            Error::NotFound => (StatusCode::NOT_FOUND, None),
+
            Error::CobStore(e @ radicle::cob::store::Error::NotFound(_, _)) => {
+
                (StatusCode::NOT_FOUND, Some(e.to_string()))
+
            }
+
            Error::Auth(msg) => (StatusCode::UNAUTHORIZED, Some(msg.to_string())),
+
            Error::Crypto(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
+
            Error::Surf(radicle_surf::Error::Git(e)) if radicle::git::is_not_found_err(&e) => {
+
                (StatusCode::NOT_FOUND, Some(e.message().to_owned()))
+
            }
+
            Error::Surf(radicle_surf::Error::Directory(
+
                e @ radicle_surf::fs::error::Directory::PathNotFound(_),
+
            )) => (StatusCode::NOT_FOUND, Some(e.to_string())),
+
            Error::Git2(e) if radicle::git::is_not_found_err(&e) => {
+
                (StatusCode::NOT_FOUND, Some(e.message().to_owned()))
+
            }
+
            Error::Git2(e) => (
+
                StatusCode::INTERNAL_SERVER_ERROR,
+
                Some(e.message().to_owned()),
+
            ),
+
            Error::Storage(err) if err.is_not_found() => {
+
                (StatusCode::NOT_FOUND, Some(err.to_string()))
+
            }
+
            Error::Repository(err) if err.is_not_found() => {
+
                (StatusCode::NOT_FOUND, Some(err.to_string()))
+
            }
+
            Error::StorageRef(err) if err.is_not_found() => {
+
                (StatusCode::NOT_FOUND, Some(err.to_string()))
+
            }
+
            Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
+
            other => {
+
                tracing::error!("Error: {message}");
+
                tracing::debug!("Error Debug: {:?}", other);
+

+
                if cfg!(debug_assertions) {
+
                    (StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
+
                } else {
+
                    (StatusCode::INTERNAL_SERVER_ERROR, None)
+
                }
+
            }
+
        };
+

+
        let body = Json(json!({
+
            "error": msg.or_else(|| status.canonical_reason().map(|r| r.to_string())),
+
            "code": status.as_u16()
+
        }));
+

+
        (status, body).into_response()
+
    }
+
}
added http-server/src/api/json.rs
@@ -0,0 +1,312 @@
+
//! Utilities for building JSON responses of our API.
+

+
use std::collections::BTreeMap;
+
use std::path::Path;
+
use std::str;
+

+
use base64::prelude::{Engine, BASE64_STANDARD};
+
use radicle::cob::{CodeLocation, Reaction};
+
use radicle::patch::ReviewId;
+
use serde_json::{json, Value};
+

+
use radicle::cob::issue::{Issue, IssueId};
+
use radicle::cob::patch::{Merge, Patch, PatchId, Review};
+
use radicle::cob::thread::{Comment, CommentId, Edit};
+
use radicle::cob::{ActorId, Author};
+
use radicle::git::RefString;
+
use radicle::node::{Alias, AliasStore};
+
use radicle::prelude::NodeId;
+
use radicle::storage::{git, refs, RemoteRepository};
+
use radicle_surf::blob::Blob;
+
use radicle_surf::tree::{EntryKind, Tree};
+
use radicle_surf::{Commit, Oid};
+

+
use crate::api::auth::Session;
+

+
/// Returns JSON of a commit.
+
pub(crate) fn commit(commit: &Commit) -> Value {
+
    json!({
+
      "id": commit.id,
+
      "author": {
+
        "name": commit.author.name,
+
        "email": commit.author.email
+
      },
+
      "summary": commit.summary,
+
      "description": commit.description(),
+
      "parents": commit.parents,
+
      "committer": {
+
        "name": commit.committer.name,
+
        "email": commit.committer.email,
+
        "time": commit.committer.time.seconds()
+
      }
+
    })
+
}
+

+
/// Returns JSON of a session.
+
pub(crate) fn session(session_id: String, session: &Session) -> Value {
+
    json!({
+
      "sessionId": session_id,
+
      "status": session.status,
+
      "publicKey": session.public_key,
+
      "alias": session.alias,
+
      "issuedAt": session.issued_at.unix_timestamp(),
+
      "expiresAt": session.expires_at.unix_timestamp()
+
    })
+
}
+

+
/// Returns JSON for a blob with a given `path`.
+
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
+
    json!({
+
        "binary": blob.is_binary(),
+
        "name": name_in_path(path),
+
        "content": blob_content(blob),
+
        "path": path,
+
        "lastCommit": commit(blob.commit())
+
    })
+
}
+

+
/// Returns a string for the blob content, encoded in base64 if binary.
+
pub fn blob_content<T: AsRef<[u8]>>(blob: &Blob<T>) -> String {
+
    match str::from_utf8(blob.content()) {
+
        Ok(s) => s.to_owned(),
+
        Err(_) => BASE64_STANDARD.encode(blob.content()),
+
    }
+
}
+

+
/// Returns JSON for a tree with a given `path` and `stats`.
+
pub(crate) fn tree(tree: &Tree, path: &str) -> Value {
+
    let prefix = Path::new(path);
+
    let entries = tree
+
        .entries()
+
        .iter()
+
        .map(|entry| {
+
            json!({
+
                "path": prefix.join(entry.name()),
+
                "oid": entry.object_id(),
+
                "name": entry.name(),
+
                "kind": match entry.entry() {
+
                    EntryKind::Tree(_) => "tree",
+
                    EntryKind::Blob(_) => "blob",
+
                    EntryKind::Submodule { .. } => "submodule"
+
                },
+
            })
+
        })
+
        .collect::<Vec<_>>();
+

+
    json!({
+
        "entries": &entries,
+
        "lastCommit": commit(tree.commit()),
+
        "name": name_in_path(path),
+
        "path": path,
+
    })
+
}
+

+
/// Returns JSON for an `issue`.
+
pub(crate) fn issue(id: IssueId, issue: Issue, aliases: &impl AliasStore) -> Value {
+
    json!({
+
        "id": id.to_string(),
+
        "author": author(&issue.author(), aliases.alias(issue.author().id())),
+
        "title": issue.title(),
+
        "state": issue.state(),
+
        "assignees": issue.assignees().map(|assignee|
+
            author(&Author::from(*assignee.as_key()), aliases.alias(assignee))
+
        ).collect::<Vec<_>>(),
+
        "discussion": issue.comments().map(|(id, c)| issue_comment(id, c, aliases)).collect::<Vec<_>>(),
+
        "labels": issue.labels().collect::<Vec<_>>(),
+
    })
+
}
+

+
/// Returns JSON for a `patch`.
+
pub(crate) fn patch(
+
    id: PatchId,
+
    patch: Patch,
+
    repo: &git::Repository,
+
    aliases: &impl AliasStore,
+
) -> Value {
+
    json!({
+
        "id": id.to_string(),
+
        "author": author(patch.author(), aliases.alias(patch.author().id())),
+
        "title": patch.title(),
+
        "state": patch.state(),
+
        "target": patch.target(),
+
        "labels": patch.labels().collect::<Vec<_>>(),
+
        "merges": patch.merges().map(|(nid, m)| merge(nid, m, aliases)).collect::<Vec<_>>(),
+
        "assignees": patch.assignees().map(|assignee|
+
            author(&Author::from(*assignee), aliases.alias(&assignee))
+
        ).collect::<Vec<_>>(),
+
        "revisions": patch.revisions().map(|(id, rev)| {
+
            json!({
+
                "id": id,
+
                "author": author(rev.author(), aliases.alias(rev.author().id())),
+
                "description": rev.description(),
+
                "edits": rev.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
                "reactions": rev.reactions().iter().flat_map(|(location, reaction)| {
+
                    reactions(reaction.iter().fold(BTreeMap::new(), |mut acc: BTreeMap<&Reaction, Vec<_>>, (author, emoji)| {
+
                        acc.entry(emoji).or_default().push(author);
+
                        acc
+
                    }), location.as_ref(), aliases)
+
                }).collect::<Vec<_>>(),
+
                "base": rev.base(),
+
                "oid": rev.head(),
+
                "refs": get_refs(repo, patch.author().id(), &rev.head()).unwrap_or_default(),
+
                "discussions": rev.discussion().comments().map(|(id, c)| {
+
                    patch_comment(id, c, aliases)
+
                }).collect::<Vec<_>>(),
+
                "timestamp": rev.timestamp().as_secs(),
+
                "reviews": patch.reviews_of(id).map(move |(id, r)| {
+
                    review(id, r, aliases)
+
                }).collect::<Vec<_>>(),
+
            })
+
        }).collect::<Vec<_>>(),
+
    })
+
}
+

+
/// Returns JSON for a `reaction`.
+
fn reactions(
+
    reactions: BTreeMap<&Reaction, Vec<&ActorId>>,
+
    location: Option<&CodeLocation>,
+
    aliases: &impl AliasStore,
+
) -> Vec<Value> {
+
    reactions
+
        .into_iter()
+
        .map(|(emoji, authors)| {
+
            if let Some(l) = location {
+
                json!({ "location": l, "emoji": emoji, "authors": authors.into_iter().map(|a|
+
                    author(&Author::from(*a), aliases.alias(a))
+
                ).collect::<Vec<_>>()})
+
            } else {
+
                json!({ "emoji": emoji, "authors": authors.into_iter().map(|a|
+
                    author(&Author::from(*a), aliases.alias(a))
+
                ).collect::<Vec<_>>()})
+
            }
+
        })
+
        .collect::<Vec<_>>()
+
}
+

+
/// Returns JSON for an `author` and fills in `alias` when present.
+
pub(crate) fn author(author: &Author, alias: Option<Alias>) -> Value {
+
    match alias {
+
        Some(alias) => json!({
+
            "id": author.id,
+
            "alias": alias,
+
        }),
+
        None => json!(author),
+
    }
+
}
+

+
/// Returns JSON for a patch `Merge` and fills in `alias` when present.
+
fn merge(nid: &NodeId, merge: &Merge, aliases: &impl AliasStore) -> Value {
+
    json!({
+
        "author": author(&Author::from(*nid), aliases.alias(nid)),
+
        "commit": merge.commit,
+
        "timestamp": merge.timestamp.as_secs(),
+
        "revision": merge.revision,
+
    })
+
}
+

+
/// Returns JSON for a patch `Review` and fills in `alias` when present.
+
fn review(id: &ReviewId, review: &Review, aliases: &impl AliasStore) -> Value {
+
    let a = review.author();
+
    json!({
+
        "id": id,
+
        "author": author(a, aliases.alias(a.id())),
+
        "verdict": review.verdict(),
+
        "summary": review.summary(),
+
        "comments": review.comments().map(|(id, c)| review_comment(id, c, aliases)).collect::<Vec<_>>(),
+
        "timestamp": review.timestamp().as_secs(),
+
    })
+
}
+

+
/// Returns JSON for an `Edit`.
+
fn edit(edit: &Edit, aliases: &impl AliasStore) -> Value {
+
    json!({
+
      "author": author(&Author::from(edit.author), aliases.alias(&edit.author)),
+
      "body": edit.body,
+
      "timestamp": edit.timestamp.as_secs(),
+
      "embeds": edit.embeds,
+
    })
+
}
+

+
/// Returns JSON for a Issue `Comment`.
+
fn issue_comment(id: &CommentId, comment: &Comment, aliases: &impl AliasStore) -> Value {
+
    json!({
+
        "id": *id,
+
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
+
        "body": comment.body(),
+
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
        "embeds": comment.embeds().to_vec(),
+
        "reactions": reactions(comment.reactions(), None, aliases),
+
        "timestamp": comment.timestamp().as_secs(),
+
        "replyTo": comment.reply_to(),
+
        "resolved": comment.is_resolved(),
+
    })
+
}
+

+
/// Returns JSON for a Patch `Comment`.
+
fn patch_comment(
+
    id: &CommentId,
+
    comment: &Comment<CodeLocation>,
+
    aliases: &impl AliasStore,
+
) -> Value {
+
    json!({
+
        "id": *id,
+
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
+
        "body": comment.body(),
+
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
        "embeds": comment.embeds().to_vec(),
+
        "reactions": reactions(comment.reactions(), None, aliases),
+
        "timestamp": comment.timestamp().as_secs(),
+
        "replyTo": comment.reply_to(),
+
        "location": comment.location(),
+
        "resolved": comment.is_resolved(),
+
    })
+
}
+

+
/// Returns JSON for a `Review`.
+
fn review_comment(
+
    id: &CommentId,
+
    comment: &Comment<CodeLocation>,
+
    aliases: &impl AliasStore,
+
) -> Value {
+
    json!({
+
        "id": *id,
+
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
+
        "body": comment.body(),
+
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
        "embeds": comment.embeds().to_vec(),
+
        "reactions": reactions(comment.reactions(), None, aliases),
+
        "timestamp": comment.timestamp().as_secs(),
+
        "replyTo": comment.reply_to(),
+
        "location": comment.location(),
+
        "resolved": comment.is_resolved(),
+
    })
+
}
+

+
/// Returns the name part of a path string.
+
fn name_in_path(path: &str) -> &str {
+
    match path.rsplit('/').next() {
+
        Some(name) => name,
+
        None => path,
+
    }
+
}
+

+
fn get_refs(
+
    repo: &git::Repository,
+
    id: &ActorId,
+
    head: &Oid,
+
) -> Result<Vec<RefString>, refs::Error> {
+
    let remote = repo.remote(id)?;
+
    let refs = remote
+
        .refs
+
        .iter()
+
        .filter_map(|(name, o)| {
+
            if o == head {
+
                Some(name.to_owned())
+
            } else {
+
                None
+
            }
+
        })
+
        .collect::<Vec<_>>();
+

+
    Ok(refs)
+
}
added http-server/src/api/v1.rs
@@ -0,0 +1,71 @@
+
mod delegates;
+
mod node;
+
mod profile;
+
mod projects;
+
mod sessions;
+
mod stats;
+

+
use axum::extract::State;
+
use axum::response::{IntoResponse, Json};
+
use axum::routing::get;
+
use axum::Router;
+
use serde_json::json;
+

+
use crate::api::{Context, API_VERSION, RADICLE_VERSION};
+

+
pub fn router(ctx: Context) -> Router {
+
    let root_router = Router::new()
+
        .route("/", get(root_handler))
+
        .with_state(ctx.clone());
+

+
    let routes = Router::new()
+
        .merge(root_router)
+
        .merge(node::router(ctx.clone()))
+
        .merge(profile::router(ctx.clone()))
+
        .merge(sessions::router(ctx.clone()))
+
        .merge(delegates::router(ctx.clone()))
+
        .merge(projects::router(ctx.clone()))
+
        .merge(stats::router(ctx));
+

+
    Router::new().nest("/v1", routes)
+
}
+

+
async fn root_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let response = json!({
+
        "message": "Welcome!",
+
        "service": "radicle-httpd",
+
        "version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")),
+
        "apiVersion": API_VERSION,
+
        "nid": ctx.profile.public_key,
+
        "path": "/api/v1",
+
        "links": [
+
            {
+
                "href": "/projects",
+
                "rel": "projects",
+
                "type": "GET"
+
            },
+
            {
+
                "href": "/node",
+
                "rel": "node",
+
                "type": "GET"
+
            },
+
            {
+
                "href": "/delegates/:did/projects",
+
                "rel": "projects",
+
                "type": "GET"
+
            },
+
            {
+
                "href": "/profile",
+
                "rel": "profile",
+
                "type": "GET"
+
            },
+
            {
+
                "href": "/stats",
+
                "rel": "stats",
+
                "type": "GET"
+
            }
+
        ]
+
    });
+

+
    Json(response)
+
}
added http-server/src/api/v1/delegates.rs
@@ -0,0 +1,225 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::routing::get;
+
use axum::{Json, Router};
+

+
use radicle::cob::Author;
+
use radicle::identity::Did;
+
use radicle::issue::cache::Issues as _;
+
use radicle::node::routing::Store;
+
use radicle::node::AliasStore;
+
use radicle::patch::cache::Patches as _;
+
use radicle::storage::{ReadRepository, ReadStorage};
+

+
use crate::api::error::Error;
+
use crate::api::json;
+
use crate::api::project::Info;
+
use crate::api::Context;
+
use crate::api::{PaginationQuery, ProjectQuery};
+
use crate::axum_extra::{Path, Query};
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route(
+
            "/delegates/:delegate/projects",
+
            get(delegates_projects_handler),
+
        )
+
        .with_state(ctx)
+
}
+

+
/// List all projects which delegate is a part of.
+
/// `GET /delegates/:delegate/projects`
+
async fn delegates_projects_handler(
+
    State(ctx): State<Context>,
+
    Path(delegate): Path<Did>,
+
    Query(qs): Query<PaginationQuery>,
+
) -> impl IntoResponse {
+
    let PaginationQuery {
+
        show,
+
        page,
+
        per_page,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let storage = &ctx.profile.storage;
+
    let db = &ctx.profile.database()?;
+
    let pinned = &ctx.profile.config.web.pinned;
+
    let mut projects = match show {
+
        ProjectQuery::All => storage
+
            .repositories()?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
+
        ProjectQuery::Pinned => storage.repositories_by_id(pinned.repositories.iter())?,
+
    };
+
    projects.sort_by_key(|p| p.rid);
+

+
    let infos = projects
+
        .into_iter()
+
        .filter_map(|id| {
+
            if !id.doc.delegates.iter().any(|d| *d == delegate) {
+
                return None;
+
            }
+
            let Ok(repo) = storage.repository(id.rid) else {
+
                return None;
+
            };
+
            let Ok((_, head)) = repo.head() else {
+
                return None;
+
            };
+
            let Ok(payload) = id.doc.project() else {
+
                return None;
+
            };
+
            let Ok(issues) = ctx.profile.issues(&repo) else {
+
                return None;
+
            };
+
            let Ok(issues) = issues.counts() else {
+
                return None;
+
            };
+
            let Ok(patches) = ctx.profile.patches(&repo) else {
+
                return None;
+
            };
+
            let Ok(patches) = patches.counts() else {
+
                return None;
+
            };
+

+
            let aliases = ctx.profile.aliases();
+
            let delegates = id
+
                .doc
+
                .delegates
+
                .into_iter()
+
                .map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
+
                .collect::<Vec<_>>();
+
            let seeding = db.count(&id.rid).unwrap_or_default();
+

+
            Some(Info {
+
                payload,
+
                delegates,
+
                threshold: id.doc.threshold,
+
                visibility: id.doc.visibility,
+
                head,
+
                issues,
+
                patches,
+
                id: id.rid,
+
                seeding,
+
            })
+
        })
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(infos))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::net::SocketAddr;
+

+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+
    use serde_json::json;
+

+
    use crate::test::{self, get, HEAD, RID};
+

+
    #[tokio::test]
+
    async fn test_delegates_projects() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let seed = test::seed(tmp.path());
+
        let app = super::router(seed.clone())
+
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
+
        let response = get(
+
            &app,
+
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.status(),
+
            StatusCode::OK,
+
            "failed response: {:?}",
+
            response.json().await
+
        );
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                    "alias": "seed"
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": HEAD,
+
                "patches": {
+
                  "open": 1,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 1,
+
                  "closed": 0,
+
                },
+
                "id": RID,
+
                "seeding": 0,
+
              },
+
            ])
+
        );
+

+
        let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
+
            [192, 168, 13, 37],
+
            8080,
+
        ))));
+
        let response = get(
+
            &app,
+
            "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.status(),
+
            StatusCode::OK,
+
            "failed response: {:?}",
+
            response.json().await
+
        );
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                    "alias": "seed"
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": HEAD,
+
                "patches": {
+
                  "open": 1,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 1,
+
                  "closed": 0,
+
                },
+
                "id": RID,
+
                "seeding": 0,
+
              }
+
            ])
+
        );
+
    }
+
}
added http-server/src/api/v1/node.rs
@@ -0,0 +1,135 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::routing::{get, put};
+
use axum::{Json, Router};
+
use axum_auth::AuthBearer;
+
use hyper::StatusCode;
+
use serde_json::json;
+

+
use radicle::identity::RepoId;
+
use radicle::node::routing::Store;
+
use radicle::node::{
+
    policy::{Policy, SeedPolicy},
+
    AliasStore, Handle, NodeId, DEFAULT_TIMEOUT,
+
};
+
use radicle::Node;
+

+
use crate::api::error::Error;
+
use crate::api::{self, Context, PoliciesQuery, RADICLE_VERSION};
+
use crate::axum_extra::{Path, Query};
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/node", get(node_handler))
+
        .route("/node/policies/repos", get(node_policies_repos_handler))
+
        .route(
+
            "/node/policies/repos/:rid",
+
            put(node_policies_seed_handler).delete(node_policies_unseed_handler),
+
        )
+
        .route("/nodes/:nid", get(nodes_handler))
+
        .route("/nodes/:nid/inventory", get(nodes_inventory_handler))
+
        .with_state(ctx)
+
}
+

+
/// Return local node information.
+
/// `GET /node`
+
async fn node_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let node = Node::new(ctx.profile.socket());
+
    let node_id = ctx.profile.public_key;
+
    let node_state = if node.is_running() {
+
        "running"
+
    } else {
+
        "stopped"
+
    };
+
    let config = match node.config() {
+
        Ok(config) => Some(config),
+
        Err(err) => {
+
            tracing::error!("Error getting node config: {:#}", err);
+
            None
+
        }
+
    };
+
    let response = json!({
+
        "id": node_id.to_string(),
+
        "version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")),
+
        "config": config,
+
        "state": node_state,
+
    });
+

+
    Ok::<_, Error>(Json(response))
+
}
+

+
/// Return stored information about other nodes.
+
/// `GET /nodes/:nid`
+
async fn nodes_handler(State(ctx): State<Context>, Path(nid): Path<NodeId>) -> impl IntoResponse {
+
    let aliases = ctx.profile.aliases();
+
    let response = json!({
+
        "alias": aliases.alias(&nid),
+
    });
+

+
    Ok::<_, Error>(Json(response))
+
}
+

+
/// Return stored information about other nodes.
+
/// `GET /nodes/:nid/inventory`
+
async fn nodes_inventory_handler(
+
    State(ctx): State<Context>,
+
    Path(nid): Path<NodeId>,
+
) -> impl IntoResponse {
+
    let db = &ctx.profile.database()?;
+
    let resources = db.get_resources(&nid)?;
+

+
    Ok::<_, Error>(Json(resources))
+
}
+

+
/// Return local repo policies information.
+
/// `GET /node/policies/repos`
+
async fn node_policies_repos_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let policies = ctx.profile.policies()?;
+
    let mut repos = Vec::new();
+

+
    for SeedPolicy { rid: id, policy } in policies.seed_policies()? {
+
        repos.push(json!({
+
            "id": id,
+
            "scope": policy.scope().unwrap_or_default(),
+
            "policy": Policy::from(policy),
+
        }));
+
    }
+

+
    Ok::<_, Error>(Json(repos))
+
}
+

+
/// Seed a new repo.
+
/// `PUT /node/policies/repos/:rid`
+
async fn node_policies_seed_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path(project): Path<RepoId>,
+
    Query(qs): Query<PoliciesQuery>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+
    let mut node = Node::new(ctx.profile.socket());
+
    node.seed(project, qs.scope.unwrap_or_default())?;
+

+
    if let Some(from) = qs.from {
+
        let results = node.fetch(project, from, DEFAULT_TIMEOUT)?;
+
        return Ok::<_, Error>((
+
            StatusCode::OK,
+
            Json(json!({ "success": true, "results": results })),
+
        ));
+
    }
+
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
+
}
+

+
/// Unseed a repo.
+
/// `DELETE /node/policies/repos/:rid`
+
async fn node_policies_unseed_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path(project): Path<RepoId>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+
    let mut node = Node::new(ctx.profile.socket());
+
    node.unseed(project)?;
+

+
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
+
}
added http-server/src/api/v1/profile.rs
@@ -0,0 +1,123 @@
+
use std::net::SocketAddr;
+

+
use axum::extract::{ConnectInfo, State};
+
use axum::response::IntoResponse;
+
use axum::routing::get;
+
use axum::{Json, Router};
+
use serde_json::json;
+

+
use crate::api::error::Error;
+
use crate::api::Context;
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/profile", get(profile_handler))
+
        .with_state(ctx)
+
}
+

+
/// Return local profile information.
+
/// `GET /profile`
+
async fn profile_handler(
+
    State(ctx): State<Context>,
+
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
+
) -> impl IntoResponse {
+
    if !addr.ip().is_loopback() {
+
        return Err(Error::Auth("Profile data is only shown for localhost"));
+
    }
+

+
    Ok::<_, Error>(Json(
+
        json!({ "config": ctx.profile.config, "home": ctx.profile.home.path() }),
+
    ))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::net::SocketAddr;
+

+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+
    use serde_json::json;
+

+
    use crate::test::{self, get};
+

+
    #[tokio::test]
+
    async fn test_remote_profile() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let seed = test::seed(tmp.path());
+
        let app = super::router(seed.clone())
+
            .layer(MockConnectInfo(SocketAddr::from(([192, 168, 1, 1], 8080))));
+
        let response = get(&app, "/profile").await;
+

+
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "error": "Profile data is only shown for localhost",
+
              "code": 401
+
            })
+
        )
+
    }
+

+
    #[tokio::test]
+
    async fn test_profile() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let seed = test::seed(tmp.path());
+
        let app = super::router(seed.clone())
+
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
+
        let response = get(&app, "/profile").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "config": {
+
                "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
+
                "preferredSeeds": [
+
                  "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
+
                  "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
+
                ],
+
                "web": { "pinned": { "repositories": [] } },
+
                "cli": {
+
                  "hints": true
+
                },
+
                "node": {
+
                  "alias": "seed",
+
                  "listen": [],
+
                  "peers": { "type": "dynamic" },
+
                  "connect": [],
+
                  "externalAddresses": [],
+
                  "network": "main",
+
                  "log": "INFO",
+
                  "relay": "auto",
+
                  "limits": {
+
                    "routingMaxSize": 1000,
+
                    "routingMaxAge": 604800,
+
                    "gossipMaxAge": 1209600,
+
                    "fetchConcurrency": 1,
+
                    "maxOpenFiles": 4096,
+
                    "rate": {
+
                      "inbound": {
+
                        "fillRate": 5.0,
+
                        "capacity": 1024
+
                      },
+
                      "outbound": {
+
                        "fillRate": 10.0,
+
                        "capacity": 2048
+
                      }
+
                    },
+
                    "connection": {
+
                      "inbound": 128,
+
                      "outbound": 16
+
                    }
+
                  },
+
                  "workers": 8,
+
                  "seedingPolicy": {
+
                      "default": "block",
+
                  }
+
                }
+
              },
+
              "home": seed.profile.path()
+
            })
+
        );
+
    }
+
}
added http-server/src/api/v1/projects.rs
@@ -0,0 +1,3565 @@
+
use std::collections::{BTreeMap, HashMap};
+

+
use axum::extract::{DefaultBodyLimit, State};
+
use axum::handler::Handler;
+
use axum::http::{header, HeaderValue};
+
use axum::response::{IntoResponse, Response};
+
use axum::routing::{get, patch, post};
+
use axum::{Json, Router};
+
use axum_auth::AuthBearer;
+
use hyper::StatusCode;
+
use radicle_surf::blob::BlobRef;
+
use serde::{Deserialize, Serialize};
+
use serde_json::json;
+
use tower_http::set_header::SetResponseHeaderLayer;
+

+
use radicle::cob::{
+
    issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Author,
+
    Embed, Label, Uri,
+
};
+
use radicle::identity::{Did, RepoId};
+
use radicle::node::routing::Store;
+
use radicle::node::{AliasStore, Node, NodeId};
+
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
+
use radicle_surf::{diff, Glob, Oid, Repository};
+

+
use crate::api::error::Error;
+
use crate::api::project::Info;
+
use crate::api::{self, announce_refs, CobsQuery, Context, PaginationQuery, ProjectQuery};
+
use crate::axum_extra::{immutable_response, Path, Query};
+

+
const CACHE_1_HOUR: &str = "public, max-age=3600, must-revalidate";
+
const MAX_BODY_LIMIT: usize = 4_194_304;
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/projects", get(project_root_handler))
+
        .route("/projects/:project", get(project_handler))
+
        .route("/projects/:project/commits", get(history_handler))
+
        .route("/projects/:project/commits/:sha", get(commit_handler))
+
        .route("/projects/:project/diff/:base/:oid", get(diff_handler))
+
        .route(
+
            "/projects/:project/activity",
+
            get(
+
                activity_handler.layer(SetResponseHeaderLayer::if_not_present(
+
                    header::CACHE_CONTROL,
+
                    |response: &Response| {
+
                        response
+
                            .status()
+
                            .is_success()
+
                            .then_some(HeaderValue::from_static(CACHE_1_HOUR))
+
                    },
+
                )),
+
            ),
+
        )
+
        .route("/projects/:project/tree/:sha/", get(tree_handler_root))
+
        .route("/projects/:project/tree/:sha/*path", get(tree_handler))
+
        .route(
+
            "/projects/:project/stats/tree/:sha",
+
            get(stats_tree_handler),
+
        )
+
        .route("/projects/:project/remotes", get(remotes_handler))
+
        .route("/projects/:project/remotes/:peer", get(remote_handler))
+
        .route("/projects/:project/blob/:sha/*path", get(blob_handler))
+
        .route("/projects/:project/readme/:sha", get(readme_handler))
+
        .route(
+
            "/projects/:project/issues",
+
            post(issue_create_handler).get(issues_handler),
+
        )
+
        .route(
+
            "/projects/:project/issues/:id",
+
            patch(issue_update_handler).get(issue_handler),
+
        )
+
        .route(
+
            "/projects/:project/patches",
+
            post(patch_create_handler).get(patches_handler),
+
        )
+
        .route(
+
            "/projects/:project/patches/:id",
+
            patch(patch_update_handler).get(patch_handler),
+
        )
+
        .with_state(ctx)
+
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
+
}
+

+
/// List all projects.
+
/// `GET /projects`
+
async fn project_root_handler(
+
    State(ctx): State<Context>,
+
    Query(qs): Query<PaginationQuery>,
+
) -> impl IntoResponse {
+
    let PaginationQuery {
+
        show,
+
        page,
+
        per_page,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let storage = &ctx.profile.storage;
+
    let db = &ctx.profile.database()?;
+
    let pinned = &ctx.profile.config.web.pinned;
+
    let policies = ctx.profile.policies()?;
+

+
    let mut projects = match show {
+
        ProjectQuery::All => storage
+
            .repositories()?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
+
        ProjectQuery::Pinned => storage
+
            .repositories_by_id(pinned.repositories.iter())?
+
            .into_iter()
+
            .filter(|repo| repo.doc.visibility.is_public())
+
            .collect::<Vec<_>>(),
+
    };
+
    projects.sort_by_key(|p| p.rid);
+

+
    let infos = projects
+
        .into_iter()
+
        .filter_map(|info| {
+
            if !policies.is_seeding(&info.rid).unwrap_or_default() {
+
                return None;
+
            }
+
            let Ok(repo) = storage.repository(info.rid) else {
+
                return None;
+
            };
+
            let Ok((_, head)) = repo.head() else {
+
                return None;
+
            };
+
            let Ok(payload) = info.doc.project() else {
+
                return None;
+
            };
+
            let Ok(issues) = ctx.profile.issues(&repo) else {
+
                return None;
+
            };
+
            let Ok(issues) = issues.counts() else {
+
                return None;
+
            };
+
            let Ok(patches) = ctx.profile.patches(&repo) else {
+
                return None;
+
            };
+
            let Ok(patches) = patches.counts() else {
+
                return None;
+
            };
+
            let aliases = ctx.profile.aliases();
+
            let delegates = info
+
                .doc
+
                .delegates
+
                .into_iter()
+
                .map(|did| api::json::author(&Author::new(did), aliases.alias(did.as_key())))
+
                .collect::<Vec<_>>();
+
            let seeding = db.count(&info.rid).unwrap_or_default();
+

+
            Some(Info {
+
                payload,
+
                delegates,
+
                head,
+
                threshold: info.doc.threshold,
+
                visibility: info.doc.visibility,
+
                issues,
+
                patches,
+
                id: info.rid,
+
                seeding,
+
            })
+
        })
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(infos))
+
}
+

+
/// Get project metadata.
+
/// `GET /projects/:project`
+
async fn project_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let info = ctx.project_info(&repo, doc)?;
+

+
    Ok::<_, Error>(Json(info))
+
}
+

+
#[derive(Serialize, Deserialize, Clone)]
+
#[serde(rename_all = "camelCase")]
+
pub struct CommitsQueryString {
+
    pub parent: Option<String>,
+
    pub since: Option<i64>,
+
    pub until: Option<i64>,
+
    pub page: Option<usize>,
+
    pub per_page: Option<usize>,
+
}
+

+
/// Get project commit range.
+
/// `GET /projects/:project/commits?parent=<sha>`
+
async fn history_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
    Query(qs): Query<CommitsQueryString>,
+
) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let CommitsQueryString {
+
        since,
+
        until,
+
        parent,
+
        page,
+
        per_page,
+
    } = qs;
+

+
    // If the parent commit is provided, the response depends only on the query
+
    // string and not on the state of the repository. This means we can instruct
+
    // the caches to treat the response as immutable.
+
    let is_immutable = parent.is_some();
+

+
    let sha = match parent {
+
        Some(commit) => commit,
+
        None => ctx.project_info(&repo, doc)?.head.to_string(),
+
    };
+
    let repo = Repository::open(repo.path())?;
+

+
    // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
+
    let page = page.unwrap_or(0);
+
    let per_page = if per_page.is_none() && (since.is_some() || until.is_some()) {
+
        usize::MAX
+
    } else {
+
        per_page.unwrap_or(30)
+
    };
+

+
    let commits = repo
+
        .history(&sha)?
+
        .filter_map(|commit| {
+
            let commit = commit.ok()?;
+
            let time = commit.committer.time.seconds();
+
            let commit = api::json::commit(&commit);
+
            match (since, until) {
+
                (Some(since), Some(until)) if time >= since && time < until => Some(commit),
+
                (Some(since), None) if time >= since => Some(commit),
+
                (None, Some(until)) if time < until => Some(commit),
+
                (None, None) => Some(commit),
+
                _ => None,
+
            }
+
        })
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    if is_immutable {
+
        Ok::<_, Error>(immutable_response(commits).into_response())
+
    } else {
+
        Ok::<_, Error>(Json(commits).into_response())
+
    }
+
}
+

+
/// Get project commit.
+
/// `GET /projects/:project/commits/:sha`
+
async fn commit_handler(
+
    State(ctx): State<Context>,
+
    Path((project, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
+
    let commit = repo.commit(sha)?;
+

+
    let diff = repo.diff_commit(commit.id)?;
+
    let glob = Glob::all_heads().branches().and(Glob::all_remotes());
+
    let branches: Vec<String> = repo
+
        .revision_branches(commit.id, glob)?
+
        .iter()
+
        .map(|b| b.refname().to_string())
+
        .collect();
+

+
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
+
    diff.files().for_each(|file_diff| match file_diff {
+
        diff::FileDiff::Added(added) => {
+
            if let Ok(blob) = repo.blob_ref(added.new.oid) {
+
                files.insert(blob.id(), blob);
+
            }
+
        }
+
        diff::FileDiff::Deleted(deleted) => {
+
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Modified(modified) => {
+
            if let (Ok(old_blob), Ok(new_blob)) = (
+
                repo.blob_ref(modified.old.oid),
+
                repo.blob_ref(modified.new.oid),
+
            ) {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Moved(moved) => {
+
            if let (Ok(old_blob), Ok(new_blob)) =
+
                (repo.blob_ref(moved.old.oid), repo.blob_ref(moved.new.oid))
+
            {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Copied(copied) => {
+
            if let (Ok(old_blob), Ok(new_blob)) =
+
                (repo.blob_ref(copied.old.oid), repo.blob_ref(copied.new.oid))
+
            {
+
                files.insert(old_blob.id(), old_blob);
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
    });
+

+
    let response: serde_json::Value = json!({
+
      "commit": api::json::commit(&commit),
+
      "diff": diff,
+
      "files": files,
+
      "branches": branches
+
    });
+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get diff between two commits
+
/// `GET /projects/:project/diff/:base/:oid`
+
async fn diff_handler(
+
    State(ctx): State<Context>,
+
    Path((project, base, oid)): Path<(RepoId, Oid, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
+
    let base = repo.commit(base)?;
+
    let commit = repo.commit(oid)?;
+
    let diff = repo.diff(base.id, commit.id)?;
+
    let mut files: HashMap<Oid, BlobRef<'_>> = HashMap::new();
+
    diff.files().for_each(|file_diff| match file_diff {
+
        diff::FileDiff::Added(added) => {
+
            if let Ok(new_blob) = repo.blob_ref(added.new.oid) {
+
                files.insert(new_blob.id(), new_blob);
+
            }
+
        }
+
        diff::FileDiff::Deleted(deleted) => {
+
            if let Ok(old_blob) = repo.blob_ref(deleted.old.oid) {
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Modified(modified) => {
+
            if let (Ok(new_blob), Ok(old_blob)) = (
+
                repo.blob_ref(modified.old.oid),
+
                repo.blob_ref(modified.new.oid),
+
            ) {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Moved(moved) => {
+
            if let (Ok(new_blob), Ok(old_blob)) =
+
                (repo.blob_ref(moved.new.oid), repo.blob_ref(moved.old.oid))
+
            {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
        diff::FileDiff::Copied(copied) => {
+
            if let (Ok(new_blob), Ok(old_blob)) =
+
                (repo.blob_ref(copied.new.oid), repo.blob_ref(copied.old.oid))
+
            {
+
                files.insert(new_blob.id(), new_blob);
+
                files.insert(old_blob.id(), old_blob);
+
            }
+
        }
+
    });
+

+
    let commits = repo
+
        .history(commit.id)?
+
        .take_while(|c| {
+
            if let Ok(c) = c {
+
                c.id != base.id
+
            } else {
+
                false
+
            }
+
        })
+
        .map(|r| r.map(|c| api::json::commit(&c)))
+
        .collect::<Result<Vec<_>, _>>()?;
+

+
    let response = json!({ "diff": diff, "files": files, "commits": commits });
+

+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get project activity for the past year.
+
/// `GET /projects/:project/activity`
+
async fn activity_handler(
+
    State(ctx): State<Context>,
+
    Path(project): Path<RepoId>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let current_date = chrono::Utc::now().timestamp();
+
    // SAFETY: The number of weeks is static and not out of bounds.
+
    #[allow(clippy::unwrap_used)]
+
    let one_year_ago = chrono::Duration::try_weeks(52).unwrap();
+
    let repo = Repository::open(repo.path())?;
+
    let head = repo.head()?;
+
    let timestamps = repo
+
        .history(head)?
+
        .filter_map(|a| {
+
            if let Ok(a) = a {
+
                let seconds = a.committer.time.seconds();
+
                if seconds > current_date - one_year_ago.num_seconds() {
+
                    return Some(seconds);
+
                }
+
            }
+
            None
+
        })
+
        .collect::<Vec<i64>>();
+

+
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps }))))
+
}
+

+
/// Get project source tree for '/' path.
+
/// `GET /projects/:project/tree/:sha/`
+
async fn tree_handler_root(
+
    State(ctx): State<Context>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    tree_handler(State(ctx), Path((rid, sha, String::new()))).await
+
}
+

+
/// Get project source tree.
+
/// `GET /projects/:project/tree/:sha/*path`
+
async fn tree_handler(
+
    State(ctx): State<Context>,
+
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+

+
    if let Some(ref cache) = ctx.cache {
+
        let cache = &mut cache.tree.lock().await;
+
        if let Some(response) = cache.get(&(project, sha, path.clone())) {
+
            return Ok::<_, Error>(immutable_response(response.clone()));
+
        }
+
    }
+

+
    let repo = Repository::open(repo.path())?;
+
    let tree = repo.tree(sha, &path)?;
+
    let response = api::json::tree(&tree, &path);
+

+
    if let Some(cache) = &ctx.cache {
+
        let cache = &mut cache.tree.lock().await;
+
        cache.put((project, sha, path.clone()), response.clone());
+
    }
+

+
    Ok::<_, Error>(immutable_response(response))
+
}
+

+
/// Get project source tree stats.
+
/// `GET /projects/:project/stats/tree/:sha`
+
async fn stats_tree_handler(
+
    State(ctx): State<Context>,
+
    Path((project, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
+
    let stats = repo.stats_from(&sha)?;
+

+
    Ok::<_, Error>(immutable_response(stats))
+
}
+

+
/// Get all project remotes.
+
/// `GET /projects/:project/remotes`
+
async fn remotes_handler(
+
    State(ctx): State<Context>,
+
    Path(project): Path<RepoId>,
+
) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(project)?;
+
    let delegates = &doc.delegates;
+
    let aliases = &ctx.profile.aliases();
+
    let remotes = repo
+
        .remotes()?
+
        .filter_map(|r| r.map(|r| r.1).ok())
+
        .map(|remote| {
+
            let refs = remote
+
                .refs
+
                .iter()
+
                .filter_map(|(r, oid)| {
+
                    r.as_str()
+
                        .strip_prefix("refs/heads/")
+
                        .map(|head| (head.to_string(), oid))
+
                })
+
                .collect::<BTreeMap<String, &Oid>>();
+

+
            match aliases.alias(&remote.id) {
+
                Some(alias) => json!({
+
                    "id": remote.id,
+
                    "alias": alias,
+
                    "heads": refs,
+
                    "delegate": delegates.contains(&remote.id.into()),
+
                }),
+
                None => json!({
+
                    "id": remote.id,
+
                    "heads": refs,
+
                    "delegate": delegates.contains(&remote.id.into()),
+
                }),
+
            }
+
        })
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(remotes))
+
}
+

+
/// Get project remote.
+
/// `GET /projects/:project/remotes/:peer`
+
async fn remote_handler(
+
    State(ctx): State<Context>,
+
    Path((project, node_id)): Path<(RepoId, NodeId)>,
+
) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(project)?;
+
    let delegates = &doc.delegates;
+
    let remote = repo.remote(&node_id)?;
+
    let refs = remote
+
        .refs
+
        .iter()
+
        .filter_map(|(r, oid)| {
+
            r.as_str()
+
                .strip_prefix("refs/heads/")
+
                .map(|head| (head.to_string(), oid))
+
        })
+
        .collect::<BTreeMap<String, &Oid>>();
+
    let remote = json!({
+
        "id": remote.id,
+
        "heads": refs,
+
        "delegate": delegates.contains(&remote.id.into()),
+
    });
+

+
    Ok::<_, Error>(Json(remote))
+
}
+

+
/// Get project source file.
+
/// `GET /projects/:project/blob/:sha/*path`
+
async fn blob_handler(
+
    State(ctx): State<Context>,
+
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
+
    let blob = repo.blob(sha, &path)?;
+

+
    if blob.size() > MAX_BODY_LIMIT {
+
        return Ok::<_, Error>(
+
            (
+
                StatusCode::PAYLOAD_TOO_LARGE,
+
                [(header::CACHE_CONTROL, "no-cache")],
+
                Json(json!([])),
+
            )
+
                .into_response(),
+
        );
+
    }
+
    Ok::<_, Error>(immutable_response(api::json::blob(&blob, &path)).into_response())
+
}
+

+
/// Get project readme.
+
/// `GET /projects/:project/readme/:sha`
+
async fn readme_handler(
+
    State(ctx): State<Context>,
+
    Path((project, sha)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
+
    let paths = [
+
        "README",
+
        "README.md",
+
        "README.markdown",
+
        "README.txt",
+
        "README.rst",
+
        "Readme.md",
+
    ];
+

+
    for path in paths
+
        .iter()
+
        .map(ToString::to_string)
+
        .chain(paths.iter().map(|p| p.to_lowercase()))
+
    {
+
        if let Ok(blob) = repo.blob(sha, &path) {
+
            if blob.size() > MAX_BODY_LIMIT {
+
                return Ok::<_, Error>(
+
                    (
+
                        StatusCode::PAYLOAD_TOO_LARGE,
+
                        [(header::CACHE_CONTROL, "no-cache")],
+
                        Json(json!([])),
+
                    )
+
                        .into_response(),
+
                );
+
            }
+

+
            return Ok::<_, Error>(
+
                immutable_response(api::json::blob(&blob, &path)).into_response(),
+
            );
+
        }
+
    }
+

+
    Err(Error::NotFound)
+
}
+

+
/// Get project issues list.
+
/// `GET /projects/:project/issues`
+
async fn issues_handler(
+
    State(ctx): State<Context>,
+
    Path(project): Path<RepoId>,
+
    Query(qs): Query<CobsQuery<api::IssueState>>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let CobsQuery {
+
        page,
+
        per_page,
+
        state,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let state = state.unwrap_or_default();
+
    let issues = ctx.profile.issues(&repo)?;
+
    let mut issues: Vec<_> = issues
+
        .list()?
+
        .filter_map(|r| {
+
            let (id, issue) = r.ok()?;
+
            (state.matches(issue.state())).then_some((id, issue))
+
        })
+
        .collect::<Vec<_>>();
+

+
    issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
+
    let aliases = &ctx.profile.aliases();
+
    let issues = issues
+
        .into_iter()
+
        .map(|(id, issue)| api::json::issue(id, issue, aliases))
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(issues))
+
}
+

+
#[derive(Debug, Deserialize, Serialize)]
+
pub struct IssueCreate {
+
    pub title: String,
+
    pub description: String,
+
    pub labels: Vec<Label>,
+
    pub assignees: Vec<Did>,
+
    pub embeds: Vec<Embed<Uri>>,
+
}
+

+
/// Create a new issue.
+
/// `POST /projects/:project/issues`
+
async fn issue_create_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path(project): Path<RepoId>,
+
    Json(issue): Json<IssueCreate>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+

+
    let (repo, _) = ctx.repo(project)?;
+
    let node = Node::new(ctx.profile.socket());
+
    let signer = ctx
+
        .profile
+
        .signer()
+
        .map_err(|_| Error::Auth("Unauthorized"))?;
+
    let embeds: Vec<Embed> = issue
+
        .embeds
+
        .into_iter()
+
        .filter_map(|embed| resolve_embed(&repo, embed))
+
        .collect();
+

+
    let mut issues = ctx.profile.issues_mut(&repo)?;
+
    let issue = issues
+
        .create(
+
            issue.title,
+
            issue.description,
+
            &issue.labels,
+
            &issue.assignees,
+
            embeds,
+
            &signer,
+
        )
+
        .map_err(Error::from)?;
+

+
    announce_refs(node, repo.id())?;
+

+
    Ok::<_, Error>((
+
        StatusCode::CREATED,
+
        Json(json!({ "success": true, "id": issue.id().to_string() })),
+
    ))
+
}
+

+
/// Update an issue.
+
/// `PATCH /projects/:project/issues/:id`
+
async fn issue_update_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path((project, issue_id)): Path<(RepoId, Oid)>,
+
    Json(action): Json<issue::Action>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+

+
    let (repo, _) = ctx.repo(project)?;
+
    let node = Node::new(ctx.profile.socket());
+
    let signer = ctx.profile.signer()?;
+
    let mut issues = ctx.profile.issues_mut(&repo)?;
+
    let mut issue = issues.get_mut(&issue_id.into())?;
+

+
    let id = match action {
+
        issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
+
        issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
+
        issue::Action::Label { labels } => issue.label(labels, &signer)?,
+
        issue::Action::Edit { title } => issue.edit(title, &signer)?,
+
        issue::Action::Comment {
+
            body,
+
            reply_to,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            if let Some(to) = reply_to {
+
                issue.comment(body, to, embeds, &signer)?
+
            } else {
+
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
+
            }
+
        }
+
        issue::Action::CommentReact {
+
            id,
+
            reaction,
+
            active,
+
        } => issue.react(id, reaction, active, &signer)?,
+
        issue::Action::CommentEdit { id, body, embeds } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            issue.edit_comment(id, body, embeds, &signer)?
+
        }
+
        issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
+
    };
+

+
    announce_refs(node, repo.id())?;
+

+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
+
}
+

+
/// Get project issue.
+
/// `GET /projects/:project/issues/:id`
+
async fn issue_handler(
+
    State(ctx): State<Context>,
+
    Path((project, issue_id)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+
    let issue = ctx
+
        .profile
+
        .issues(&repo)?
+
        .get(&issue_id.into())?
+
        .ok_or(Error::NotFound)?;
+
    let aliases = ctx.profile.aliases();
+

+
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
+
}
+

+
#[derive(Deserialize, Serialize)]
+
pub struct PatchCreate {
+
    pub title: String,
+
    pub description: String,
+
    pub target: Oid,
+
    pub oid: Oid,
+
    pub labels: Vec<Label>,
+
}
+

+
/// Create a new patch.
+
/// `POST /projects/:project/patches`
+
async fn patch_create_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path(project): Path<RepoId>,
+
    Json(patch): Json<PatchCreate>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+

+
    let node = Node::new(ctx.profile.socket());
+
    let signer = ctx
+
        .profile
+
        .signer()
+
        .map_err(|_| Error::Auth("Unauthorized"))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let mut patches = ctx.profile.patches_mut(&repo)?;
+
    let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?;
+

+
    let patch = patches
+
        .create(
+
            patch.title,
+
            patch.description,
+
            patch::MergeTarget::default(),
+
            base_oid,
+
            patch.oid,
+
            &patch.labels,
+
            &signer,
+
        )
+
        .map_err(Error::from)?;
+

+
    announce_refs(node, repo.id())?;
+

+
    Ok::<_, Error>((
+
        StatusCode::CREATED,
+
        Json(json!({ "success": true, "id": patch.id.to_string() })),
+
    ))
+
}
+

+
/// Update an patch.
+
/// `PATCH /projects/:project/patches/:id`
+
async fn patch_update_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path((project, patch_id)): Path<(RepoId, Oid)>,
+
    Json(action): Json<patch::Action>,
+
) -> impl IntoResponse {
+
    api::auth::validate(&ctx, &token).await?;
+

+
    let node = Node::new(ctx.profile.socket());
+
    let signer = ctx
+
        .profile
+
        .signer()
+
        .map_err(|_| Error::Auth("Unauthorized"))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let mut patches = ctx.profile.patches_mut(&repo)?;
+
    let mut patch = patches.get_mut(&patch_id.into())?;
+
    let id = match action {
+
        patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
+
        patch::Action::Label { labels } => patch.label(labels, &signer)?,
+
        patch::Action::Lifecycle { state } => patch.lifecycle(state, &signer)?,
+
        patch::Action::Assign { assignees } => patch.assign(assignees, &signer)?,
+
        patch::Action::Merge { revision, commit } => {
+
            // TODO: We should cleanup the stored copy at least.
+
            patch.merge(revision, commit, &signer)?.entry
+
        }
+
        patch::Action::Review {
+
            revision,
+
            summary,
+
            verdict,
+
            labels,
+
        } => *patch.review(revision, verdict, summary, labels, &signer)?,
+
        patch::Action::ReviewEdit {
+
            review,
+
            summary,
+
            verdict,
+
        } => patch.edit_review(review, summary, verdict, &signer)?,
+
        patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
+
        patch::Action::ReviewComment {
+
            review,
+
            body,
+
            reply_to,
+
            location,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?
+
        }
+
        patch::Action::ReviewCommentEdit {
+
            review,
+
            comment,
+
            body,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            patch.edit_review_comment(review, comment, body, embeds, &signer)?
+
        }
+
        patch::Action::ReviewCommentReact {
+
            review,
+
            comment,
+
            reaction,
+
            active,
+
        } => patch.react_review_comment(review, comment, reaction, active, &signer)?,
+
        patch::Action::ReviewCommentRedact { review, comment } => {
+
            patch.redact_review_comment(review, comment, &signer)?
+
        }
+
        patch::Action::ReviewCommentResolve { review, comment } => {
+
            patch.resolve_review_comment(review, comment, &signer)?
+
        }
+
        patch::Action::ReviewCommentUnresolve { review, comment } => {
+
            patch.unresolve_review_comment(review, comment, &signer)?
+
        }
+
        patch::Action::Revision {
+
            description,
+
            base,
+
            oid,
+
            ..
+
        } => patch.update(description, base, oid, &signer)?.into(),
+
        patch::Action::RevisionEdit {
+
            revision,
+
            description,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            patch.edit_revision(revision, description, embeds, &signer)?
+
        }
+
        patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
+
        patch::Action::RevisionReact {
+
            revision,
+
            reaction,
+
            active,
+
            location,
+
        } => patch.react(revision, reaction, location, active, &signer)?,
+
        patch::Action::RevisionComment {
+
            revision,
+
            body,
+
            reply_to,
+
            location,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            patch.comment(revision, body, reply_to, location, embeds, &signer)?
+
        }
+
        patch::Action::RevisionCommentEdit {
+
            revision,
+
            comment,
+
            body,
+
            embeds,
+
        } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| resolve_embed(&repo, embed))
+
                .collect();
+
            patch.comment_edit(revision, comment, body, embeds, &signer)?
+
        }
+
        patch::Action::RevisionCommentReact {
+
            revision,
+
            comment,
+
            reaction,
+
            active,
+
        } => patch.comment_react(revision, comment, reaction, active, &signer)?,
+
        patch::Action::RevisionCommentRedact { revision, comment } => {
+
            patch.comment_redact(revision, comment, &signer)?
+
        }
+
    };
+

+
    announce_refs(node, repo.id())?;
+

+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
+
}
+

+
/// Get project patches list.
+
/// `GET /projects/:project/patches`
+
async fn patches_handler(
+
    State(ctx): State<Context>,
+
    Path(rid): Path<RepoId>,
+
    Query(qs): Query<CobsQuery<api::PatchState>>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let CobsQuery {
+
        page,
+
        per_page,
+
        state,
+
    } = qs;
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let state = state.unwrap_or_default();
+
    let patches = ctx.profile.patches(&repo)?;
+
    let mut patches = patches
+
        .list()?
+
        .filter_map(|r| {
+
            let (id, patch) = r.ok()?;
+
            (state.matches(patch.state())).then_some((id, patch))
+
        })
+
        .collect::<Vec<_>>();
+
    patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
+
    let aliases = ctx.profile.aliases();
+
    let patches = patches
+
        .into_iter()
+
        .map(|(id, patch)| api::json::patch(id, patch, &repo, &aliases))
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(patches))
+
}
+

+
/// Get project patch.
+
/// `GET /projects/:project/patches/:id`
+
async fn patch_handler(
+
    State(ctx): State<Context>,
+
    Path((rid, patch_id)): Path<(RepoId, Oid)>,
+
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
+
    let patches = ctx.profile.patches(&repo)?;
+
    let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
+
    let aliases = ctx.profile.aliases();
+

+
    Ok::<_, Error>(Json(api::json::patch(
+
        patch_id.into(),
+
        patch,
+
        &repo,
+
        &aliases,
+
    )))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::net::SocketAddr;
+

+
    use axum::body::Body;
+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+
    use pretty_assertions::assert_eq;
+
    use radicle::storage::ReadStorage;
+
    use serde_json::json;
+

+
    use crate::test::*;
+

+
    #[tokio::test]
+
    async fn test_projects_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let seed = seed(tmp.path());
+
        let app = super::router(seed.clone())
+
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
+
        let response = get(&app, "/projects?show=all").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": "seed"
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": HEAD,
+
                "patches": {
+
                  "open": 1,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 1,
+
                  "closed": 0,
+
                },
+
                "id": RID,
+
                "seeding": 0,
+
              },
+
            ])
+
        );
+

+
        let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
+
            [192, 168, 13, 37],
+
            8080,
+
        ))));
+
        let response = get(&app, "/projects?show=all").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": "seed"
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": HEAD,
+
                "patches": {
+
                  "open": 1,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 1,
+
                  "closed": 0,
+
                },
+
                "id": RID,
+
                "seeding": 0,
+
              }
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
               "name": "hello-world",
+
               "description": "Rad repository for tests",
+
               "defaultBranch": "master",
+
               "delegates": [
+
                 {
+
                   "id": DID,
+
                   "alias": "seed"
+
                 }
+
               ],
+
               "threshold": 1,
+
               "visibility": {
+
                 "type": "public"
+
               },
+
               "head": HEAD,
+
               "patches": {
+
                 "open": 1,
+
                 "draft": 0,
+
                 "archived": 0,
+
                 "merged": 0,
+
               },
+
               "issues": {
+
                 "open": 1,
+
                 "closed": 0,
+
               },
+
               "id": RID,
+
               "seeding": 0,
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, "/projects/rad:z2u2CP3ZJzB7ZqE8jHrau19yjcfCQ").await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_commits_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/commits")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
                {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                {
+
                  "id": PARENT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add contributing file",
+
                  "description": "",
+
                  "parents": [
+
                    "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673002014,
+
                  },
+
                },
+
                {
+
                  "id": INITIAL_COMMIT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                  },
+
                  "summary": "Initial commit",
+
                  "description": "",
+
                  "parents": [],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673001014,
+
                  },
+
                },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_commits() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/commits/{HEAD}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "commit": {
+
                "id": HEAD,
+
                "author": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz"
+
                },
+
                "summary": "Add another folder",
+
                "description": "",
+
                "parents": [
+
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                ],
+
                "committer": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz",
+
                  "time": 1673003014
+
                },
+
              },
+
              "diff": {
+
                "files": [
+
                  {
+
                    "state": "deleted",
+
                    "path": "CONTRIBUTING",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -1 +0,0 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Thank you very much!\n",
+
                              "lineNo": 1,
+
                              "type": "deletion",
+
                            },
+
                          ],
+
                          "old":  {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                          "new": {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                        },
+
                      ],
+
                      "stats": {
+
                        "additions": 0,
+
                        "deletions": 1,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "old": {
+
                      "oid": "82eb77880c693655bce074e3dbbd9fa711dc018b",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                  {
+
                    "state": "added",
+
                    "path": "README",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -0,0 +1 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Hello World!\n",
+
                              "lineNo": 1,
+
                              "type": "addition",
+
                            },
+
                          ],
+
                          "old":  {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                          "new": {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                        },
+
                      ],
+
                      "stats": {
+
                        "additions": 1,
+
                        "deletions": 0,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "new": {
+
                      "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                  {
+
                    "state": "added",
+
                    "path": "dir1/README",
+
                    "diff": {
+
                      "type": "plain",
+
                      "hunks": [
+
                        {
+
                          "header": "@@ -0,0 +1 @@\n",
+
                          "lines": [
+
                            {
+
                              "line": "Hello World from dir1!\n",
+
                              "lineNo": 1,
+
                              "type": "addition"
+
                            }
+
                          ],
+
                          "old":  {
+
                            "start": 0,
+
                            "end": 0,
+
                          },
+
                          "new": {
+
                            "start": 1,
+
                            "end": 2,
+
                          },
+
                        }
+
                      ],
+
                      "stats": {
+
                        "additions": 1,
+
                        "deletions": 0,
+
                      },
+
                      "eof": "noneMissing",
+
                    },
+
                    "new": {
+
                      "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                      "mode": "blob",
+
                    },
+
                  },
+
                ],
+
                "stats": {
+
                  "filesChanged": 3,
+
                  "insertions": 2,
+
                  "deletions": 1
+
                }
+
              },
+
              "files": {
+
                "82eb77880c693655bce074e3dbbd9fa711dc018b": {
+
                  "id": "82eb77880c693655bce074e3dbbd9fa711dc018b",
+
                  "binary": false,
+
                  "content": "Thank you very much!\n",
+
                },
+
                "980a0d5f19a64b4b30a87d4206aade58726b60e3": {
+
                  "id": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                  "binary": false,
+
                  "content": "Hello World!\n",
+
                },
+
                "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
+
                  "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                  "binary": false,
+
                  "content": "Hello World from dir1!\n",
+
                },
+
              },
+
              "branches": [
+
                "refs/heads/master"
+
              ]
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_commits_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/projects/{RID}/commits/ffffffffffffffffffffffffffffffffffffffff"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_stats() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/stats/tree/{HEAD}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!(
+
              {
+
                "commits": 3,
+
                "branches": 1,
+
                "contributors": 1
+
              }
+
            )
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_tree() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "entries": [
+
                  {
+
                    "path": "dir1",
+
                    "oid": "2d1c3cbfcf1d190d7fc77ac8f9e53db0e91a9ad3",
+
                    "name": "dir1",
+
                    "kind": "tree"
+
                  },
+
                  {
+
                    "path": "README",
+
                    "oid": "980a0d5f19a64b4b30a87d4206aade58726b60e3",
+
                    "name": "README",
+
                    "kind": "blob"
+
                  }
+
                ],
+
                "lastCommit": {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                "name": "",
+
                "path": "",
+
              }
+
            )
+
        );
+

+
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/dir1")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "entries": [
+
                {
+
                  "path": "dir1/README",
+
                  "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                  "name": "README",
+
                  "kind": "blob"
+
                }
+
              ],
+
              "lastCommit": {
+
                "id": HEAD,
+
                "author": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz"
+
                },
+
                "summary": "Add another folder",
+
                "description": "",
+
                "parents": [
+
                  "ee8d6a29304623a78ebfa5eeed5af674d0e58f83",
+
                ],
+
                "committer": {
+
                  "name": "Alice Liddell",
+
                  "email": "alice@radicle.xyz",
+
                  "time": 1673003014
+
                },
+
              },
+
              "name": "dir1",
+
              "path": "dir1",
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_tree_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/projects/{RID}/tree/ffffffffffffffffffffffffffffffffffffffff"),
+
        )
+
        .await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/unknown")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_remotes_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/remotes")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                "alias": CONTRIBUTOR_ALIAS,
+
                "heads": {
+
                  "master": HEAD
+
                },
+
                "delegate": true
+
              }
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_remotes() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/projects/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
                "heads": {
+
                    "master": HEAD
+
                },
+
                "delegate": true
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_remotes_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/projects/{RID}/remotes/z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_blob() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/blob/{HEAD}/README")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "binary": false,
+
                "name": "README",
+
                "path": "README",
+
                "lastCommit": {
+
                  "id": HEAD,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Add another folder",
+
                  "description": "",
+
                  "parents": [
+
                    "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
+
                  ],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673003014
+
                  },
+
                },
+
                "content": "Hello World!\n",
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_blob_not_found() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/blob/{HEAD}/unknown")).await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_readme() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/readme/{INITIAL_COMMIT}")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "binary": false,
+
                "name": "README",
+
                "path": "README",
+
                "lastCommit": {
+
                  "id": INITIAL_COMMIT,
+
                  "author": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz"
+
                  },
+
                  "summary": "Initial commit",
+
                  "description": "",
+
                  "parents": [],
+
                  "committer": {
+
                    "name": "Alice Liddell",
+
                    "email": "alice@radicle.xyz",
+
                    "time": 1673001014
+
                  },
+
                },
+
                "content": "Hello World!\n"
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_diff() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(
+
            &app,
+
            format!("/projects/{RID}/diff/{INITIAL_COMMIT}/{HEAD}"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
                "diff": {
+
                  "files": [
+
                    {
+
                      "state": "added",
+
                      "path": "dir1/README",
+
                      "diff": {
+
                        "type": "plain",
+
                        "hunks": [
+
                          {
+
                            "header": "@@ -0,0 +1 @@\n",
+
                            "lines": [
+
                              {
+
                                "line": "Hello World from dir1!\n",
+
                                "lineNo": 1,
+
                                "type": "addition",
+
                              },
+
                            ],
+
                            "old":  {
+
                              "start": 0,
+
                              "end": 0,
+
                            },
+
                            "new": {
+
                              "start": 1,
+
                              "end": 2,
+
                            },
+
                          },
+
                        ],
+
                        "stats": {
+
                          "additions": 1,
+
                          "deletions": 0,
+
                        },
+
                        "eof": "noneMissing",
+
                      },
+
                      "new": {
+
                        "oid": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                        "mode": "blob",
+
                      },
+
                    },
+
                  ],
+
                  "stats": {
+
                    "filesChanged": 1,
+
                    "insertions": 1,
+
                    "deletions": 0,
+
                  },
+
                },
+
                "files": {
+
                  "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1": {
+
                    "id": "1dd5654ca2d2cf9f33b14c92b5ca9e1d21a91ae1",
+
                    "binary": false,
+
                    "content": "Hello World from dir1!\n",
+
                  },
+
                },
+
                "commits": [
+
                  {
+
                    "id": HEAD,
+
                    "author": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                    },
+
                    "summary": "Add another folder",
+
                    "description": "",
+
                    "parents": [
+
                      "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"
+
                    ],
+
                    "committer": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                      "time": 1673003014,
+
                    },
+
                  },
+
                  {
+
                    "id": PARENT,
+
                    "author": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                    },
+
                    "summary": "Add contributing file",
+
                    "description": "",
+
                    "parents": [
+
                      "f604ce9fd5b7cc77b7609beda45ea8760bee78f7",
+
                    ],
+
                    "committer": {
+
                      "name": "Alice Liddell",
+
                      "email": "alice@radicle.xyz",
+
                      "time": 1673002014,
+
                    }
+
                  }
+
                ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_root() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/{RID}/issues")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "id": ISSUE_ID,
+
                "author": {
+
                  "id": DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "Issue #1",
+
                "state": {
+
                  "status": "open"
+
                },
+
                "assignees": [],
+
                "discussion": [
+
                  {
+
                    "id": ISSUE_ID,
+
                    "author": {
+
                      "id": DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "body": "Change 'hello world' to 'hello everyone'",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "Change 'hello world' to 'hello everyone'",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                    ],
+
                    "embeds": [],
+
                    "reactions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "replyTo": null,
+
                    "resolved": false,
+
                  }
+
                ],
+
                "labels": []
+
              }
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_create() {
+
        const CREATED_ISSUE_ID: &str = "fcd0d5940b55df596cf8079fd1845903f1104bcd";
+

+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        create_session(ctx).await;
+

+
        let body = serde_json::to_vec(&json!({
+
            "title": "Issue #2",
+
            "description": "Change 'hello world' to 'hello everyone'",
+
            "labels": ["bug"],
+
            "embeds": [
+
              {
+
                "name": "example.html",
+
                "content": "data:image/png;base64,PGh0bWw+SGVsbG8gV29ybGQhPC9odG1sPg=="
+
              }
+
            ],
+
            "assignees": [],
+
        }))
+
        .unwrap();
+

+
        let response = post(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::CREATED);
+
        assert_eq!(
+
            response.json().await,
+
            json!({ "success": true, "id": CREATED_ISSUE_ID })
+
        );
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CREATED_ISSUE_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CREATED_ISSUE_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "Issue #2",
+
              "state": {
+
                "status": "open",
+
              },
+
              "assignees": [],
+
              "discussion": [{
+
                "id": CREATED_ISSUE_ID,
+
                "author": {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "body": "Change 'hello world' to 'hello everyone'",
+
                "edits": [
+
                  {
+
                    "author": {
+
                      "id": CONTRIBUTOR_DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "body": "Change 'hello world' to 'hello everyone'",
+
                    "timestamp": TIMESTAMP,
+
                    "embeds": [
+
                      {
+
                        "name": "example.html",
+
                        "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84",
+
                      },
+
                    ],
+
                  },
+
                ],
+
                "embeds": [
+
                  {
+
                    "name": "example.html",
+
                    "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84"
+
                  }
+
                ],
+
                "reactions": [],
+
                "timestamp": TIMESTAMP,
+
                "replyTo": null,
+
                "resolved": false,
+
              }],
+
              "labels": [
+
                  "bug",
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_comment() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        create_session(ctx).await;
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment",
+
          "body": "This is first-level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
+
          "replyTo": ISSUE_DISCUSSION_ID,
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        // Get ID to redact later in the test
+
        let response = response.json().await;
+
        let id = &response["id"];
+
        assert!(id.is_string());
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment.react",
+
          "id": ISSUE_DISCUSSION_ID,
+
          "reaction": "🚀",
+
          "active": true,
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment.edit",
+
          "id": ISSUE_DISCUSSION_ID,
+
          "body": "EDIT: Change 'hello world' to 'hello anyone'",
+
          "embeds": [
+
            {
+
              "name":"image.jpg",
+
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc"
+
            }
+
          ]
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.success().await, true);
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment.redact",
+
          "id": id.as_str().unwrap(),
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.success().await, true);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": ISSUE_DISCUSSION_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "Issue #1",
+
              "state": {
+
                "status": "open",
+
              },
+
              "assignees": [],
+
              "discussion": [
+
                {
+
                  "id": ISSUE_DISCUSSION_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "body": "EDIT: Change 'hello world' to 'hello anyone'",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "Change 'hello world' to 'hello everyone'",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "EDIT: Change 'hello world' to 'hello anyone'",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [
+
                        {
+
                          "name": "image.jpg",
+
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                        },
+
                      ],
+
                    },
+
                  ],
+
                  "embeds": [
+
                    {
+
                      "name": "image.jpg",
+
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                    }
+
                  ],
+
                  "reactions": [
+
                    {
+
                      "emoji": "🚀",
+
                      "authors": [
+
                        {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS,
+
                        }
+
                      ],
+
                    },
+
                  ],
+
                  "timestamp": TIMESTAMP,
+
                  "replyTo": null,
+
                  "resolved": false,
+
                },
+
              ],
+
              "labels": [],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_assign() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        create_session(ctx).await;
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "assign",
+
          "assignees": [CONTRIBUTOR_DID],
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": ISSUE_DISCUSSION_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS,
+
              },
+
              "title": "Issue #1",
+
              "state": {
+
                "status": "open",
+
              },
+
              "assignees": [
+
                {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS,
+
                }
+
              ],
+
              "discussion": [
+
                {
+
                  "id": ISSUE_DISCUSSION_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS,
+
                  },
+
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS,
+
                      },
+
                      "body": "Change 'hello world' to 'hello everyone'",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "embeds": [],
+
                  "reactions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "replyTo": null,
+
                  "resolved": false,
+
                },
+
              ],
+
              "labels": [],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_issues_reply() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        create_session(ctx).await;
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment",
+
          "body": "This is a reply to the first comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
+
          "replyTo": ISSUE_DISCUSSION_ID,
+
        }))
+
        .unwrap();
+

+
        let _ = get(&app, format!("/projects/{CONTRIBUTOR_RID}/issues")).await;
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(response.success().await, true);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": ISSUE_DISCUSSION_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "Issue #1",
+
              "state": {
+
                "status": "open",
+
              },
+
              "assignees": [],
+
              "discussion": [
+
                {
+
                  "id": ISSUE_DISCUSSION_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "Change 'hello world' to 'hello everyone'",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "embeds": [],
+
                  "reactions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "replyTo": null,
+
                  "resolved": false,
+
                },
+
                {
+
                  "id": ISSUE_COMMENT_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "body": "This is a reply to the first comment",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "This is a reply to the first comment",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [
+
                        {
+
                          "name": "image.jpg",
+
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                        },
+
                      ],
+
                    },
+
                  ],
+
                  "embeds": [
+
                    {
+
                      "name": "image.jpg",
+
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                    }
+
                  ],
+
                  "reactions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "replyTo": ISSUE_DISCUSSION_ID,
+
                  "resolved": false,
+
                },
+
              ],
+
              "labels": [],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        let response = get(&app, format!("/projects/{CONTRIBUTOR_RID}/patches")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "id": CONTRIBUTOR_PATCH_ID,
+
                "author": {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "A new `hello world`",
+
                "state": { "status": "open" },
+
                "target": "delegates",
+
                "labels": [],
+
                "merges": [],
+
                "assignees": [],
+
                "revisions": [
+
                  {
+
                    "id": CONTRIBUTOR_PATCH_ID,
+
                    "reactions": [],
+
                    "author": {
+
                      "id": CONTRIBUTOR_DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "description": "change `hello world` in README to something else",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "change `hello world` in README to something else",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                    ],
+
                    "base": PARENT,
+
                    "oid": HEAD,
+
                    "refs": [
+
                      "refs/heads/master",
+
                    ],
+
                    "discussions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "reviews": [],
+
                  }
+
                ],
+
              }
+
            ])
+
        );
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!(
+
              {
+
                "id": CONTRIBUTOR_PATCH_ID,
+
                "author": {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "A new `hello world`",
+
                "state": { "status": "open" },
+
                "target": "delegates",
+
                "labels": [],
+
                "merges": [],
+
                "assignees": [],
+
                "revisions": [
+
                  {
+
                    "id": CONTRIBUTOR_PATCH_ID,
+
                    "reactions": [],
+
                    "author": {
+
                      "id": CONTRIBUTOR_DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "description": "change `hello world` in README to something else",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "change `hello world` in README to something else",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                    ],
+
                    "base": PARENT,
+
                    "oid": HEAD,
+
                    "refs": [
+
                      "refs/heads/master",
+
                    ],
+
                    "discussions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "reviews": [],
+
                  }
+
                ],
+
              }
+
            )
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_create_patches() {
+
        const CREATED_PATCH_ID: &str = "9aabc4055fd811f915c55e9a6ea9f525aa3e88f2";
+

+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        create_session(ctx).await;
+

+
        let body = serde_json::to_vec(&json!({
+
          "title": "Update README",
+
          "description": "Do some changes to README",
+
          "target": INITIAL_COMMIT,
+
          "oid": HEAD,
+
          "labels": [],
+
        }))
+
        .unwrap();
+

+
        let response = post(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::CREATED);
+
        assert_eq!(
+
            response.json().await,
+
            json!(
+
              {
+
                "success": true,
+
                "id": CREATED_PATCH_ID,
+
              }
+
            )
+
        );
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CREATED_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!(
+
              {
+
                "id": CREATED_PATCH_ID,
+
                "author": {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS
+
                },
+
                "title": "Update README",
+
                "state": { "status": "open" },
+
                "target": "delegates",
+
                "labels": [],
+
                "merges": [],
+
                "assignees": [],
+
                "revisions": [
+
                  {
+
                    "id": CREATED_PATCH_ID,
+
                    "reactions": [],
+
                    "author": {
+
                      "id": CONTRIBUTOR_DID,
+
                      "alias": CONTRIBUTOR_ALIAS
+
                    },
+
                    "description": "Do some changes to README",
+
                    "edits": [
+
                      {
+
                        "author": {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        },
+
                        "body": "Do some changes to README",
+
                        "timestamp": TIMESTAMP,
+
                        "embeds": [],
+
                      },
+
                   ],
+
                    "base": INITIAL_COMMIT,
+
                    "oid": HEAD,
+
                    "refs": [
+
                      "refs/heads/master",
+
                    ],
+
                    "discussions": [],
+
                    "timestamp": TIMESTAMP,
+
                    "reviews": [],
+
                  }
+
                ],
+
              }
+
            )
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_assign() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "assign",
+
          "assignees": [CONTRIBUTOR_DID]
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [
+
                {
+
                  "id": CONTRIBUTOR_DID,
+
                  "alias": CONTRIBUTOR_ALIAS,
+
                }
+
              ],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_label() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "label",
+
          "labels": ["bug","design"],
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [
+
                "bug",
+
                "design"
+
              ],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_revisions() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "revision",
+
          "description": "This is a new revision",
+
          "base": PARENT,
+
          "oid": HEAD,
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
                {
+
                  "id": "cccf3b0675220f25b054b6625d84611cb6506d9a",
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "This is a new revision",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "This is a new revision",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                }
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_edit() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "edit",
+
          "title": "This is a updated title",
+
          "description": "Let's write some description",
+
          "target": "delegates",
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "This is a updated title",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "reactions": [],
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_revisions_edit() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "revision.edit",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "description": "Let's change the description a bit",
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "revision.react",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "reaction": "🚀",
+
          "location": {
+
            "commit": INITIAL_COMMIT,
+
            "path": "./README.md",
+
            "new": {
+
              "type": "lines",
+
              "range": {
+
                "start": 0,
+
                "end": 1
+
              }
+
            }
+
          },
+
          "active": true,
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "revision.react",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "reaction": "🙏",
+
          "location": null,
+
          "active": true,
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "Let's change the description a bit",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "Let's change the description a bit",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [
+
                    {
+
                      "emoji": "🙏",
+
                      "authors": [
+
                        {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        }
+
                      ],
+
                    },
+
                    {
+
                      "location": {
+
                        "commit": INITIAL_COMMIT,
+
                        "path": "./README.md",
+
                        "old": null,
+
                        "new": {
+
                          "type": "lines",
+
                          "range": {
+
                            "start": 0,
+
                            "end": 1
+
                          }
+
                        }
+
                      },
+
                      "emoji": "🚀",
+
                      "authors": [
+
                        {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                        }
+
                      ]
+
                    },
+
                  ],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_discussions() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let thread_body = serde_json::to_vec(&json!({
+
          "type": "revision.comment",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "body": "This is a root level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(thread_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let comment_id = response.id().await.to_string();
+
        let comment_react_body = serde_json::to_vec(&json!({
+
          "type": "revision.comment.react",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "comment": comment_id,
+
          "reaction": "🚀",
+
          "active": true
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(comment_react_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let comment_edit = serde_json::to_vec(&json!({
+
          "type": "revision.comment.edit",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "comment": comment_id,
+
          "body": "EDIT: This is a root level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
            }
+
          ],
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(comment_edit)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        let reply_body = serde_json::to_vec(&json!({
+
          "type": "revision.comment",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "body": "This is a root level comment",
+
          "replyTo": comment_id,
+
          "embeds": [],
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(reply_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        let comment_id_2 = response.id().await.to_string();
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [
+
                    {
+
                      "id": comment_id,
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "EDIT: This is a root level comment",
+
                      "edits": [
+
                        {
+
                          "author": {
+
                            "id": CONTRIBUTOR_DID,
+
                            "alias": CONTRIBUTOR_ALIAS
+
                          },
+
                          "body": "This is a root level comment",
+
                          "timestamp": TIMESTAMP,
+
                          "embeds": [
+
                            {
+
                                "name": "image.jpg",
+
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                            },
+
                          ],
+
                        },
+
                        {
+
                          "author": {
+
                            "id": CONTRIBUTOR_DID,
+
                            "alias": CONTRIBUTOR_ALIAS
+
                          },
+
                          "body": "EDIT: This is a root level comment",
+
                          "timestamp": TIMESTAMP,
+
                          "embeds": [
+
                           {
+
                                "name": "image.jpg",
+
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                            },
+
                          ],
+
                        },
+
                      ],
+
                      "embeds": [
+
                        {
+
                          "name": "image.jpg",
+
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                        }
+
                      ],
+
                      "reactions": [
+
                        {
+
                          "emoji": "🚀",
+
                          "authors": [
+
                            {
+
                              "id": CONTRIBUTOR_DID,
+
                              "alias": CONTRIBUTOR_ALIAS
+
                            }
+
                          ],
+
                        },
+
                      ],
+
                      "timestamp": TIMESTAMP,
+
                      "replyTo": null,
+
                      "location": null,
+
                      "resolved": false,
+
                    },
+
                    {
+
                      "id": comment_id_2,
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "This is a root level comment",
+
                      "edits": [
+
                        {
+
                          "author": {
+
                            "id": CONTRIBUTOR_DID,
+
                            "alias": CONTRIBUTOR_ALIAS
+
                          },
+
                          "body": "This is a root level comment",
+
                          "timestamp": TIMESTAMP,
+
                          "embeds": [],
+
                        },
+
                      ],
+
                      "embeds": [],
+
                      "reactions": [],
+
                      "timestamp": TIMESTAMP,
+
                      "replyTo": comment_id,
+
                      "location": null,
+
                      "resolved": false,
+
                    },
+
                  ],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_reviews() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let thread_body = serde_json::to_vec(&json!({
+
          "type": "review",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "summary": "A small review",
+
          "verdict": "accept",
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(thread_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let review_id = response.id().await.to_string();
+
        let review_comment_body = serde_json::to_vec(&json!({
+
          "type": "review.comment",
+
          "review": review_id,
+
          "body": "This is a comment on a review",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
+
          "location": {
+
            "commit": HEAD,
+
            "path": "README.md",
+
            "new": {
+
              "type": "lines",
+
              "range": {
+
                "start": 2,
+
                "end": 4
+
              }
+
            }
+
          }
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(review_comment_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let comment_id = response.id().await.to_string();
+
        let review_comment_edit_body = serde_json::to_vec(&json!({
+
          "type": "review.comment.edit",
+
          "review": review_id,
+
          "comment": comment_id,
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
            }
+
          ],
+
          "body": "EDIT: This is a comment on a review",
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(review_comment_edit_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let review_react_body = serde_json::to_vec(&json!({
+
          "type": "review.comment.react",
+
          "review": review_id,
+
          "comment": comment_id,
+
          "reaction": "🚀",
+
          "active": true
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(review_react_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let review_resolve_body = serde_json::to_vec(&json!({
+
          "type": "review.comment.resolve",
+
          "review": review_id,
+
          "comment": comment_id,
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(review_resolve_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [
+
                    {
+
                      "id": "140a44a4eac2cdb74b2f5f95a9dce97847eb9636",
+
                      "author": {
+
                          "id": CONTRIBUTOR_DID,
+
                          "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "verdict": "accept",
+
                      "summary": "A small review",
+
                      "comments": [
+
                        {
+
                          "id": "0dcfca53416761cf975cc4cd6d452790cee06b49",
+
                          "author": {
+
                            "id": CONTRIBUTOR_DID,
+
                            "alias": CONTRIBUTOR_ALIAS
+
                          },
+
                          "body": "EDIT: This is a comment on a review",
+
                          "edits": [
+
                            {
+
                              "author": {
+
                                "id": CONTRIBUTOR_DID,
+
                                "alias": CONTRIBUTOR_ALIAS
+
                              },
+
                              "body": "This is a comment on a review",
+
                              "timestamp": 1671125284,
+
                              "embeds": [
+
                                {
+
                                  "name": "image.jpg",
+
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                                },
+
                              ],
+
                            },
+
                            {
+
                              "author": {
+
                                "id": CONTRIBUTOR_DID,
+
                                "alias": CONTRIBUTOR_ALIAS
+
                              },
+
                              "body": "EDIT: This is a comment on a review",
+
                              "timestamp": 1671125284,
+
                              "embeds": [
+
                                {
+
                                  "name": "image.jpg",
+
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                                },
+
                              ],
+
                            },
+
                          ],
+
                          "embeds": [
+
                            {
+
                              "name": "image.jpg",
+
                              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                            },
+
                          ],
+
                          "reactions": [
+
                            {
+
                              "emoji": "🚀",
+
                              "authors": [
+
                                {
+
                                  "id": CONTRIBUTOR_DID,
+
                                  "alias": CONTRIBUTOR_ALIAS
+
                                }
+
                              ],
+
                            },
+
                          ],
+
                          "timestamp": 1671125284,
+
                          "replyTo": null,
+
                          "location": {
+
                            "commit": HEAD,
+
                            "path": "README.md",
+
                            "old": null,
+
                            "new": {
+
                              "type": "lines",
+
                              "range": {
+
                                "start": 2,
+
                                "end": 4,
+
                              },
+
                            },
+
                          },
+
                          "resolved": true,
+
                        }
+
                      ],
+
                      "timestamp": 1671125284,
+
                    },
+
                  ],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_merges() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let thread_body = serde_json::to_vec(&json!({
+
          "type": "merge",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "commit": PARENT,
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(thread_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
                "alias": CONTRIBUTOR_ALIAS
+
              },
+
              "title": "A new `hello world`",
+
              "state": {
+
                  "status": "merged",
+
                  "revision": CONTRIBUTOR_PATCH_ID,
+
                  "commit": PARENT,
+
              },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [{
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "commit": PARENT,
+
                  "timestamp": TIMESTAMP,
+
                  "revision": CONTRIBUTOR_PATCH_ID,
+
              }],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "edits": [
+
                    {
+
                      "author": {
+
                        "id": CONTRIBUTOR_DID,
+
                        "alias": CONTRIBUTOR_ALIAS
+
                      },
+
                      "body": "change `hello world` in README to something else",
+
                      "timestamp": TIMESTAMP,
+
                      "embeds": [],
+
                    },
+
                  ],
+
                  "reactions": [],
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_private() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        // Check that the repo exists.
+
        ctx.profile()
+
            .storage
+
            .repository(RID_PRIVATE.parse().unwrap())
+
            .unwrap();
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/patches")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/issues")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/commits")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/remotes")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+
}
added http-server/src/api/v1/sessions.rs
@@ -0,0 +1,183 @@
+
use std::iter::repeat_with;
+

+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::routing::{post, put};
+
use axum::{Json, Router};
+
use axum_auth::AuthBearer;
+
use hyper::StatusCode;
+
use radicle::crypto::{PublicKey, Signature};
+
use serde::{Deserialize, Serialize};
+
use time::OffsetDateTime;
+

+
use crate::api::auth::{self, AuthState, Session};
+
use crate::api::error::Error;
+
use crate::api::json;
+
use crate::api::Context;
+
use crate::axum_extra::Path;
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/sessions", post(session_create_handler))
+
        .route(
+
            "/sessions/:id",
+
            put(session_signin_handler)
+
                .get(session_handler)
+
                .delete(session_delete_handler),
+
        )
+
        .with_state(ctx)
+
}
+

+
#[derive(Debug, Deserialize, Serialize)]
+
struct AuthChallenge {
+
    sig: Signature,
+
    pk: PublicKey,
+
}
+

+
/// Create session.
+
/// `POST /sessions`
+
async fn session_create_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let mut rng = fastrand::Rng::new();
+
    let session_id = repeat_with(|| rng.alphanumeric())
+
        .take(32)
+
        .collect::<String>();
+
    let signer = ctx.profile.signer().map_err(Error::from)?;
+
    let session = Session {
+
        status: AuthState::Unauthorized,
+
        public_key: *signer.public_key(),
+
        alias: ctx.profile.config.node.alias.clone(),
+
        issued_at: OffsetDateTime::now_utc(),
+
        expires_at: OffsetDateTime::now_utc()
+
            .checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
+
            .unwrap(),
+
    };
+
    let mut sessions = ctx.sessions.write().await;
+
    sessions.insert(session_id.clone(), session.clone());
+

+
    Ok::<_, Error>((
+
        StatusCode::CREATED,
+
        Json(json::session(session_id, &session)),
+
    ))
+
}
+

+
/// Get a session.
+
/// `GET /sessions/:id`
+
async fn session_handler(
+
    State(ctx): State<Context>,
+
    Path(session_id): Path<String>,
+
) -> impl IntoResponse {
+
    let sessions = ctx.sessions.read().await;
+
    let session = sessions.get(&session_id).ok_or(Error::NotFound)?;
+

+
    Ok::<_, Error>(Json(json::session(session_id, session)))
+
}
+

+
/// Update session.
+
/// `PUT /sessions/:id`
+
async fn session_signin_handler(
+
    State(ctx): State<Context>,
+
    Path(session_id): Path<String>,
+
    Json(request): Json<AuthChallenge>,
+
) -> impl IntoResponse {
+
    let mut sessions = ctx.sessions.write().await;
+
    let session = sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
+
    if session.status == AuthState::Unauthorized {
+
        if session.public_key != request.pk {
+
            return Err(Error::Auth("Invalid public key"));
+
        }
+
        if session.expires_at <= OffsetDateTime::now_utc() {
+
            return Err(Error::Auth("Session expired"));
+
        }
+
        let payload = format!("{}:{}", session_id, request.pk);
+
        request
+
            .pk
+
            .verify(payload.as_bytes(), &request.sig)
+
            .map_err(Error::from)?;
+
        session.status = AuthState::Authorized;
+
        session.expires_at = OffsetDateTime::now_utc()
+
            .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
+
            .unwrap();
+

+
        return Ok::<_, Error>(Json(json!({ "success": true })));
+
    }
+

+
    Err(Error::Auth("Session already authorized"))
+
}
+

+
/// Delete session.
+
/// `DELETE /sessions/:id`
+
async fn session_delete_handler(
+
    State(ctx): State<Context>,
+
    AuthBearer(token): AuthBearer,
+
    Path(session_id): Path<String>,
+
) -> impl IntoResponse {
+
    if token != session_id {
+
        return Err(Error::Auth("Not authorized to delete this session"));
+
    }
+
    let mut sessions = ctx.sessions.write().await;
+
    sessions.remove_entry(&token).ok_or(Error::NotFound)?;
+

+
    Ok::<_, Error>(Json(json!({ "success": true })))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use crate::commands::web::{sign, SessionInfo};
+
    use axum::body::Body;
+
    use axum::http::StatusCode;
+

+
    use crate::api::auth::{AuthState, Session};
+
    use crate::test::{self, get, post, put};
+

+
    #[tokio::test]
+
    async fn test_session() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        // Create session.
+
        let response = post(&app, "/sessions", None, None).await;
+
        let status = response.status();
+
        let json = response.json().await;
+
        let session_info: SessionInfo = serde_json::from_value(json).unwrap();
+

+
        assert_eq!(status, StatusCode::CREATED);
+

+
        // Check that an unauthorized session has been created.
+
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
+
        let status = response.status();
+
        let json = response.json().await;
+
        let body: Session = serde_json::from_value(json).unwrap();
+

+
        assert_eq!(status, StatusCode::OK);
+
        assert_eq!(body.status, AuthState::Unauthorized);
+

+
        // Create request body
+
        let signer = ctx.profile.signer().unwrap();
+
        let signature = sign(signer, &session_info).unwrap();
+
        let body = serde_json::to_vec(&super::AuthChallenge {
+
            sig: signature,
+
            pk: session_info.public_key,
+
        })
+
        .unwrap();
+

+
        let response = put(
+
            &app,
+
            format!("/sessions/{}", session_info.session_id),
+
            Some(Body::from(body)),
+
            None,
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        // Check that session has been authorized.
+
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
+
        let status = response.status();
+
        let json = response.json().await;
+
        let body: Session = serde_json::from_value(json).unwrap();
+

+
        assert_eq!(status, StatusCode::OK);
+
        assert_eq!(body.status, AuthState::Authorized);
+
    }
+
}
added http-server/src/api/v1/stats.rs
@@ -0,0 +1,42 @@
+
use axum::extract::State;
+
use axum::response::IntoResponse;
+
use axum::routing::get;
+
use axum::{Json, Router};
+
use serde_json::json;
+

+
use radicle::storage::ReadStorage;
+

+
use crate::api::error::Error;
+
use crate::api::Context;
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/stats", get(stats_handler))
+
        .with_state(ctx)
+
}
+

+
/// Return the stats for the node.
+
/// `GET /stats`
+
async fn stats_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let total = ctx.profile.storage.repositories()?.len();
+

+
    Ok::<_, Error>(Json(json!({ "repos": { "total": total } })))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use axum::http::StatusCode;
+
    use serde_json::json;
+

+
    use crate::test::{self, get};
+

+
    #[tokio::test]
+
    async fn test_stats() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(test::seed(tmp.path()));
+
        let response = get(&app, "/stats").await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(response.json().await, json!({ "repos": { "total": 2 } }));
+
    }
+
}
added http-server/src/axum_extra.rs
@@ -0,0 +1,99 @@
+
use axum::extract::path::ErrorKind;
+
use axum::extract::rejection::{PathRejection, QueryRejection};
+
use axum::extract::FromRequestParts;
+
use axum::http::request::Parts;
+
use axum::http::{header, StatusCode};
+
use axum::response::IntoResponse;
+
use axum::{async_trait, Json};
+

+
use serde::de::DeserializeOwned;
+
use serde::Serialize;
+

+
pub struct Path<T>(pub T);
+

+
#[async_trait]
+
impl<S, T> FromRequestParts<S> for Path<T>
+
where
+
    T: DeserializeOwned + Send,
+
    S: Send + Sync,
+
{
+
    type Rejection = (StatusCode, axum::Json<Error>);
+

+
    async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
+
        match axum::extract::Path::<T>::from_request_parts(req, state).await {
+
            Ok(value) => Ok(Self(value.0)),
+
            Err(rejection) => {
+
                let status = StatusCode::BAD_REQUEST;
+
                let body = match rejection {
+
                    PathRejection::FailedToDeserializePathParams(inner) => {
+
                        let kind = inner.into_kind();
+
                        match &kind {
+
                            ErrorKind::Message(msg) => Json(Error {
+
                                success: false,
+
                                error: msg.to_string(),
+
                            }),
+
                            _ => Json(Error {
+
                                success: false,
+
                                error: kind.to_string(),
+
                            }),
+
                        }
+
                    }
+
                    _ => Json(Error {
+
                        success: false,
+
                        error: format!("{rejection}"),
+
                    }),
+
                };
+

+
                Err((status, body))
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Default)]
+
pub struct Query<T>(pub T);
+

+
#[async_trait]
+
impl<S, T> FromRequestParts<S> for Query<T>
+
where
+
    T: DeserializeOwned + Send,
+
    S: Send + Sync,
+
{
+
    type Rejection = (StatusCode, axum::Json<Error>);
+

+
    async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
+
        match axum::extract::Query::<T>::from_request_parts(req, state).await {
+
            Ok(value) => Ok(Self(value.0)),
+
            Err(rejection) => {
+
                let status = StatusCode::BAD_REQUEST;
+
                let body = match rejection {
+
                    QueryRejection::FailedToDeserializeQueryString(inner) => Json(Error {
+
                        success: false,
+
                        error: inner.to_string(),
+
                    }),
+
                    _ => Json(Error {
+
                        success: false,
+
                        error: format!("{rejection}"),
+
                    }),
+
                };
+

+
                Err((status, body))
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Serialize)]
+
pub struct Error {
+
    success: bool,
+
    error: String,
+
}
+

+
/// Add a Cache-Control header that marks the response as immutable and
+
/// instructs clients to cache the response for 7 days.
+
pub fn immutable_response(data: impl serde::Serialize) -> impl IntoResponse {
+
    (
+
        [(header::CACHE_CONTROL, "public, max-age=604800, immutable")],
+
        Json(data),
+
    )
+
}
added http-server/src/bin/rad-web.rs
@@ -0,0 +1,10 @@
+
use radicle_cli::terminal as term;
+
use radicle_httpd::commands::web as rad_web;
+

+
fn main() {
+
    term::run_command_args::<rad_web::Options, _>(
+
        rad_web::HELP,
+
        rad_web::run,
+
        std::env::args_os().skip(1).collect(),
+
    )
+
}
added http-server/src/cache.rs
@@ -0,0 +1,22 @@
+
use std::num::NonZeroUsize;
+
use std::sync::Arc;
+

+
use lru::LruCache;
+
use tokio::sync::Mutex;
+

+
use radicle::prelude::RepoId;
+
use radicle_surf::Oid;
+

+
#[derive(Clone)]
+
pub struct Cache {
+
    pub tree: Arc<Mutex<LruCache<(RepoId, Oid, String), serde_json::Value>>>,
+
}
+

+
impl Cache {
+
    /// Creates a new cache of the given size.
+
    pub fn new(size: NonZeroUsize) -> Self {
+
        Cache {
+
            tree: Arc::new(Mutex::new(LruCache::new(size))),
+
        }
+
    }
+
}
added http-server/src/commands.rs
@@ -0,0 +1,2 @@
+
//! Extra CLI commands relating to HTTPd.
+
pub mod web;
added http-server/src/commands/web.rs
@@ -0,0 +1,233 @@
+
use std::ffi::OsString;
+
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+
use std::process::Command;
+
use std::thread::sleep;
+
use std::time::Duration;
+

+
use anyhow::{anyhow, Context};
+
use serde::{Deserialize, Serialize};
+
use url::{Position, Url};
+

+
use radicle::crypto::{PublicKey, Signature, Signer};
+

+
use radicle_cli::terminal as term;
+
use radicle_cli::terminal::args::{Args, Error, Help};
+

+
pub const HELP: Help = Help {
+
    name: "web",
+
    description: "Run the HTTP daemon and connect the web explorer to it",
+
    version: env!("RADICLE_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad web [<option>...] [<explorer-url>]
+

+
    Runs the Radicle HTTP Daemon and opens a Radicle web explorer to authenticate with it.
+

+
Options
+

+
    --listen, -l  <addr>     Address to bind the HTTP daemon to (default: 127.0.0.1:8080)
+
    --connect, -c [<addr>]   Connect the explorer to an already running daemon (default: 127.0.0.1:8080)
+
    --path, -p  <path>       Path to be opened in the explorer after authentication
+
    --[no-]open              Open the authentication URL automatically (default: open)
+
    --help                   Print help
+
"#,
+
};
+

+
#[derive(Debug, Clone, Deserialize, Serialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct SessionInfo {
+
    pub session_id: String,
+
    pub public_key: PublicKey,
+
}
+

+
#[derive(Debug)]
+
pub struct Options {
+
    pub app_url: Url,
+
    pub listen: SocketAddr,
+
    pub path: Option<String>,
+
    pub connect: Option<SocketAddr>,
+
    pub open: bool,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut listen = None;
+
        let mut connect = None;
+
        let mut path = None;
+
        // SAFETY: This is a valid URL.
+
        #[allow(clippy::unwrap_used)]
+
        let mut app_url = Url::parse("https://app.radicle.xyz").unwrap();
+
        let mut open = true;
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("listen") | Short('l') if listen.is_none() => {
+
                    let val = parser.value()?;
+
                    listen = Some(term::args::socket_addr(&val)?);
+
                }
+
                Long("path") | Short('p') if path.is_none() => {
+
                    let val = parser.value()?;
+
                    path = Some(term::args::string(&val));
+
                }
+
                Long("connect") | Short('c') if connect.is_none() => {
+
                    if let Ok(val) = parser.value() {
+
                        connect = Some(term::args::socket_addr(&val)?);
+
                    } else {
+
                        connect = Some(SocketAddr::new(
+
                            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
+
                            8080,
+
                        ));
+
                    }
+
                }
+
                Long("open") => open = true,
+
                Long("no-open") => open = false,
+
                Long("help") | Short('h') => {
+
                    return Err(Error::Help.into());
+
                }
+
                Value(val) => {
+
                    let val = val.to_string_lossy();
+
                    app_url = Url::parse(val.as_ref()).context("invalid explorer URL supplied")?;
+
                }
+
                _ => {
+
                    return Err(anyhow!(arg.unexpected()));
+
                }
+
            }
+
        }
+

+
        Ok((
+
            Options {
+
                open,
+
                app_url,
+
                listen: listen.unwrap_or(SocketAddr::new(
+
                    IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
+
                    8080,
+
                )),
+
                path,
+
                connect,
+
            },
+
            vec![],
+
        ))
+
    }
+
}
+

+
pub fn sign(signer: Box<dyn Signer>, session: &SessionInfo) -> Result<Signature, anyhow::Error> {
+
    signer
+
        .try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
+
        .map_err(anyhow::Error::from)
+
}
+

+
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let profile = ctx.profile()?;
+
    let runtime_and_handle = if options.connect.is_none() {
+
        tracing_subscriber::fmt::init();
+

+
        let runtime = tokio::runtime::Builder::new_multi_thread()
+
            .enable_all()
+
            .build()
+
            .expect("failed to create threaded runtime");
+
        let httpd_handle = runtime.spawn(crate::run(crate::Options {
+
            aliases: Default::default(),
+
            listen: options.listen,
+
            cache: None,
+
        }));
+
        Some((runtime, httpd_handle))
+
    } else {
+
        None
+
    };
+

+
    let mut retries = 30;
+
    let connect = options.connect.unwrap_or(options.listen);
+
    let response = loop {
+
        retries -= 1;
+
        sleep(Duration::from_millis(100));
+

+
        match ureq::post(&format!("http://{connect}/api/v1/sessions")).call() {
+
            Ok(response) => {
+
                break response;
+
            }
+
            Err(err) => {
+
                if err.kind() == ureq::ErrorKind::ConnectionFailed && retries > 0 {
+
                    continue;
+
                } else {
+
                    anyhow::bail!(err);
+
                }
+
            }
+
        }
+
    };
+

+
    let session = response.into_json::<SessionInfo>()?;
+
    let signer = profile.signer()?;
+
    let signature = sign(signer, &session)?;
+

+
    let mut auth_url = options.app_url.clone();
+
    auth_url
+
        .path_segments_mut()
+
        .map_err(|_| anyhow!("URL not supported"))?
+
        .push("session")
+
        .push(&session.session_id);
+

+
    auth_url
+
        .query_pairs_mut()
+
        .append_pair("pk", &session.public_key.to_string())
+
        .append_pair("sig", &signature.to_string())
+
        .append_pair("addr", &connect.to_string());
+

+
    let pathname = radicle::rad::cwd().ok().and_then(|(_, rid)| {
+
        Url::parse(
+
            &profile
+
                .config
+
                .public_explorer
+
                .url(options.listen, rid)
+
                .to_string(),
+
        )
+
        .map(|x| x[Position::BeforePath..].to_string())
+
        .ok()
+
    });
+
    if let Some(path) = options.path.or(pathname) {
+
        auth_url.query_pairs_mut().append_pair("path", &path);
+
    }
+

+
    if options.open {
+
        #[cfg(any(target_os = "freebsd", target_os = "windows"))]
+
        let cmd_name = "echo";
+
        #[cfg(target_os = "macos")]
+
        let cmd_name = "open";
+
        #[cfg(target_os = "linux")]
+
        let cmd_name = "xdg-open";
+

+
        let mut cmd = Command::new(cmd_name);
+
        match cmd.arg(auth_url.as_str()).spawn() {
+
            Ok(mut child) => match child.wait() {
+
                Ok(exit_status) => {
+
                    if exit_status.success() {
+
                        term::success!("Opened {auth_url}");
+
                    } else {
+
                        term::info!("Visit {auth_url} to connect");
+
                    }
+
                }
+
                Err(_) => {
+
                    term::info!("Visit {auth_url} to connect");
+
                }
+
            },
+
            Err(_) => {
+
                term::error(format!("Could not open web browser via `{cmd_name}`"));
+
                term::hint("Use `rad web --no-open` if this continues");
+
                term::info!("Visit {auth_url} to connect");
+
            }
+
        }
+
    } else {
+
        term::info!("Visit {auth_url} to connect");
+
    }
+

+
    if let Some((runtime, httpd_handle)) = runtime_and_handle {
+
        runtime
+
            .block_on(httpd_handle)?
+
            .context("httpd server error")?;
+
    }
+

+
    Ok(())
+
}
added http-server/src/error.rs
@@ -0,0 +1,116 @@
+
use std::process::ExitStatus;
+

+
use axum::http;
+
use axum::response::{IntoResponse, Response};
+

+
/// Errors relating to the Git backend.
+
#[derive(Debug, thiserror::Error)]
+
pub enum GitError {
+
    /// The entity was not found.
+
    #[error("not found")]
+
    NotFound,
+

+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] std::io::Error),
+

+
    /// The service is not available.
+
    #[error("service '{0}' not available")]
+
    ServiceUnavailable(&'static str),
+

+
    /// Invalid identifier.
+
    #[error("invalid radicle identifier: {0}")]
+
    Id(#[from] radicle::identity::IdError),
+

+
    /// Storage error.
+
    #[error("storage: {0}")]
+
    Storage(#[from] radicle::storage::Error),
+

+
    /// Repository error.
+
    #[error("repository: {0}")]
+
    Repository(#[from] radicle::storage::RepositoryError),
+

+
    /// Git backend error.
+
    #[error("git-http-backend: exited with code {0}")]
+
    BackendExited(ExitStatus),
+

+
    /// Git backend error.
+
    #[error("git-http-backend: invalid header returned: {0:?}")]
+
    BackendHeader(String),
+

+
    /// HeaderName error.
+
    #[error(transparent)]
+
    InvalidHeaderName(#[from] axum::http::header::InvalidHeaderName),
+

+
    /// HeaderValue error.
+
    #[error(transparent)]
+
    InvalidHeaderValue(#[from] axum::http::header::InvalidHeaderValue),
+
}
+

+
impl GitError {
+
    pub fn status(&self) -> http::StatusCode {
+
        match self {
+
            GitError::ServiceUnavailable(_) => http::StatusCode::SERVICE_UNAVAILABLE,
+
            GitError::Id(_) => http::StatusCode::NOT_FOUND,
+
            GitError::NotFound => http::StatusCode::NOT_FOUND,
+
            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
+
        }
+
    }
+
}
+

+
impl IntoResponse for GitError {
+
    fn into_response(self) -> Response {
+
        tracing::error!("{}", self);
+

+
        self.status().into_response()
+
    }
+
}
+

+
/// Errors relating to the `/raw` route.
+
#[derive(Debug, thiserror::Error)]
+
pub enum RawError {
+
    /// Surf error.
+
    #[error(transparent)]
+
    Surf(#[from] radicle_surf::Error),
+

+
    /// Git error.
+
    #[error(transparent)]
+
    Git(#[from] radicle::git::ext::Error),
+

+
    /// Radicle Storage error.
+
    #[error(transparent)]
+
    Storage(#[from] radicle::storage::Error),
+

+
    /// Repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
+

+
    /// Http Headers error.
+
    #[error(transparent)]
+
    Headers(#[from] http::header::InvalidHeaderValue),
+

+
    /// Surf file error.
+
    #[error(transparent)]
+
    SurfFile(#[from] radicle_surf::fs::error::File),
+

+
    /// The entity was not found.
+
    #[error("not found")]
+
    NotFound,
+
}
+

+
impl RawError {
+
    pub fn status(&self) -> http::StatusCode {
+
        match self {
+
            RawError::SurfFile(_) | RawError::NotFound => http::StatusCode::NOT_FOUND,
+
            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
+
        }
+
    }
+
}
+

+
impl IntoResponse for RawError {
+
    fn into_response(self) -> Response {
+
        tracing::error!("{}", self);
+

+
        self.status().into_response()
+
    }
+
}
added http-server/src/git.rs
@@ -0,0 +1,252 @@
+
use std::collections::HashMap;
+
use std::io::prelude::*;
+
use std::net::SocketAddr;
+
use std::path::Path;
+
use std::process::{Command, Stdio};
+
use std::sync::Arc;
+
use std::{io, net, str};
+

+
use axum::body::Bytes;
+
use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery, State};
+
use axum::http::header::HeaderName;
+
use axum::http::{HeaderMap, Method, StatusCode};
+
use axum::response::IntoResponse;
+
use axum::routing::any;
+
use axum::Router;
+
use flate2::write::GzDecoder;
+
use hyper::body::Buf as _;
+

+
use radicle::identity::RepoId;
+
use radicle::profile::Profile;
+
use radicle::storage::{ReadRepository, ReadStorage};
+

+
use crate::error::GitError as Error;
+

+
pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router {
+
    Router::new()
+
        .route("/:project/*request", any(git_handler))
+
        .with_state((profile, aliases))
+
}
+

+
async fn git_handler(
+
    State((profile, aliases)): State<(Arc<Profile>, HashMap<String, RepoId>)>,
+
    AxumPath((project, request)): AxumPath<(String, String)>,
+
    method: Method,
+
    headers: HeaderMap,
+
    ConnectInfo(remote): ConnectInfo<SocketAddr>,
+
    query: RawQuery,
+
    body: Bytes,
+
) -> impl IntoResponse {
+
    let query = query.0.unwrap_or_default();
+
    let name = project.strip_suffix(".git").unwrap_or(&project);
+
    let rid: RepoId = match name.parse() {
+
        Ok(rid) => rid,
+
        Err(_) => {
+
            let Some(rid) = aliases.get(name) else {
+
                return Err(Error::NotFound);
+
            };
+
            *rid
+
        }
+
    };
+

+
    let (status, headers, body) = git_http_backend(
+
        &profile, method, headers, body, remote, rid, &request, query,
+
    )
+
    .await?;
+

+
    let mut response_headers = HeaderMap::new();
+
    for (name, vec) in headers.iter() {
+
        for value in vec {
+
            let header: HeaderName = name.try_into()?;
+
            response_headers.insert(header, value.parse()?);
+
        }
+
    }
+

+
    Ok::<_, Error>((status, response_headers, body))
+
}
+

+
async fn git_http_backend(
+
    profile: &Profile,
+
    method: Method,
+
    headers: HeaderMap,
+
    mut body: Bytes,
+
    remote: net::SocketAddr,
+
    id: RepoId,
+
    path: &str,
+
    query: String,
+
) -> Result<(StatusCode, HashMap<String, Vec<String>>, Vec<u8>), Error> {
+
    let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id);
+
    let content_type =
+
        if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) {
+
            content_type
+
        } else {
+
            ""
+
        };
+

+
    // Don't allow cloning of private repositories.
+
    let doc = profile.storage.repository(id)?.identity_doc()?;
+
    if doc.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

+
    // Reject push requests.
+
    match (path, query.as_str()) {
+
        ("git-receive-pack", _) | (_, "service=git-receive-pack") => {
+
            return Err(Error::ServiceUnavailable("git-receive-pack"));
+
        }
+
        _ => {}
+
    };
+

+
    tracing::debug!("id: {:?}", id);
+
    tracing::debug!("headers: {:?}", headers);
+
    tracing::debug!("path: {:?}", path);
+
    tracing::debug!("method: {:?}", method.as_str());
+
    tracing::debug!("remote: {:?}", remote.to_string());
+

+
    let mut cmd = Command::new("git");
+
    let mut child = cmd
+
        .arg("http-backend")
+
        .env("REQUEST_METHOD", method.as_str())
+
        .env("GIT_PROJECT_ROOT", git_dir)
+
        // "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass
+
        // the check for the "git-daemon-export-ok" file in each repository before allowing export of
+
        // that repository."
+
        .env("GIT_HTTP_EXPORT_ALL", String::default())
+
        .env("PATH_INFO", Path::new("/").join(path))
+
        .env("CONTENT_TYPE", content_type)
+
        .env("QUERY_STRING", query)
+
        .stderr(Stdio::piped())
+
        .stdout(Stdio::piped())
+
        .stdin(Stdio::piped())
+
        .spawn()?;
+

+
    // Whether the request body is compressed.
+
    let gzip = matches!(
+
        headers.get("Content-Encoding").map(|h| h.to_str()),
+
        Some(Ok("gzip"))
+
    );
+

+
    {
+
        // This is safe because we captured the child's stdin.
+
        let mut stdin = child.stdin.take().unwrap();
+

+
        // Copy the request body to git-http-backend's stdin.
+
        if gzip {
+
            let mut decoder = GzDecoder::new(&mut stdin);
+
            let mut reader = body.reader();
+

+
            io::copy(&mut reader, &mut decoder)?;
+
            decoder.finish()?;
+
        } else {
+
            while body.has_remaining() {
+
                let mut chunk = body.chunk();
+
                let count = chunk.len();
+

+
                io::copy(&mut chunk, &mut stdin)?;
+
                body.advance(count);
+
            }
+
        }
+
    }
+

+
    match child.wait_with_output() {
+
        Ok(output) if output.status.success() => {
+
            tracing::info!("git-http-backend: exited successfully for {}", id);
+

+
            let mut reader = std::io::Cursor::new(output.stdout);
+
            let mut headers = HashMap::new();
+

+
            // Parse headers returned by git so that we can use them in the client response.
+
            for line in io::Read::by_ref(&mut reader).lines() {
+
                let line = line?;
+

+
                if line.is_empty() || line == "\r" {
+
                    break;
+
                }
+

+
                let mut parts = line.splitn(2, ':');
+
                let key = parts.next();
+
                let value = parts.next();
+

+
                if let (Some(key), Some(value)) = (key, value) {
+
                    let value = &value[1..];
+

+
                    headers
+
                        .entry(key.to_string())
+
                        .or_insert_with(Vec::new)
+
                        .push(value.to_string());
+
                } else {
+
                    return Err(Error::BackendHeader(line));
+
                }
+
            }
+

+
            let status = {
+
                tracing::debug!("git-http-backend: {:?}", &headers);
+

+
                let line = headers.remove("Status").unwrap_or_default();
+
                let line = line.into_iter().next().unwrap_or_default();
+
                let mut parts = line.split(' ');
+

+
                parts
+
                    .next()
+
                    .and_then(|p| p.parse().ok())
+
                    .unwrap_or(StatusCode::OK)
+
            };
+

+
            let position = reader.position() as usize;
+
            let body = reader.into_inner().split_off(position);
+

+
            Ok((status, headers, body))
+
        }
+
        Ok(output) => {
+
            if let Ok(output) = std::str::from_utf8(&output.stderr) {
+
                tracing::error!("git-http-backend: stderr: {}", output.trim_end());
+
            }
+
            Err(Error::BackendExited(output.status))
+
        }
+
        Err(err) => {
+
            panic!("failed to wait for git-http-backend: {err}");
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::collections::HashMap;
+
    use std::net::SocketAddr;
+
    use std::str::FromStr;
+

+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+
    use radicle::identity::RepoId;
+

+
    use crate::test::{self, get, RID};
+

+
    #[tokio::test]
+
    async fn test_info_request() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.profile().to_owned(), HashMap::new())
+
            .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
+

+
        let response = get(&app, format!("/{RID}.git/info/refs")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
    }
+

+
    #[tokio::test]
+
    async fn test_aliases() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(
+
            ctx.profile().to_owned(),
+
            HashMap::from_iter([(String::from("heartwood"), RepoId::from_str(RID).unwrap())]),
+
        )
+
        .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
+

+
        let response = get(&app, "/woodheart.git/info/refs").await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, "/heartwood.git/info/refs").await;
+
        assert_eq!(response.status(), StatusCode::OK);
+
    }
+
}
added http-server/src/lib.rs
@@ -0,0 +1,180 @@
+
#![allow(clippy::type_complexity)]
+
#![allow(clippy::too_many_arguments)]
+
#![recursion_limit = "256"]
+
pub mod commands;
+
pub mod error;
+

+
use std::collections::HashMap;
+
use std::net::SocketAddr;
+
use std::num::NonZeroUsize;
+
use std::process::Command;
+
use std::str;
+
use std::sync::Arc;
+
use std::time::Duration;
+

+
use anyhow::Context as _;
+
use axum::body::{Body, HttpBody};
+
use axum::http::{Request, Response};
+
use axum::middleware;
+
use axum::Router;
+
use tokio::net::TcpListener;
+
use tower_http::trace::TraceLayer;
+
use tracing::Span;
+

+
use radicle::identity::RepoId;
+
use radicle::Profile;
+

+
use tracing_extra::{tracing_middleware, ColoredStatus, Paint, RequestId, TracingInfo};
+

+
mod api;
+
mod axum_extra;
+
mod cache;
+
mod git;
+
mod raw;
+
#[cfg(test)]
+
mod test;
+
mod tracing_extra;
+

+
/// Default cache HTTP size.
+
pub const DEFAULT_CACHE_SIZE: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(100) };
+

+
#[derive(Debug, Clone)]
+
pub struct Options {
+
    pub aliases: HashMap<String, RepoId>,
+
    pub listen: SocketAddr,
+
    pub cache: Option<NonZeroUsize>,
+
}
+

+
/// Run the Server.
+
pub async fn run(options: Options) -> anyhow::Result<()> {
+
    let git_version = Command::new("git")
+
        .arg("version")
+
        .output()
+
        .context("'git' command must be available")?
+
        .stdout;
+

+
    tracing::info!("{}", str::from_utf8(&git_version)?.trim());
+

+
    let listener = TcpListener::bind(options.listen).await?;
+

+
    tracing::info!("listening on http://{}", options.listen);
+

+
    let profile = Profile::load()?;
+
    let request_id = RequestId::new();
+

+
    tracing::info!("using radicle home at {}", profile.home().path().display());
+

+
    let app =
+
        router(options, profile)?
+
        .layer(middleware::from_fn(tracing_middleware))
+
        .layer(
+
            TraceLayer::new_for_http()
+
                .make_span_with(move |_request: &Request<Body>| {
+
                    tracing::info_span!("request", id = %request_id.clone().next())
+
                })
+
                .on_response(
+
                    |response: &Response<Body>, latency: Duration, _span: &Span| {
+
                        if let Some(info) = response.extensions().get::<TracingInfo>() {
+
                            tracing::info!(
+
                                "{} \"{} {} {:?}\" {} {:?} {}",
+
                                info.connect_info.0,
+
                                info.method,
+
                                info.uri,
+
                                info.version,
+
                                ColoredStatus(response.status()),
+
                                latency,
+
                                Paint::dim(
+
                                    response
+
                                        .body()
+
                                        .size_hint()
+
                                        .exact()
+
                                        .map(|n| n.to_string())
+
                                        .unwrap_or("0".to_string())
+
                                        .into()
+
                                ),
+
                            );
+
                        } else {
+
                            tracing::info!("Processed");
+
                        }
+
                    },
+
                ),
+
        )
+
        .into_make_service_with_connect_info::<SocketAddr>();
+

+
    axum::serve(listener, app)
+
        .await
+
        .map_err(anyhow::Error::from)
+
}
+

+
/// Create a router consisting of other sub-routers.
+
fn router(options: Options, profile: Profile) -> anyhow::Result<Router> {
+
    let profile = Arc::new(profile);
+
    let ctx = api::Context::new(profile.clone(), &options);
+

+
    let api_router = api::router(ctx);
+
    let git_router = git::router(profile.clone(), options.aliases);
+
    let raw_router = raw::router(profile);
+

+
    let app = Router::new()
+
        .merge(git_router)
+
        .nest("/api", api_router)
+
        .nest("/raw", raw_router);
+

+
    Ok(app)
+
}
+

+
pub mod logger {
+
    use tracing::dispatcher::Dispatch;
+

+
    pub fn init() -> Result<(), tracing::subscriber::SetGlobalDefaultError> {
+
        tracing::dispatcher::set_global_default(Dispatch::new(subscriber()))
+
    }
+

+
    #[cfg(feature = "logfmt")]
+
    pub fn subscriber() -> impl tracing::Subscriber {
+
        use tracing_subscriber::layer::SubscriberExt as _;
+
        use tracing_subscriber::EnvFilter;
+

+
        tracing_subscriber::Registry::default()
+
            .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
+
            .with(tracing_logfmt::layer())
+
    }
+

+
    #[cfg(not(feature = "logfmt"))]
+
    pub fn subscriber() -> impl tracing::Subscriber {
+
        tracing_subscriber::FmtSubscriber::builder()
+
            .with_target(false)
+
            .with_max_level(tracing::Level::DEBUG)
+
            .finish()
+
    }
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use std::collections::HashMap;
+
    use std::net::SocketAddr;
+

+
    use axum::extract::connect_info::MockConnectInfo;
+
    use axum::http::StatusCode;
+

+
    use crate::test::{self, get};
+

+
    #[tokio::test]
+
    async fn test_invalid_route_returns_404() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(
+
            super::Options {
+
                aliases: HashMap::new(),
+
                listen: SocketAddr::from(([0, 0, 0, 0], 8080)),
+
                cache: None,
+
            },
+
            test::profile(tmp.path(), [0xff; 32]),
+
        )
+
        .unwrap()
+
        .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
+

+
        let response = get(&app, "/aa/a").await;
+

+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+
}
added http-server/src/main.rs
@@ -0,0 +1,62 @@
+
use std::num::NonZeroUsize;
+
use std::{collections::HashMap, process};
+

+
use radicle::prelude::RepoId;
+
use radicle_httpd as httpd;
+

+
#[tokio::main]
+
async fn main() -> anyhow::Result<()> {
+
    let options = parse_options()?;
+

+
    // SAFETY: The logger is only initialized once.
+
    httpd::logger::init().unwrap();
+
    tracing::info!("version {}-{}", env!("RADICLE_VERSION"), env!("GIT_HEAD"));
+

+
    match httpd::run(options).await {
+
        Ok(()) => {}
+
        Err(err) => {
+
            tracing::error!("Fatal: {:#}", err);
+
            process::exit(1);
+
        }
+
    }
+
    Ok(())
+
}
+

+
/// Parse command-line arguments into HTTP options.
+
fn parse_options() -> Result<httpd::Options, lexopt::Error> {
+
    use lexopt::prelude::*;
+

+
    let mut parser = lexopt::Parser::from_env();
+
    let mut listen = None;
+
    let mut aliases = HashMap::new();
+
    let mut cache = Some(httpd::DEFAULT_CACHE_SIZE);
+

+
    while let Some(arg) = parser.next()? {
+
        match arg {
+
            Long("listen") => {
+
                let addr = parser.value()?.parse()?;
+
                listen = Some(addr);
+
            }
+
            Long("alias") | Short('a') => {
+
                let alias: String = parser.value()?.parse()?;
+
                let id: RepoId = parser.value()?.parse()?;
+

+
                aliases.insert(alias, id);
+
            }
+
            Long("cache") => {
+
                let size = parser.value()?.parse()?;
+
                cache = NonZeroUsize::new(size);
+
            }
+
            Long("help") | Short('h') => {
+
                println!("usage: radicle-httpd [--listen <addr>] [--alias <name> <rid>] [--cache <size>]..");
+
                process::exit(0);
+
            }
+
            _ => return Err(arg.unexpected()),
+
        }
+
    }
+
    Ok(httpd::Options {
+
        aliases,
+
        listen: listen.unwrap_or_else(|| ([0, 0, 0, 0], 8080).into()),
+
        cache,
+
    })
+
}
added http-server/src/raw.rs
@@ -0,0 +1,225 @@
+
use std::sync::Arc;
+
use std::time::Duration;
+

+
use axum::extract::{Query, State};
+
use axum::http::{header, HeaderValue, Method, StatusCode};
+
use axum::response::IntoResponse;
+
use axum::routing::get;
+
use axum::Router;
+
use hyper::HeaderMap;
+
use radicle_surf::blob::{Blob, BlobRef};
+
use tower_http::cors;
+

+
use radicle::prelude::RepoId;
+
use radicle::profile::Profile;
+
use radicle::storage::{ReadRepository, ReadStorage};
+
use radicle_surf::{Oid, Repository};
+

+
use crate::api::RawQuery;
+
use crate::axum_extra::Path;
+
use crate::error::RawError as Error;
+

+
const MAX_BLOB_SIZE: usize = 4_194_304;
+

+
static MIMES: &[(&str, &str)] = &[
+
    ("3gp", "video/3gpp"),
+
    ("7z", "application/x-7z-compressed"),
+
    ("aac", "audio/aac"),
+
    ("avi", "video/x-msvideo"),
+
    ("bin", "application/octet-stream"),
+
    ("bmp", "image/bmp"),
+
    ("bz", "application/x-bzip"),
+
    ("bz2", "application/x-bzip2"),
+
    ("csh", "application/x-csh"),
+
    ("css", "text/css"),
+
    ("csv", "text/csv"),
+
    ("doc", "application/msword"),
+
    (
+
        "docx",
+
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+
    ),
+
    ("epub", "application/epub+zip"),
+
    ("gz", "application/gzip"),
+
    ("gif", "image/gif"),
+
    ("htm", "text/html"),
+
    ("html", "text/html"),
+
    ("ico", "image/vnd.microsoft.icon"),
+
    ("jar", "application/java-archive"),
+
    ("jpeg", "image/jpeg"),
+
    ("jpg", "image/jpeg"),
+
    ("js", "text/javascript"),
+
    ("json", "application/json"),
+
    ("mjs", "text/javascript"),
+
    ("mp3", "audio/mpeg"),
+
    ("mp4", "video/mp4"),
+
    ("mpeg", "video/mpeg"),
+
    ("odp", "application/vnd.oasis.opendocument.presentation"),
+
    ("ods", "application/vnd.oasis.opendocument.spreadsheet"),
+
    ("odt", "application/vnd.oasis.opendocument.text"),
+
    ("oga", "audio/ogg"),
+
    ("ogv", "video/ogg"),
+
    ("ogx", "application/ogg"),
+
    ("otf", "font/otf"),
+
    ("png", "image/png"),
+
    ("pdf", "application/pdf"),
+
    ("php", "application/x-httpd-php"),
+
    ("ppt", "application/vnd.ms-powerpoint"),
+
    (
+
        "pptx",
+
        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+
    ),
+
    ("rar", "application/vnd.rar"),
+
    ("rtf", "application/rtf"),
+
    ("sh", "application/x-sh"),
+
    ("svg", "image/svg+xml"),
+
    ("tar", "application/x-tar"),
+
    ("tif", "image/tiff"),
+
    ("tiff", "image/tiff"),
+
    ("ttf", "font/ttf"),
+
    ("txt", "text/plain"),
+
    ("wav", "audio/wav"),
+
    ("weba", "audio/webm"),
+
    ("webm", "video/webm"),
+
    ("webp", "image/webp"),
+
    ("woff", "font/woff"),
+
    ("woff2", "font/woff2"),
+
    ("xhtml", "application/xhtml+xml"),
+
    ("xls", "application/vnd.ms-excel"),
+
    (
+
        "xlsx",
+
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+
    ),
+
    ("xml", "application/xml"),
+
    ("zip", "application/zip"),
+
];
+

+
pub fn router(profile: Arc<Profile>) -> Router {
+
    Router::new()
+
        .route("/:rid/:sha/*path", get(file_by_commit_handler))
+
        .route("/:rid/head/*path", get(file_by_canonical_head_handler))
+
        .route("/:rid/blobs/:oid", get(file_by_oid_handler))
+
        .with_state(profile)
+
        .layer(
+
            cors::CorsLayer::new()
+
                .max_age(Duration::from_secs(86400))
+
                .allow_origin(cors::Any)
+
                .allow_methods([Method::GET])
+
                .allow_headers([header::CONTENT_TYPE]),
+
        )
+
}
+

+
async fn file_by_commit_handler(
+
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
+
    State(profile): State<Arc<Profile>>,
+
) -> impl IntoResponse {
+
    let storage = &profile.storage;
+
    let repo = storage.repository(rid)?;
+

+
    // Don't allow downloading raw files for private repos.
+
    if repo.identity_doc()?.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

+
    let repo: Repository = repo.backend.into();
+
    let blob = repo.blob(sha, &path)?;
+

+
    blob_response(blob, path)
+
}
+

+
async fn file_by_canonical_head_handler(
+
    Path((rid, path)): Path<(RepoId, String)>,
+
    State(profile): State<Arc<Profile>>,
+
) -> impl IntoResponse {
+
    let storage = &profile.storage;
+
    let repo = storage.repository(rid)?;
+

+
    // Don't allow downloading raw files for private repos.
+
    if repo.identity_doc()?.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

+
    let (_, sha) = repo.head()?;
+
    let repo: Repository = repo.backend.into();
+
    let blob = repo.blob(sha, &path)?;
+

+
    blob_response(blob, path)
+
}
+

+
fn blob_response(
+
    blob: Blob<BlobRef>,
+
    path: String,
+
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
+
    let mut response_headers = HeaderMap::new();
+
    if blob.size() > MAX_BLOB_SIZE {
+
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
+
    }
+

+
    let mime = if let Some(ext) = path.split('.').last() {
+
        MIMES
+
            .binary_search_by(|(k, _)| k.cmp(&ext))
+
            .map(|k| MIMES[k].1)
+
            .unwrap_or("text; charset=utf-8")
+
    } else {
+
        "application/octet-stream"
+
    };
+
    response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(mime)?);
+

+
    Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_owned()))
+
}
+

+
async fn file_by_oid_handler(
+
    Path((rid, oid)): Path<(RepoId, Oid)>,
+
    State(profile): State<Arc<Profile>>,
+
    Query(qs): Query<RawQuery>,
+
) -> impl IntoResponse {
+
    let storage = &profile.storage;
+
    let repo = storage.repository(rid)?;
+

+
    // Don't allow downloading raw files for private repos.
+
    if repo.identity_doc()?.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

+
    let blob = repo.blob(oid)?;
+
    let mut response_headers = HeaderMap::new();
+

+
    if blob.size() > MAX_BLOB_SIZE {
+
        return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
+
    }
+

+
    response_headers.insert(
+
        header::CONTENT_TYPE,
+
        HeaderValue::from_str(&qs.mime.unwrap_or("application/octet-stream".to_string()))?,
+
    );
+

+
    Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_vec()))
+
}
+

+
#[cfg(test)]
+
mod routes {
+
    use axum::http::StatusCode;
+

+
    use crate::test::{self, get, RID, RID_PRIVATE};
+
    use radicle::storage::ReadStorage;
+

+
    #[tokio::test]
+
    async fn test_file_handler() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.profile().to_owned());
+

+
        let response = get(&app, format!("/{RID}/head/dir1/README")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(response.body().await, "Hello World from dir1!\n");
+

+
        // Make sure the repo exists in storage.
+
        ctx.profile()
+
            .storage
+
            .repository(RID_PRIVATE.parse().unwrap())
+
            .unwrap();
+

+
        let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
+
}
added http-server/src/test.rs
@@ -0,0 +1,395 @@
+
use std::collections::BTreeSet;
+
use std::fs;
+
use std::path::Path;
+
use std::str::FromStr;
+
use std::sync::Arc;
+

+
use axum::body::{Body, Bytes};
+
use axum::http::{Method, Request};
+
use axum::Router;
+
use serde_json::Value;
+
use time::OffsetDateTime;
+
use tower::ServiceExt;
+

+
use radicle::cob::patch::MergeTarget;
+
use radicle::crypto::ssh::keystore::MemorySigner;
+
use radicle::crypto::ssh::Keystore;
+
use radicle::crypto::{KeyPair, Seed, Signer};
+
use radicle::git::{raw as git2, RefString};
+
use radicle::identity::Visibility;
+
use radicle::profile::{env, Home};
+
use radicle::storage::ReadStorage;
+
use radicle::Storage;
+
use radicle::{node, profile};
+
use radicle_crypto::test::signer::MockSigner;
+

+
use crate::api::{auth, Context};
+

+
pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
+
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
+
pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
+
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
+
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
+
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
+
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
+
pub const ISSUE_DISCUSSION_ID: &str = "41e2823caa54f1d53e375035ed4aabd0a89fa855";
+
pub const ISSUE_COMMENT_ID: &str = "e9f963fab82ad875e46b29a327c5d3d51f825cdc";
+
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
+
pub const TIMESTAMP: u64 = 1671125284;
+
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
+
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
+
pub const CONTRIBUTOR_ALIAS: &str = "seed";
+
pub const CONTRIBUTOR_PATCH_ID: &str = "3e3f0dc34b3eeb64cfbc7218fbd52b97246e0564";
+

+
/// Create a new profile.
+
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
+
    let home = Home::new(home).unwrap();
+
    let keystore = Keystore::new(&home.keys());
+
    let keypair = KeyPair::from_seed(Seed::from(seed));
+
    let alias = node::Alias::new("seed");
+
    let storage = Storage::open(
+
        home.storage(),
+
        radicle::git::UserInfo {
+
            alias: alias.clone(),
+
            key: keypair.pk.into(),
+
        },
+
    )
+
    .unwrap();
+

+
    let mut db = home.policies_mut().unwrap();
+
    db.follow(&keypair.pk.into(), Some(&alias)).unwrap();
+

+
    radicle::storage::git::transport::local::register(storage.clone());
+
    keystore.store(keypair.clone(), "radicle", None).unwrap();
+

+
    radicle::Profile {
+
        home,
+
        storage,
+
        keystore,
+
        public_key: keypair.pk.into(),
+
        config: profile::Config::new(alias),
+
    }
+
}
+

+
pub fn seed(dir: &Path) -> Context {
+
    let home = dir.join("radicle");
+
    let profile = profile(home.as_path(), [0xff; 32]);
+
    let signer = Box::new(MockSigner::from_seed([0xff; 32]));
+

+
    crate::logger::init().ok();
+

+
    seed_with_signer(dir, profile, &signer)
+
}
+

+
pub fn contributor(dir: &Path) -> Context {
+
    let mut seed = [0xff; 32];
+
    *seed.last_mut().unwrap() = 0xee;
+

+
    let home = dir.join("radicle");
+
    let profile = profile(home.as_path(), seed);
+
    let signer = MemorySigner::load(&profile.keystore, None).unwrap();
+

+
    seed_with_signer(dir, profile, &signer)
+
}
+

+
fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G) -> Context {
+
    const DEFAULT_BRANCH: &str = "master";
+

+
    crate::logger::init().ok();
+

+
    profile.policies_mut().unwrap();
+
    profile.database_mut().unwrap(); // Create the database.
+

+
    let mut policies = profile.policies_mut().unwrap();
+
    let workdir = dir.join("hello-world-private");
+
    fs::create_dir_all(&workdir).unwrap();
+

+
    // add commits to workdir (repo)
+
    let mut opts = git2::RepositoryInitOptions::new();
+
    opts.initial_head(DEFAULT_BRANCH);
+
    let repo = git2::Repository::init_opts(&workdir, &opts).unwrap();
+
    let tree = radicle::git::write_tree(
+
        Path::new("README"),
+
        "Hello Private World!\n".as_bytes(),
+
        &repo,
+
    )
+
    .unwrap();
+

+
    let sig_time = git2::Time::new(1673001014, 0);
+
    let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
+

+
    repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
+
        .unwrap();
+

+
    // rad init
+
    let repo = git2::Repository::open(&workdir).unwrap();
+
    let name = "hello-world-private".to_string();
+
    let description = "Private Rad repository for tests".to_string();
+
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
+
    let visibility = Visibility::Private {
+
        allow: BTreeSet::default(),
+
    };
+
    let (rid, _, _) = radicle::rad::init(
+
        &repo,
+
        &name,
+
        &description,
+
        branch,
+
        visibility,
+
        signer,
+
        &profile.storage,
+
    )
+
    .unwrap();
+

+
    policies.seed(&rid, node::policy::Scope::All).unwrap();
+

+
    let workdir = dir.join("hello-world");
+

+
    env::set_var(env::GIT_COMMITTER_DATE, TIMESTAMP.to_string());
+

+
    fs::create_dir_all(&workdir).unwrap();
+

+
    // add commits to workdir (repo)
+
    let mut opts = git2::RepositoryInitOptions::new();
+
    opts.initial_head(DEFAULT_BRANCH);
+
    let repo = git2::Repository::init_opts(&workdir, &opts).unwrap();
+
    let tree =
+
        radicle::git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap();
+

+
    let sig_time = git2::Time::new(1673001014, 0);
+
    let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
+

+
    let oid = repo
+
        .commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
+
        .unwrap();
+
    let commit = repo.find_commit(oid).unwrap();
+

+
    repo.checkout_tree(tree.as_object(), None).unwrap();
+

+
    let tree = radicle::git::write_tree(
+
        Path::new("CONTRIBUTING"),
+
        "Thank you very much!\n".as_bytes(),
+
        &repo,
+
    )
+
    .unwrap();
+
    let sig_time = git2::Time::new(1673002014, 0);
+
    let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
+

+
    let oid2 = repo
+
        .commit(
+
            Some("HEAD"),
+
            &sig,
+
            &sig,
+
            "Add contributing file\n",
+
            &tree,
+
            &[&commit],
+
        )
+
        .unwrap();
+
    let commit2 = repo.find_commit(oid2).unwrap();
+

+
    repo.checkout_tree(tree.as_object(), None).unwrap();
+

+
    fs::create_dir(workdir.join("dir1")).unwrap();
+
    fs::write(
+
        workdir.join("dir1").join("README"),
+
        "Hello World from dir1!\n",
+
    )
+
    .unwrap();
+
    let mut index = repo.index().unwrap();
+
    index
+
        .add_all(["."], git2::IndexAddOption::DEFAULT, None)
+
        .unwrap();
+
    index.write().unwrap();
+

+
    let oid = index.write_tree().unwrap();
+
    let tree = repo.find_tree(oid).unwrap();
+

+
    let sig_time = git2::Time::new(1673003014, 0);
+
    let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
+
    repo.commit(
+
        Some("HEAD"),
+
        &sig,
+
        &sig,
+
        "Add another folder\n",
+
        &tree,
+
        &[&commit2],
+
    )
+
    .unwrap();
+

+
    // rad init
+
    let repo = git2::Repository::open(&workdir).unwrap();
+
    let name = "hello-world".to_string();
+
    let description = "Rad repository for tests".to_string();
+
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
+
    let visibility = Visibility::default();
+
    let (rid, _, _) = radicle::rad::init(
+
        &repo,
+
        &name,
+
        &description,
+
        branch,
+
        visibility,
+
        signer,
+
        &profile.storage,
+
    )
+
    .unwrap();
+
    policies.seed(&rid, node::policy::Scope::All).unwrap();
+

+
    let storage = &profile.storage;
+
    let repo = storage.repository(rid).unwrap();
+
    let mut issues = profile.issues_mut(&repo).unwrap();
+
    let issue = issues
+
        .create(
+
            "Issue #1".to_string(),
+
            "Change 'hello world' to 'hello everyone'".to_string(),
+
            &[],
+
            &[],
+
            [],
+
            signer,
+
        )
+
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor issue: {}", issue.id());
+

+
    // eq. rad patch open
+
    let mut patches = profile.patches_mut(&repo).unwrap();
+
    let oid = radicle::git::Oid::from_str(HEAD).unwrap();
+
    let base = radicle::git::Oid::from_str(PARENT).unwrap();
+
    let patch = patches
+
        .create(
+
            "A new `hello world`",
+
            "change `hello world` in README to something else",
+
            MergeTarget::Delegates,
+
            base,
+
            oid,
+
            &[],
+
            signer,
+
        )
+
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor patch: {}", patch.id());
+

+
    let options = crate::Options {
+
        aliases: std::collections::HashMap::new(),
+
        listen: std::net::SocketAddr::from(([0, 0, 0, 0], 8080)),
+
        cache: Some(crate::DEFAULT_CACHE_SIZE),
+
    };
+

+
    Context::new(Arc::new(profile), &options)
+
}
+

+
/// Adds an authorized session to the Context::sessions HashMap.
+
pub async fn create_session(ctx: Context) {
+
    let issued_at = OffsetDateTime::now_utc();
+
    let mut sessions = ctx.sessions().write().await;
+
    sessions.insert(
+
        String::from(SESSION_ID),
+
        auth::Session {
+
            status: auth::AuthState::Authorized,
+
            public_key: ctx.profile().public_key,
+
            alias: ctx.profile().config.node.alias.clone(),
+
            issued_at,
+
            expires_at: issued_at
+
                .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
+
                .unwrap(),
+
        },
+
    );
+
}
+

+
pub async fn get(app: &Router, path: impl ToString) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::GET, None, None))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
pub async fn post(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::POST, body, auth))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
pub async fn patch(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::PATCH, body, auth))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
pub async fn put(
+
    app: &Router,
+
    path: impl ToString,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::PUT, body, auth))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
fn request(
+
    path: impl ToString,
+
    method: Method,
+
    body: Option<Body>,
+
    auth: Option<String>,
+
) -> Request<Body> {
+
    let mut request = Request::builder()
+
        .method(method)
+
        .uri(path.to_string())
+
        .header("Content-Type", "application/json");
+
    if let Some(token) = auth {
+
        request = request.header("Authorization", format!("Bearer {token}"));
+
    }
+

+
    request.body(body.unwrap_or_else(Body::empty)).unwrap()
+
}
+

+
#[derive(Debug)]
+
pub struct Response(axum::response::Response);
+

+
impl Response {
+
    pub async fn json(self) -> Value {
+
        let body = self.body().await;
+
        serde_json::from_slice(&body).unwrap()
+
    }
+

+
    pub async fn id(self) -> radicle::git::Oid {
+
        let json = self.json().await;
+
        let string = json["id"].as_str().unwrap();
+

+
        radicle::git::Oid::from_str(string).unwrap()
+
    }
+

+
    pub async fn success(self) -> bool {
+
        let json = self.json().await;
+
        let success = json["success"].as_bool();
+

+
        success.unwrap_or(false)
+
    }
+

+
    pub fn status(&self) -> axum::http::StatusCode {
+
        self.0.status()
+
    }
+

+
    pub async fn body(self) -> Bytes {
+
        axum::body::to_bytes(self.0.into_body(), usize::MAX)
+
            .await
+
            .unwrap()
+
    }
+
}
added http-server/src/tracing_extra.rs
@@ -0,0 +1,70 @@
+
use std::fmt;
+
use std::net::SocketAddr;
+
use std::sync::atomic::{AtomicU64, Ordering};
+
use std::sync::Arc;
+

+
use axum::body::Body;
+
use axum::extract::ConnectInfo;
+
use axum::http::Request;
+
use axum::middleware::Next;
+
use axum::response::IntoResponse;
+
use axum::Extension;
+
use hyper::{Method, StatusCode, Uri, Version};
+

+
pub use radicle_term::ansi::Paint;
+

+
#[derive(Clone)]
+
pub struct RequestId(Arc<AtomicU64>);
+

+
impl RequestId {
+
    pub fn new() -> RequestId {
+
        RequestId(Arc::new(0.into()))
+
    }
+

+
    pub fn next(&mut self) -> u64 {
+
        self.0.fetch_add(1, Ordering::SeqCst)
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct TracingInfo {
+
    pub connect_info: ConnectInfo<SocketAddr>,
+
    pub method: Method,
+
    pub version: Version,
+
    pub uri: Uri,
+
}
+

+
pub struct ColoredStatus(pub StatusCode);
+

+
impl fmt::Display for ColoredStatus {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self.0.as_u16() {
+
            200..=299 => write!(f, "{}", Paint::green(self.0)),
+
            300..=399 => write!(f, "{}", Paint::blue(self.0)),
+
            400..=499 => write!(f, "{}", Paint::red(self.0)),
+
            _ => write!(f, "{}", Paint::yellow(self.0)),
+
        }
+
    }
+
}
+

+
pub async fn tracing_middleware(request: Request<Body>, next: Next) -> impl IntoResponse {
+
    let connect_info = *request
+
        .extensions()
+
        .get::<ConnectInfo<std::net::SocketAddr>>()
+
        .unwrap();
+

+
    let method = request.method().clone();
+
    let version = request.version();
+
    let uri = request.uri().clone();
+

+
    let tracing_info = TracingInfo {
+
        connect_info,
+
        method,
+
        version,
+
        uri,
+
    };
+

+
    let response = next.run(request).await;
+

+
    (Extension(tracing_info), response)
+
}
added http-server/systemd/radicle-httpd.service
@@ -0,0 +1,23 @@
+
# Example systemd unit file for `radicle-httpd`.
+
#
+
# When running radicle-httpd on a server, it should be run as a separate user.
+
#
+
# Copy this file into /etc/systemd/system and set the User/Group parameters
+
# under [Service] appropriately, as well as the `RAD_HOME` environment variable.
+
#
+
[Unit]
+
Description=Radicle HTTP Daemon
+
After=network.target network-online.target
+
Requires=network-online.target
+

+
[Service]
+
User=seed
+
Group=seed
+
ExecStart=/usr/local/bin/radicle-httpd --listen 127.0.0.1:8080
+
Environment=RAD_HOME=/home/seed/.radicle RUST_BACKTRACE=1 RUST_LOG=info
+
KillMode=process
+
Restart=always
+
RestartSec=1
+

+
[Install]
+
WantedBy=multi-user.target
deleted httpd-client/index.ts
@@ -1,248 +0,0 @@
-
import type { BaseUrl } from "./lib/fetcher.js";
-
import type {
-
  Blob,
-
  DiffResponse,
-
  Project,
-
  ProjectListQuery,
-
  Remote,
-
  Tree,
-
  TreeStats,
-
} from "./lib/project.js";
-
import type {
-
  SuccessResponse,
-
  CodeLocation,
-
  Range,
-
  Policy,
-
  Scope,
-
} from "./lib/shared.js";
-
import type { Comment, Embed, Reaction } from "./lib/project/comment.js";
-
import type {
-
  Commit,
-
  CommitBlob,
-
  CommitHeader,
-
  ChangesetWithDiff,
-
  ChangesetWithoutDiff,
-
  Diff,
-
  DiffBlob,
-
  DiffContent,
-
  DiffFile,
-
  HunkLine,
-
} from "./lib/project/commit.js";
-
import type { Issue, IssueState } from "./lib/project/issue.js";
-
import type {
-
  LifecycleState,
-
  Merge,
-
  Patch,
-
  PatchState,
-
  PatchUpdateAction,
-
  Review,
-
  Revision,
-
  Verdict,
-
} from "./lib/project/patch.js";
-
import type { RequestOptions } from "./lib/fetcher.js";
-
import type { ZodSchema } from "zod";
-

-
import { z, array, literal, number, object, string, union } from "zod";
-

-
import * as project from "./lib/project.js";
-
import * as profile from "./lib/profile.js";
-
import * as session from "./lib/session.js";
-
import { Fetcher } from "./lib/fetcher.js";
-
import { nodeConfigSchema, successResponseSchema } from "./lib/shared.js";
-

-
export type {
-
  BaseUrl,
-
  Blob,
-
  ChangesetWithDiff,
-
  ChangesetWithoutDiff,
-
  CodeLocation,
-
  Comment,
-
  Commit,
-
  CommitBlob,
-
  CommitHeader,
-
  Diff,
-
  DiffBlob,
-
  DiffContent,
-
  DiffFile,
-
  DiffResponse,
-
  Embed,
-
  HunkLine,
-
  Issue,
-
  IssueState,
-
  LifecycleState,
-
  Merge,
-
  Patch,
-
  PatchState,
-
  PatchUpdateAction,
-
  Policy,
-
  Project,
-
  ProjectListQuery,
-
  Range,
-
  Reaction,
-
  Remote,
-
  Review,
-
  Revision,
-
  Scope,
-
  TreeStats,
-
  Tree,
-
  Verdict,
-
};
-

-
export type Node = z.infer<typeof nodeSchema>;
-

-
const nodeSchema = object({
-
  id: string(),
-
  version: string(),
-
  config: nodeConfigSchema.nullable(),
-
  state: union([literal("running"), literal("stopped")]),
-
});
-

-
export type NodeInfo = z.infer<typeof nodeInfoSchema>;
-

-
const nodeInfoSchema = object({
-
  message: string(),
-
  service: string(),
-
  version: string(),
-
  apiVersion: string(),
-
  nid: string(),
-
  path: string(),
-
  links: array(
-
    object({
-
      href: string(),
-
      rel: string(),
-
      type: union([
-
        literal("GET"),
-
        literal("POST"),
-
        literal("PUT"),
-
        literal("DELETE"),
-
      ]),
-
    }),
-
  ),
-
});
-

-
export type NodeTracking = z.infer<typeof nodeTrackingSchema>;
-

-
const nodeTrackingSchema = array(
-
  object({
-
    id: string(),
-
    scope: string(),
-
    policy: string(),
-
  }),
-
);
-

-
export interface NodeStats {
-
  repos: { total: number };
-
}
-

-
const nodeStatsSchema = object({
-
  repos: object({ total: number() }),
-
}) satisfies ZodSchema<NodeStats>;
-

-
export class HttpdClient {
-
  #fetcher: Fetcher;
-

-
  public baseUrl: BaseUrl;
-
  public project: project.Client;
-
  public profile: profile.Client;
-
  public session: session.Client;
-

-
  public constructor(baseUrl: BaseUrl) {
-
    this.baseUrl = baseUrl;
-
    this.#fetcher = new Fetcher(this.baseUrl);
-

-
    this.project = new project.Client(this.#fetcher);
-
    this.profile = new profile.Client(this.#fetcher);
-
    this.session = new session.Client(this.#fetcher);
-
  }
-

-
  public changePort(port: number): void {
-
    this.baseUrl.port = port;
-
  }
-

-
  public get url(): string {
-
    return `${this.baseUrl.scheme}://${this.baseUrl.hostname}:${this.baseUrl.port}`;
-
  }
-

-
  public get hostname(): string {
-
    return this.baseUrl.hostname;
-
  }
-

-
  public get port(): string {
-
    return this.baseUrl.port.toString();
-
  }
-

-
  public async getNodeInfo(options?: RequestOptions): Promise<NodeInfo> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        options,
-
      },
-
      nodeInfoSchema,
-
    );
-
  }
-

-
  public async getStats(options?: RequestOptions): Promise<NodeStats> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: "stats",
-
        options,
-
      },
-
      nodeStatsSchema,
-
    );
-
  }
-

-
  public async getTracking(options?: RequestOptions): Promise<NodeTracking> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: "node/policies/repos",
-
        options,
-
      },
-
      nodeTrackingSchema,
-
    );
-
  }
-

-
  public async seedById(
-
    id: string,
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "PUT",
-
        path: `node/policies/repos/${id}`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-

-
  public async stopSeedingById(
-
    id: string,
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "DELETE",
-
        path: `node/policies/repos/${id}`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-

-
  public async getNode(options?: RequestOptions): Promise<Node> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: "node",
-
        options,
-
      },
-
      nodeSchema,
-
    );
-
  }
-
}
deleted httpd-client/lib/fetcher.ts
@@ -1,182 +0,0 @@
-
// This module provides low-level capabilities to interact with a typed
-
// JSON HTTP API.
-

-
import type { ZodIssue, ZodType, TypeOf } from "zod";
-

-
import config from "virtual:config";
-
import { compare } from "compare-versions";
-

-
export interface BaseUrl {
-
  hostname: string;
-
  port: number;
-
  scheme: string;
-
}
-

-
// Error that is thrown by `Fetcher` methods.
-
export class ResponseError extends Error {
-
  public method: string;
-
  public url: string;
-
  public status: number;
-
  public body: unknown;
-

-
  public constructor(method: string, response: Response, body_: unknown) {
-
    const body: unknown = body_;
-
    if (
-
      typeof body === "object" &&
-
      body !== null &&
-
      "message" in body &&
-
      typeof body.message === "string"
-
    ) {
-
      super(body.message);
-
    } else {
-
      super("Response error");
-
    }
-

-
    this.method = method;
-
    this.body = body_;
-
    this.status = response.status;
-
    this.url = response.url;
-
  }
-
}
-

-
// Error that is thrown by `Fetcher` methods when parsing the response
-
// body fails.
-
export class ResponseParseError extends Error {
-
  public method: string;
-
  public body: unknown;
-
  public description: string;
-
  public apiVersion: string | undefined;
-
  public zodIssues: ZodIssue[];
-
  public path?: string;
-

-
  public constructor(
-
    method: string,
-
    body: unknown,
-
    apiVersion: string | undefined,
-
    zodIssues: ZodIssue[],
-
    path?: string,
-
  ) {
-
    super("Failed to parse response body");
-

-
    let description: string;
-
    if (
-
      apiVersion === undefined ||
-
      compare(apiVersion, config.nodes.apiVersion, "<")
-
    ) {
-
      description = `The node you are fetching from seems to be outdated, make sure the httpd API version is at least ${config.nodes.apiVersion} currently ${apiVersion ?? "unknown"}.`;
-
    } else if (
-
      config.nodes.apiVersion === undefined ||
-
      compare(apiVersion, config.nodes.apiVersion, ">")
-
    ) {
-
      description = `The web client you are using is outdated, make sure it supports at least ${apiVersion} to interact with this node currently ${config.nodes.apiVersion ?? "unknown"}.`;
-
    } else {
-
      description =
-
        "This is usually due to a version mismatch between the seed and the web interface.";
-
    }
-
    this.apiVersion = apiVersion;
-
    this.description =
-
      "The response received from the seed does not match the expected schema.<br/>".concat(
-
        description,
-
      );
-

-
    this.method = method;
-
    this.path = path;
-
    this.body = body;
-
    this.zodIssues = zodIssues;
-
  }
-
}
-

-
export interface RequestOptions {
-
  abort?: AbortSignal;
-
}
-

-
export interface FetchParams {
-
  method: Method;
-
  // Path to append to the `Fetcher`s base URL to get the final URL.
-
  path?: string;
-
  // Object that is serialized into JSON and sent as the data.
-
  body?: unknown;
-
  // Query parameters to be serialized with URLSearchParams.
-
  query?: Record<string, string | number | boolean>;
-
  options?: RequestOptions;
-
  headers?: Record<string, string>;
-
}
-

-
type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
-

-
export class Fetcher {
-
  #baseUrl: BaseUrl;
-

-
  public constructor(baseUrl: BaseUrl) {
-
    this.#baseUrl = baseUrl;
-
  }
-

-
  // Execute a fetch and parse the result with the provided schema.
-
  // Return the parsed payload.
-
  //
-
  // Throws `ResponseError` if the response status code is not `200`.
-
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
  public async fetchOk<T extends ZodType<any>>(
-
    params: FetchParams,
-
    schema: T,
-
  ): Promise<TypeOf<T>> {
-
    const response = await this.fetch({
-
      ...params,
-
      query: { ...params.query, v: config.nodes.apiVersion },
-
    });
-

-
    if (!response.ok) {
-
      let responseBody = await response.text();
-
      try {
-
        responseBody = JSON.parse(responseBody);
-
      } catch (_e: unknown) {
-
        // We keep the original text response body.
-
      }
-
      throw new ResponseError(params.method, response, responseBody);
-
    }
-

-
    const responseBody = await response.json();
-
    const result = schema.safeParse(responseBody);
-
    if (result.success) {
-
      return result.data;
-
    } else {
-
      const response = await this.fetch({ method: "GET" });
-
      const info = await response.json();
-
      throw new ResponseParseError(
-
        params.method,
-
        responseBody,
-
        info.apiVersion,
-
        result.error.errors,
-
        params.path,
-
      );
-
    }
-
  }
-

-
  private async fetch({
-
    method,
-
    path,
-
    body,
-
    options = {},
-
    query,
-
    headers = {},
-
  }: FetchParams): Promise<Response> {
-
    if (body !== undefined && headers["content-type"] === undefined) {
-
      headers["content-type"] = "application/json";
-
    }
-

-
    const pathSegment = path === undefined ? "" : `/${path}`;
-

-
    let url = `${this.#baseUrl.scheme}://${this.#baseUrl.hostname}:${this.#baseUrl.port}/api/v1${pathSegment}`;
-

-
    if (query) {
-
      const searchparams = new URLSearchParams(query as Record<string, string>);
-
      url = `${url}?${searchparams.toString()}`;
-
    }
-
    return globalThis.fetch(url, {
-
      method,
-
      headers,
-
      body: body === undefined ? undefined : JSON.stringify(body),
-
      signal: options.abort,
-
    });
-
  }
-
}
deleted httpd-client/lib/profile.ts
@@ -1,36 +0,0 @@
-
import type { Fetcher, RequestOptions } from "./fetcher.js";
-
import type { z } from "zod";
-

-
import { array, boolean, object, string } from "zod";
-
import { nodeConfigSchema } from "./shared.js";
-

-
const profileSchema = object({
-
  config: object({
-
    publicExplorer: string(),
-
    preferredSeeds: array(string()),
-
    cli: object({ hints: boolean() }),
-
    node: nodeConfigSchema,
-
  }),
-
  home: string(),
-
});
-

-
export type Profile = z.infer<typeof profileSchema>;
-

-
export class Client {
-
  #fetcher: Fetcher;
-

-
  public constructor(fetcher: Fetcher) {
-
    this.#fetcher = fetcher;
-
  }
-

-
  public async getProfile(options?: RequestOptions): Promise<Profile> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `profile`,
-
        options,
-
      },
-
      profileSchema,
-
    );
-
  }
-
}
deleted httpd-client/lib/project.ts
@@ -1,503 +0,0 @@
-
import type { Commit, Commits } from "./project/commit.js";
-
import type { Embed } from "./project/comment.js";
-
import type { Fetcher, RequestOptions } from "./fetcher.js";
-
import type {
-
  Issue,
-
  IssueCreated,
-
  IssueUpdateAction,
-
} from "./project/issue.js";
-
import type {
-
  Patch,
-
  PatchCreate,
-
  PatchCreated,
-
  PatchUpdateAction,
-
} from "./project/patch.js";
-
import type { SuccessResponse } from "./shared.js";
-
import type { ZodSchema } from "zod";
-

-
import { successResponseSchema } from "./shared.js";
-
import {
-
  array,
-
  boolean,
-
  literal,
-
  number,
-
  optional,
-
  record,
-
  object,
-
  string,
-
  union,
-
  z,
-
} from "zod";
-

-
import {
-
  diffBlobSchema,
-
  commitHeaderSchema,
-
  commitSchema,
-
  commitsSchema,
-
  diffSchema,
-
} from "./project/commit.js";
-

-
import {
-
  issueCreatedSchema,
-
  issueSchema,
-
  issuesSchema,
-
} from "./project/issue.js";
-

-
import {
-
  patchSchema,
-
  patchesSchema,
-
  patchCreatedSchema,
-
} from "./project/patch.js";
-

-
const projectSchema = object({
-
  id: string(),
-
  name: string(),
-
  description: string(),
-
  defaultBranch: string(),
-
  delegates: array(object({ id: string(), alias: optional(string()) })),
-
  head: string(),
-
  threshold: number(),
-
  visibility: union([
-
    object({ type: literal("public") }),
-
    object({ type: literal("private"), allow: optional(array(string())) }),
-
  ]).optional(),
-
  patches: object({
-
    open: number(),
-
    draft: number(),
-
    archived: number(),
-
    merged: number(),
-
  }),
-
  issues: object({
-
    open: number(),
-
    closed: number(),
-
  }),
-
  seeding: number(),
-
});
-
const projectsSchema = array(projectSchema);
-

-
export type Project = z.infer<typeof projectSchema>;
-

-
const activitySchema = object({
-
  activity: array(number()),
-
});
-

-
export type Activity = z.infer<typeof activitySchema>;
-

-
const blobSchema = object({
-
  binary: boolean(),
-
  content: optional(string()),
-
  name: string(),
-
  path: string(),
-
  lastCommit: commitHeaderSchema,
-
});
-

-
export type Blob = z.infer<typeof blobSchema>;
-

-
const treeEntrySchema = object({
-
  path: string(),
-
  name: string(),
-
  oid: string(),
-
  kind: union([literal("blob"), literal("tree"), literal("submodule")]),
-
});
-

-
export type TreeEntry = z.infer<typeof treeEntrySchema>;
-

-
const treeStatsSchema = object({
-
  commits: number(),
-
  branches: number(),
-
  contributors: number(),
-
});
-

-
export type TreeStats = z.infer<typeof treeStatsSchema>;
-

-
export type Tree = z.infer<typeof treeSchema>;
-

-
const treeSchema = object({
-
  entries: array(treeEntrySchema),
-
  lastCommit: commitHeaderSchema,
-
  name: string(),
-
  path: string(),
-
});
-

-
export type Remote = z.infer<typeof remoteSchema>;
-

-
const remoteSchema = object({
-
  id: string(),
-
  alias: string().optional(),
-
  heads: record(string(), string()),
-
  delegate: boolean(),
-
});
-

-
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
-

-
export type DiffResponse = z.infer<typeof diffResponseSchema>;
-

-
const diffResponseSchema = object({
-
  commits: array(commitHeaderSchema),
-
  diff: diffSchema,
-
  files: record(string(), diffBlobSchema),
-
});
-

-
export type ProjectListQuery = {
-
  page?: number;
-
  perPage?: number;
-
  show?: "pinned" | "all";
-
};
-
export class Client {
-
  #fetcher: Fetcher;
-

-
  public constructor(fetcher: Fetcher) {
-
    this.#fetcher = fetcher;
-
  }
-

-
  public async getByDelegate(
-
    delegateId: string,
-
    options?: RequestOptions,
-
  ): Promise<Project[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `delegates/${delegateId}/projects`,
-
        options,
-
      },
-
      projectsSchema,
-
    );
-
  }
-

-
  public async getById(id: string, options?: RequestOptions): Promise<Project> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}`,
-
        options,
-
      },
-
      projectSchema,
-
    );
-
  }
-

-
  public async getAll(
-
    query?: ProjectListQuery,
-
    options?: RequestOptions,
-
  ): Promise<Project[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: "projects",
-
        query,
-
        options,
-
      },
-
      projectsSchema,
-
    );
-
  }
-

-
  public async getActivity(
-
    id: string,
-
    options?: RequestOptions,
-
  ): Promise<Activity> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/activity`,
-
        options,
-
      },
-
      activitySchema,
-
    );
-
  }
-

-
  public async getReadme(
-
    id: string,
-
    sha: string,
-
    options?: RequestOptions,
-
  ): Promise<Blob> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/readme/${sha}`,
-
        options,
-
      },
-
      blobSchema,
-
    );
-
  }
-

-
  public async getBlob(
-
    id: string,
-
    sha: string,
-
    path: string,
-
    options?: RequestOptions,
-
  ): Promise<Blob> {
-
    const blob = await this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/blob/${sha}/${path}`,
-
        options,
-
      },
-
      blobSchema,
-
    );
-
    return blob;
-
  }
-

-
  public async getTree(
-
    id: string,
-
    sha: string,
-
    path?: string,
-
    options?: RequestOptions,
-
  ): Promise<Tree> {
-
    const tree = await this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/tree/${sha}/${path ?? ""}`,
-
        options,
-
      },
-
      treeSchema,
-
    );
-
    return tree;
-
  }
-

-
  public async getTreeStatsBySha(
-
    id: string,
-
    sha: string,
-
    options?: RequestOptions,
-
  ): Promise<TreeStats> {
-
    const tree = await this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/stats/tree/${sha}`,
-
        options,
-
      },
-
      treeStatsSchema,
-
    );
-
    return tree;
-
  }
-

-
  public async getAllRemotes(
-
    id: string,
-
    options?: RequestOptions,
-
  ): Promise<Remote[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/remotes`,
-
        options,
-
      },
-
      remotesSchema,
-
    );
-
  }
-

-
  public async getRemoteByPeer(
-
    id: string,
-
    peer: string,
-
    options?: RequestOptions,
-
  ): Promise<Remote> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/remotes/${peer}`,
-
        options,
-
      },
-
      remoteSchema,
-
    );
-
  }
-

-
  public async getAllCommits(
-
    id: string,
-
    query?: {
-
      parent?: string;
-
      since?: number;
-
      until?: number;
-
      page?: number;
-
      perPage?: number;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<Commits> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/commits`,
-
        query,
-
        options,
-
      },
-
      commitsSchema,
-
    );
-
  }
-

-
  public async getCommitBySha(
-
    id: string,
-
    sha: string,
-
    options?: RequestOptions,
-
  ): Promise<Commit> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/commits/${sha}`,
-
        options,
-
      },
-
      commitSchema,
-
    );
-
  }
-

-
  public async getDiff(
-
    id: string,
-
    revisionBase: string,
-
    revisionOid: string,
-
    options?: RequestOptions,
-
  ): Promise<DiffResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/diff/${revisionBase}/${revisionOid}`,
-
        options,
-
      },
-
      diffResponseSchema,
-
    );
-
  }
-

-
  public async getIssueById(
-
    id: string,
-
    issueId: string,
-
    options?: RequestOptions,
-
  ): Promise<Issue> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/issues/${issueId}`,
-
        options,
-
      },
-
      issueSchema,
-
    );
-
  }
-

-
  public async getAllIssues(
-
    id: string,
-
    query?: {
-
      page?: number;
-
      perPage?: number;
-
      state?: string;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<Issue[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/issues`,
-
        query,
-
        options,
-
      },
-
      issuesSchema,
-
    );
-
  }
-

-
  public async createIssue(
-
    id: string,
-
    body: {
-
      title: string;
-
      description: string;
-
      assignees: string[];
-
      embeds: Embed[];
-
      labels: string[];
-
    },
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<IssueCreated> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "POST",
-
        path: `projects/${id}/issues`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        body,
-
        options,
-
      },
-
      issueCreatedSchema,
-
    );
-
  }
-

-
  public async updateIssue(
-
    id: string,
-
    issueId: string,
-
    body: IssueUpdateAction,
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "PATCH",
-
        path: `projects/${id}/issues/${issueId}`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        body,
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-

-
  public async getPatchById(
-
    id: string,
-
    patchId: string,
-
    options?: RequestOptions,
-
  ): Promise<Patch> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/patches/${patchId}`,
-
        options,
-
      },
-
      patchSchema,
-
    );
-
  }
-

-
  public async getAllPatches(
-
    id: string,
-
    query?: {
-
      page?: number;
-
      perPage?: number;
-
      state?: string;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<Patch[]> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `projects/${id}/patches`,
-
        query,
-
        options,
-
      },
-
      patchesSchema,
-
    );
-
  }
-

-
  public async createPatch(
-
    id: string,
-
    body: PatchCreate,
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<PatchCreated> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "POST",
-
        path: `projects/${id}/patches`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        body,
-
        options,
-
      },
-
      patchCreatedSchema,
-
    );
-
  }
-

-
  public async updatePatch(
-
    id: string,
-
    patchId: string,
-
    body: PatchUpdateAction,
-
    authToken: string,
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "PATCH",
-
        path: `projects/${id}/patches/${patchId}`,
-
        headers: { Authorization: `Bearer ${authToken}` },
-
        body,
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-
}
deleted httpd-client/lib/project/comment.ts
@@ -1,27 +0,0 @@
-
import type { z } from "zod";
-
import { array, boolean, number, object, string } from "zod";
-
import { authorSchema, codeLocationSchema } from "../shared";
-

-
export type Comment = z.infer<typeof commentSchema>;
-
export type Embed = Comment["embeds"][0];
-
export type Reaction = Comment["reactions"][0];
-

-
export const commentSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  body: string(),
-
  edits: array(
-
    object({
-
      author: authorSchema,
-
      body: string(),
-
      embeds: array(object({ name: string(), content: string() })),
-
      timestamp: number(),
-
    }),
-
  ),
-
  embeds: array(object({ name: string(), content: string() })),
-
  reactions: array(object({ emoji: string(), authors: array(authorSchema) })),
-
  timestamp: number(),
-
  location: codeLocationSchema.nullable().optional(),
-
  resolved: boolean(),
-
  replyTo: string().nullable(),
-
});
deleted httpd-client/lib/project/commit.ts
@@ -1,239 +0,0 @@
-
import type { z } from "zod";
-
export type {
-
  Commit,
-
  CommitHeader,
-
  Commits,
-
  Diff,
-
  DiffContent,
-
  DiffFile,
-
  ChangesetWithDiff,
-
  ChangesetWithoutDiff,
-
  HunkLine,
-
  Hunks,
-
};
-

-
import {
-
  array,
-
  boolean,
-
  discriminatedUnion,
-
  literal,
-
  number,
-
  object,
-
  record,
-
  string,
-
  union,
-
} from "zod";
-
export {
-
  commitBlobSchema,
-
  commitHeaderSchema,
-
  commitSchema,
-
  commitsSchema,
-
  diffBlobSchema,
-
  diffSchema,
-
};
-

-
const gitPersonSchema = object({
-
  name: string(),
-
  email: string(),
-
});
-

-
type CommitHeader = z.infer<typeof commitHeaderSchema>;
-

-
const commitHeaderSchema = object({
-
  id: string(),
-
  author: gitPersonSchema,
-
  summary: string(),
-
  description: string(),
-
  parents: array(string()),
-
  committer: gitPersonSchema.merge(object({ time: number() })),
-
});
-

-
const diffBlobSchema = object({
-
  binary: boolean(),
-
  content: string(),
-
  id: string(),
-
});
-

-
export type DiffBlob = z.infer<typeof diffBlobSchema>;
-

-
const commitBlobSchema = object({
-
  binary: boolean(),
-
  content: string(),
-
});
-

-
export type CommitBlob = z.infer<typeof commitBlobSchema>;
-

-
type AdditionHunkLine = z.infer<typeof additionHunkLineSchema>;
-

-
const additionHunkLineSchema = object({
-
  line: string(),
-
  lineNo: number(),
-
  type: literal("addition"),
-
});
-

-
type DeletionHunkLine = z.infer<typeof deletionHunkLineSchema>;
-

-
const deletionHunkLineSchema = object({
-
  line: string(),
-
  lineNo: number(),
-
  type: literal("deletion"),
-
});
-

-
type DiffFile = z.infer<typeof diffFileSchema>;
-

-
const diffFileSchema = object({
-
  oid: string(),
-
  mode: union([
-
    literal("blob"),
-
    literal("blobExecutable"),
-
    literal("tree"),
-
    literal("link"),
-
    literal("commit"),
-
  ]),
-
});
-

-
type ContextHunkLine = z.infer<typeof contextHunkLineSchema>;
-

-
const contextHunkLineSchema = object({
-
  line: string(),
-
  lineNoNew: number(),
-
  lineNoOld: number(),
-
  type: literal("context"),
-
});
-

-
type HunkLine = AdditionHunkLine | DeletionHunkLine | ContextHunkLine;
-

-
const hunkLineSchema = union([
-
  additionHunkLineSchema,
-
  deletionHunkLineSchema,
-
  contextHunkLineSchema,
-
]);
-

-
type Hunks = z.infer<typeof changesetHunkSchema>;
-

-
const changesetHunkSchema = object({
-
  header: string(),
-
  lines: array(hunkLineSchema),
-
});
-

-
type DiffContent = z.infer<typeof diffContentSchema>;
-

-
const diffContentSchema = discriminatedUnion("type", [
-
  object({
-
    type: literal("plain"),
-
    stats: object({
-
      additions: number(),
-
      deletions: number(),
-
    }),
-
    hunks: array(changesetHunkSchema),
-
    eof: union([
-
      literal("noneMissing"),
-
      literal("oldMissing"),
-
      literal("newMissing"),
-
      literal("bothMissing"),
-
    ]),
-
  }),
-
  object({ type: literal("binary") }),
-
  object({ type: literal("empty") }),
-
]);
-
const diffChangesetSchema = object({
-
  path: string(),
-
  diff: diffContentSchema,
-
});
-

-
const diffAddedChangesetSchema = diffChangesetSchema.merge(
-
  object({ state: literal("added"), new: diffFileSchema }),
-
);
-

-
const diffDeletedChangesetSchema = diffChangesetSchema.merge(
-
  object({ state: literal("deleted"), old: diffFileSchema }),
-
);
-

-
const diffModifiedChangesetSchema = diffChangesetSchema.merge(
-
  object({
-
    state: literal("modified"),
-
    new: diffFileSchema,
-
    old: diffFileSchema,
-
  }),
-
);
-

-
const diffCopiedChangesetSchema = object({
-
  state: literal("copied"),
-
  newPath: string(),
-
  oldPath: string(),
-
});
-

-
const diffCopiedWithModificationsChangesetSchema =
-
  diffCopiedChangesetSchema.merge(
-
    object({
-
      old: diffFileSchema,
-
      new: diffFileSchema,
-
      diff: diffContentSchema,
-
    }),
-
  );
-

-
const diffMovedChangesetSchema = object({
-
  state: literal("moved"),
-
  newPath: string(),
-
  oldPath: string(),
-
  current: diffFileSchema,
-
});
-

-
const diffMovedWithModificationsChangesetSchema = diffMovedChangesetSchema
-
  .omit({ current: true })
-
  .merge(
-
    object({
-
      old: diffFileSchema,
-
      new: diffFileSchema,
-
      diff: diffContentSchema,
-
    }),
-
  );
-

-
type Diff = z.infer<typeof diffSchema>;
-

-
type ChangesetWithDiff = z.infer<typeof changesetWithDiffSchema>;
-
type ChangesetWithoutDiff = z.infer<typeof changesetWithoutDiffSchema>;
-

-
const changesetWithDiffSchema = union([
-
  diffAddedChangesetSchema,
-
  diffDeletedChangesetSchema,
-
  diffModifiedChangesetSchema,
-
  diffMovedWithModificationsChangesetSchema,
-
  diffCopiedWithModificationsChangesetSchema,
-
]);
-
const changesetWithoutDiffSchema = union([
-
  diffMovedChangesetSchema,
-
  diffCopiedChangesetSchema,
-
]);
-

-
const diffSchema = object({
-
  files: array(
-
    union([
-
      diffAddedChangesetSchema,
-
      diffDeletedChangesetSchema,
-
      diffModifiedChangesetSchema,
-
      diffMovedChangesetSchema,
-
      diffMovedWithModificationsChangesetSchema,
-
      diffCopiedChangesetSchema,
-
      diffCopiedWithModificationsChangesetSchema,
-
    ]),
-
  ),
-
  stats: object({
-
    filesChanged: number(),
-
    insertions: number(),
-
    deletions: number(),
-
  }),
-
});
-

-
type Commit = z.infer<typeof commitSchema>;
-

-
const commitSchema = object({
-
  commit: commitHeaderSchema,
-
  diff: diffSchema,
-
  branches: array(string()),
-
  files: record(string(), commitBlobSchema),
-
});
-

-
type Commits = z.infer<typeof commitsSchema>;
-

-
const commitsSchema = array(commitHeaderSchema);
deleted httpd-client/lib/project/issue.ts
@@ -1,70 +0,0 @@
-
import type { Embed } from "./comment.js";
-
import type { ZodSchema, z } from "zod";
-
import { array, boolean, literal, object, string, union } from "zod";
-

-
import { commentSchema } from "./comment.js";
-
import { authorSchema } from "../shared.js";
-

-
export type IssueState =
-
  | { status: "open" }
-
  | { status: "closed"; reason: "other" | "solved" };
-

-
const issueStateSchema = union([
-
  object({ status: literal("open") }),
-
  object({
-
    status: literal("closed"),
-
    reason: union([literal("other"), literal("solved")]),
-
  }),
-
]) satisfies ZodSchema<IssueState>;
-

-
export const issueSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  title: string(),
-
  state: issueStateSchema,
-
  discussion: array(commentSchema),
-
  labels: array(string()),
-
  assignees: array(authorSchema),
-
});
-

-
export type Issue = z.infer<typeof issueSchema>;
-

-
export interface IssueCreated {
-
  success: boolean;
-
  id: string;
-
}
-

-
export const issueCreatedSchema = object({
-
  success: boolean(),
-
  id: string(),
-
}) satisfies ZodSchema<IssueCreated>;
-

-
export const issuesSchema = array(issueSchema) satisfies ZodSchema<Issue[]>;
-

-
export type IssueUpdateAction =
-
  | { type: "edit"; title: string }
-
  | { type: "label"; labels: string[] }
-
  | {
-
      type: "assign";
-
      assignees: string[];
-
    }
-
  | { type: "lifecycle"; state: IssueState }
-
  | {
-
      type: "comment";
-
      body: string;
-
      embeds?: Embed[];
-
      replyTo?: string;
-
    }
-
  | {
-
      type: "comment.edit";
-
      id: string;
-
      body: string;
-
      embeds: Embed[];
-
    }
-
  | { type: "comment.redact"; id: string }
-
  | {
-
      type: "comment.react";
-
      id: string;
-
      reaction: string;
-
      active: boolean;
-
    };
deleted httpd-client/lib/project/patch.ts
@@ -1,209 +0,0 @@
-
import type { Embed } from "./comment.js";
-
import type { ZodSchema, z } from "zod";
-
import type { CodeLocation } from "../shared.js";
-

-
import { commentSchema } from "./comment.js";
-

-
import {
-
  array,
-
  boolean,
-
  literal,
-
  number,
-
  optional,
-
  object,
-
  string,
-
  tuple,
-
  union,
-
} from "zod";
-
import { authorSchema } from "../shared.js";
-

-
export type PatchState = z.infer<typeof patchStateSchema>;
-

-
const patchStateSchema = union([
-
  object({
-
    status: literal("draft"),
-
  }),
-
  object({
-
    status: literal("open"),
-
    conflicts: array(tuple([string(), string()])).optional(),
-
  }),
-
  object({
-
    status: literal("archived"),
-
  }),
-
  object({
-
    status: literal("merged"),
-
    revision: string(),
-
    commit: string(),
-
  }),
-
]);
-

-
export type Merge = z.infer<typeof mergeSchema>;
-

-
const mergeSchema = object({
-
  author: authorSchema,
-
  revision: string(),
-
  commit: string(),
-
  timestamp: number(),
-
});
-

-
export type Verdict = "accept" | "reject";
-

-
const reviewSchema = object({
-
  author: authorSchema,
-
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
-
  comments: array(commentSchema),
-
  summary: string().nullable(),
-
  timestamp: number(),
-
});
-

-
export type Review = z.infer<typeof reviewSchema>;
-

-
const revisionSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  description: string(),
-
  edits: array(
-
    object({
-
      author: authorSchema,
-
      body: string(),
-
      embeds: array(object({ name: string(), content: string() })),
-
      timestamp: number(),
-
    }),
-
  ),
-
  reactions: array(
-
    object({
-
      emoji: string(),
-
      authors: array(authorSchema),
-
    }),
-
  ),
-
  base: string(),
-
  oid: string(),
-
  refs: array(string()),
-
  discussions: array(commentSchema),
-
  reviews: array(reviewSchema),
-
  timestamp: number(),
-
});
-

-
export type Revision = z.infer<typeof revisionSchema>;
-

-
export const patchSchema = object({
-
  id: string(),
-
  author: authorSchema,
-
  title: string(),
-
  state: patchStateSchema,
-
  target: string(),
-
  labels: array(string()),
-
  merges: array(mergeSchema),
-
  assignees: array(string()),
-
  revisions: array(revisionSchema),
-
});
-

-
export type Patch = z.infer<typeof patchSchema>;
-

-
export const patchesSchema = array(patchSchema) satisfies ZodSchema<Patch[]>;
-

-
export type LifecycleState =
-
  | { status: "draft" }
-
  | { status: "open" }
-
  | { status: "archived" };
-

-
export type PatchUpdateAction =
-
  | { type: "edit"; title: string; target: "delegates" }
-
  | { type: "label"; labels: string[] }
-
  | { type: "assign"; assignees: string[] }
-
  | { type: "merge"; revision: string; commit: string }
-
  | { type: "lifecycle"; state: LifecycleState }
-
  | {
-
      type: "review";
-
      revision: string;
-
      summary?: string;
-
      verdict?: Verdict | null;
-
    }
-
  | { type: "review.edit"; review: string; summary?: string }
-
  | { type: "review.redact"; review: string }
-
  | {
-
      type: "review.comment";
-
      review: string;
-
      body: string;
-
      location: CodeLocation;
-
      replyTo?: string;
-
      embeds?: Embed[];
-
    }
-
  | {
-
      type: "review.comment.edit";
-
      review: string;
-
      comment: string;
-
      body: string;
-
      embeds: Embed[];
-
    }
-
  | {
-
      type: "review.comment.redact";
-
      review: string;
-
      comment: string;
-
    }
-
  | {
-
      type: "review.comment.react";
-
      review: string;
-
      comment: string;
-
      reaction: string;
-
      active: boolean;
-
    }
-
  | { type: "revision"; description: string; base: string; oid: string }
-
  | {
-
      type: "revision.edit";
-
      revision: string;
-
      description: string;
-
      embeds?: Embed[];
-
    }
-
  | {
-
      type: "revision.react";
-
      revision: string;
-
      reaction: string;
-
      location?: CodeLocation;
-
      active: boolean;
-
    }
-
  | { type: "revision.redact"; revision: string }
-
  | {
-
      type: "revision.comment";
-
      revision: string;
-
      body: string;
-
      embeds?: Embed[];
-
      location?: CodeLocation;
-
      replyTo?: string;
-
    }
-
  | {
-
      type: "revision.comment.edit";
-
      revision: string;
-
      comment: string;
-
      body: string;
-
      embeds: Embed[];
-
    }
-
  | {
-
      type: "revision.comment.redact";
-
      revision: string;
-
      comment: string;
-
    }
-
  | {
-
      type: "revision.comment.react";
-
      revision: string;
-
      comment: string;
-
      reaction: string;
-
      active: boolean;
-
    };
-

-
export const patchCreateSchema = object({
-
  title: string(),
-
  description: string(),
-
  target: string(),
-
  oid: string(),
-
  labels: array(string()),
-
});
-

-
export type PatchCreate = z.infer<typeof patchCreateSchema>;
-

-
export const patchCreatedSchema = object({
-
  success: boolean(),
-
  id: string(),
-
});
-

-
export type PatchCreated = z.infer<typeof patchCreatedSchema>;
deleted httpd-client/lib/session.ts
@@ -1,90 +0,0 @@
-
import type { Fetcher, RequestOptions } from "./fetcher.js";
-
import type { SuccessResponse } from "./shared.js";
-
import type { z } from "zod";
-

-
import { number, object, string } from "zod";
-

-
import { successResponseSchema } from "./shared.js";
-

-
export const sessionPayloadSchema = object({
-
  sessionId: string(),
-
  signature: string(),
-
  publicKey: string(),
-
});
-

-
export type SessionPayload = z.infer<typeof sessionPayloadSchema>;
-

-
const sessionSchema = object({
-
  sessionId: string(),
-
  status: string(),
-
  publicKey: string(),
-
  alias: string(),
-
  issuedAt: number(),
-
  expiresAt: number(),
-
});
-

-
export type Session = z.infer<typeof sessionSchema>;
-

-
export class Client {
-
  #fetcher: Fetcher;
-

-
  public constructor(fetcher: Fetcher) {
-
    this.#fetcher = fetcher;
-
  }
-

-
  public async getById(id: string, options?: RequestOptions): Promise<Session> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "GET",
-
        path: `sessions/${id}`,
-
        options,
-
      },
-
      sessionSchema,
-
    );
-
  }
-

-
  public async create(options?: RequestOptions): Promise<Session> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "POST",
-
        path: "sessions",
-
        options,
-
      },
-
      sessionSchema,
-
    );
-
  }
-

-
  public async update(
-
    id: string,
-
    body: {
-
      sig: string;
-
      pk: string;
-
    },
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "PUT",
-
        path: `sessions/${id}`,
-
        body,
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-

-
  public async delete(
-
    id: string,
-
    options?: RequestOptions,
-
  ): Promise<SuccessResponse> {
-
    return this.#fetcher.fetchOk(
-
      {
-
        method: "DELETE",
-
        path: `sessions/${id}`,
-
        headers: { Authorization: `Bearer ${id}` },
-
        options,
-
      },
-
      successResponseSchema,
-
    );
-
  }
-
}
deleted httpd-client/lib/shared.ts
@@ -1,77 +0,0 @@
-
import type { ZodSchema, z } from "zod";
-

-
import { array, literal, number, object, string, union } from "zod";
-

-
export interface SuccessResponse {
-
  success: true;
-
}
-

-
export const successResponseSchema = object({
-
  success: literal(true),
-
}) satisfies ZodSchema<SuccessResponse>;
-

-
const policySchema = union([literal("allow"), literal("block")]);
-
const scopeSchema = union([literal("followed"), literal("all")]);
-

-
export const nodeConfigSchema = object({
-
  alias: string(),
-
  peers: union([
-
    object({ type: literal("static") }),
-
    object({ type: literal("dynamic"), target: number() }),
-
  ]),
-
  listen: array(string()),
-
  connect: array(string()),
-
  externalAddresses: array(string()),
-
  network: union([literal("main"), literal("test")]),
-
  relay: union([literal("always"), literal("never"), literal("auto")]),
-
  limits: object({
-
    routingMaxSize: number(),
-
    routingMaxAge: number(),
-
    fetchConcurrency: number(),
-
    gossipMaxAge: number(),
-
    maxOpenFiles: number(),
-
    rate: object({
-
      inbound: object({
-
        fillRate: number(),
-
        capacity: number(),
-
      }),
-
      outbound: object({
-
        fillRate: number(),
-
        capacity: number(),
-
      }),
-
    }),
-
  }),
-
  policy: policySchema,
-
  scope: scopeSchema,
-
});
-

-
export type Policy = z.infer<typeof policySchema>;
-
export type Scope = z.infer<typeof scopeSchema>;
-

-
export const rangeSchema = union([
-
  object({
-
    type: literal("lines"),
-
    range: object({ start: number(), end: number() }),
-
  }),
-
  object({
-
    type: literal("chars"),
-
    line: number(),
-
    range: object({ start: number(), end: number() }),
-
  }),
-
]);
-

-
export type Range = z.infer<typeof rangeSchema>;
-

-
export const codeLocationSchema = object({
-
  commit: string(),
-
  path: string(),
-
  old: rangeSchema.nullable(),
-
  new: rangeSchema.nullable(),
-
});
-

-
export type CodeLocation = z.infer<typeof codeLocationSchema>;
-

-
export const authorSchema = object({
-
  id: string(),
-
  alias: string().optional(),
-
});
deleted httpd-client/tests/client.test.ts
@@ -1,24 +0,0 @@
-
import { describe, test } from "vitest";
-

-
import { HttpdClient } from "@httpd-client";
-
import { defaultHttpdPort } from "@tests/support/fixtures";
-

-
const api = new HttpdClient({
-
  hostname: "127.0.0.1",
-
  port: defaultHttpdPort,
-
  scheme: "http",
-
});
-

-
describe("client", () => {
-
  test("#getNodeInfo()", async () => {
-
    await api.getNodeInfo();
-
  });
-

-
  test("#getStats()", async () => {
-
    await api.getStats();
-
  });
-

-
  test("#getNode()", async () => {
-
    await api.getNode();
-
  });
-
});
deleted httpd-client/tests/project.test.ts
@@ -1,291 +0,0 @@
-
import { describe, test } from "vitest";
-

-
import { HttpdClient } from "@httpd-client";
-
import {
-
  aliceMainHead,
-
  aliceRemote,
-
  bobRemote,
-
  cobRid,
-
  defaultHttpdPort,
-
  sourceBrowsingRid,
-
} from "@tests/support/fixtures.js";
-
import {
-
  assertIssue,
-
  assertPatch,
-
  createIssueToBeModified,
-
  createPatchToBeModified,
-
} from "@httpd-client/tests/support/support";
-
import { authenticate } from "@httpd-client/tests/support/httpd.js";
-
import { testFixture as testWithAPI } from "@httpd-client/tests/support/fixtures.js";
-

-
describe("project", () => {
-
  const api = new HttpdClient({
-
    hostname: "127.0.0.1",
-
    port: defaultHttpdPort,
-
    scheme: "http",
-
  });
-

-
  test("#getByDelegate(delegateId)", async () => {
-
    await api.project.getByDelegate(aliceRemote);
-
  });
-

-
  test("#getAll()", async () => {
-
    await api.project.getAll();
-
  });
-

-
  test("#getById(id)", async () => {
-
    await api.project.getById(sourceBrowsingRid);
-
  });
-

-
  test("#getActivity(id)", async () => {
-
    await api.project.getActivity(sourceBrowsingRid);
-
  });
-

-
  test("#getReadme(id, sha)", async () => {
-
    await api.project.getReadme(sourceBrowsingRid, aliceMainHead);
-
  });
-

-
  test("#getBlob(id, sha, path)", async () => {
-
    await api.project.getBlob(sourceBrowsingRid, aliceMainHead, "src/true.c");
-
  });
-

-
  test("#getTree(id, sha)", async () => {
-
    await api.project.getTree(sourceBrowsingRid, aliceMainHead);
-
  });
-

-
  test("#getTreeStats(id, sha)", async () => {
-
    await api.project.getTreeStatsBySha(sourceBrowsingRid, aliceMainHead);
-
  });
-

-
  test("#getTree(id, sha, path)", async () => {
-
    await api.project.getTree(sourceBrowsingRid, aliceMainHead, "src");
-
  });
-

-
  test("#getAllRemotes(id)", async () => {
-
    await api.project.getAllRemotes(sourceBrowsingRid);
-
  });
-

-
  test("#getRemoteByPeer(id, peer)", async () => {
-
    await api.project.getRemoteByPeer(
-
      sourceBrowsingRid,
-
      aliceRemote.substring(8),
-
    );
-
  });
-

-
  test("#getAllCommits(id)", async () => {
-
    await api.project.getAllCommits(sourceBrowsingRid);
-
  });
-

-
  // TODO: test since/until properly.
-
  test("#getAllCommits(id, {parent, since, until, page, perPage})", async () => {
-
    await api.project.getAllCommits(sourceBrowsingRid, {
-
      parent: aliceMainHead,
-
      since: 1679065819581,
-
      until: 1679065819590,
-
      page: 1,
-
      perPage: 2,
-
    });
-
  });
-

-
  test("#getCommitBySha(id, sha)", async () => {
-
    await api.project.getCommitBySha(sourceBrowsingRid, aliceMainHead);
-
  });
-

-
  test("#getDiff(id, revisionBase, revisionOid)", async () => {
-
    await api.project.getDiff(
-
      sourceBrowsingRid,
-
      "90f6d058ece12f75f349bc7bbe88142187fe0379",
-
      aliceMainHead,
-
    );
-
  });
-

-
  test("#getIssueById(id, issueId)", async () => {
-
    await api.project.getIssueById(
-
      cobRid,
-
      "d481fe6e562dd78129589d4738f171a8380fcb19",
-
    );
-
  });
-

-
  test("#getAllIssues(id)", async () => {
-
    await api.project.getAllIssues(cobRid, {
-
      page: 0,
-
      perPage: 5,
-
      state: "open",
-
    });
-
  });
-

-
  testWithAPI(
-
    "#createIssue(id, { title, description, assignees, labels })",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const { id: issueId } = await api.project.createIssue(
-
        cobRid,
-
        {
-
          title: "aaa",
-
          description: "bbb",
-
          assignees: [],
-
          embeds: [],
-
          labels: ["bug", "documentation"],
-
        },
-
        sessionId,
-
      );
-
      await assertIssue(
-
        issueId,
-
        {
-
          title: "aaa",
-
          discussion: [{ body: "bbb" }],
-
          assignees: [],
-
          labels: ["bug", "documentation"],
-
        },
-
        api,
-
      );
-
    },
-
  );
-

-
  testWithAPI(
-
    "#updateIssue(id, issueId, { type: 'edit' }, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const issueId = await createIssueToBeModified(api, sessionId);
-
      await api.project.updateIssue(
-
        cobRid,
-
        issueId,
-
        { type: "edit", title: "ccc" },
-
        sessionId,
-
      );
-
      await assertIssue(issueId, { title: "ccc" }, api);
-
    },
-
  );
-

-
  testWithAPI(
-
    "#updateIssue(id, issueId, { type: 'label' }, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const issueId = await createIssueToBeModified(api, sessionId);
-
      await api.project.updateIssue(
-
        cobRid,
-
        issueId,
-
        { type: "label", labels: ["bug"] },
-
        sessionId,
-
      );
-
      await assertIssue(issueId, { labels: ["bug"] }, api);
-
    },
-
  );
-

-
  testWithAPI(
-
    "#updateIssue(id, issueId, { type: 'assign' }, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const issueId = await createIssueToBeModified(api, sessionId);
-
      await api.project.updateIssue(
-
        cobRid,
-
        issueId,
-
        {
-
          type: "assign",
-
          assignees: [bobRemote],
-
        },
-
        sessionId,
-
      );
-
      await assertIssue(
-
        issueId,
-
        {
-
          assignees: [
-
            {
-
              id: "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5",
-
              alias: "bob",
-
            },
-
          ],
-
        },
-
        api,
-
      );
-
    },
-
  );
-

-
  testWithAPI(
-
    "#updateIssue(id, issueId, { type: 'lifecycle' }, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const issueId = await createIssueToBeModified(api, sessionId);
-
      await api.project.updateIssue(
-
        cobRid,
-
        issueId,
-
        { type: "lifecycle", state: { status: "closed", reason: "solved" } },
-
        sessionId,
-
      );
-
      await assertIssue(
-
        issueId,
-
        {
-
          state: { status: "closed", reason: "solved" },
-
        },
-
        api,
-
      );
-
    },
-
  );
-

-
  test("#getPatchById(id, patchId)", async () => {
-
    await api.project.getPatchById(
-
      cobRid,
-
      "59a0821edc73630bce540596cffc7854da557365",
-
    );
-
  });
-

-
  test("#getAllPatches(id)", async () => {
-
    await api.project.getAllPatches(cobRid);
-
  });
-

-
  testWithAPI(
-
    "#createPatch(id, patchCreate, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const { id: oid } = await api.project.createPatch(
-
        cobRid,
-
        {
-
          title: "ppp",
-
          description: "qqq",
-
          target: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
-
          oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
-
          labels: [],
-
        },
-
        sessionId,
-
      );
-
      await assertPatch(
-
        oid,
-
        {
-
          title: "ppp",
-
          state: { status: "open" },
-
          target: "delegates",
-
          labels: [],
-
          revisions: [
-
            {
-
              description: "qqq",
-
              base: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
-
              oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
-
            },
-
          ],
-
        },
-
        api,
-
      );
-
    },
-
  );
-

-
  testWithAPI(
-
    "#updatePatch(id, patchId, { type: 'edit' }, authToken)",
-
    async ({ httpd: { api, peer } }) => {
-
      const sessionId = await authenticate(api, peer);
-
      const patchId = await createPatchToBeModified(api, sessionId);
-
      await api.project.updatePatch(
-
        cobRid,
-
        patchId,
-
        { type: "label", labels: ["bug"] },
-
        sessionId,
-
      );
-
      await assertPatch(
-
        patchId,
-
        {
-
          labels: ["bug"],
-
        },
-
        api,
-
      );
-
    },
-
  );
-
});
deleted httpd-client/tests/session.test.ts
@@ -1,38 +0,0 @@
-
import * as FsSync from "node:fs";
-
import * as Path from "node:path";
-
import { describe, test } from "vitest";
-

-
import { HttpdClient } from "@httpd-client";
-
import { authenticate } from "@httpd-client/tests/support/httpd.js";
-
import { createPeerManager } from "@tests/support/peerManager.js";
-
import { gitOptions } from "@tests/support/fixtures.js";
-
import { tmpDir } from "@tests/support/support.js";
-

-
describe("session", async () => {
-
  const peerManager = await createPeerManager({
-
    dataDir: Path.resolve(Path.join(tmpDir, "peers")),
-
    outputLog: FsSync.createWriteStream(
-
      Path.join(tmpDir, "peerManager.log"),
-
    ).setMaxListeners(16),
-
  });
-
  const peer = await peerManager.createPeer({
-
    name: "session",
-
    gitOptions: gitOptions["alice"],
-
  });
-
  await peer.startHttpd();
-
  const api = new HttpdClient(peer.httpdBaseUrl);
-

-
  test("#getById(id)", async () => {
-
    const id = await authenticate(api, peer);
-
    await api.session.getById(id);
-
  });
-

-
  test("#update(id, {sig, pk})", async () => {
-
    await authenticate(api, peer);
-
  });
-

-
  test("#delete(id)", async () => {
-
    const id = await authenticate(api, peer);
-
    await api.session.delete(id);
-
  });
-
});
deleted httpd-client/tests/support/fixtures.ts
@@ -1,32 +0,0 @@
-
import * as FsSync from "node:fs";
-
import * as Path from "node:path";
-
import { test } from "vitest";
-

-
import { HttpdClient } from "@httpd-client";
-
import { RadiclePeer, createPeerManager } from "@tests/support/peerManager.js";
-
import { gitOptions } from "@tests/support/fixtures.js";
-
import { tmpDir } from "@tests/support/support.js";
-

-
interface TestFixtures {
-
  httpd: { api: HttpdClient; peer: RadiclePeer };
-
}
-

-
export const testFixture = test.extend<TestFixtures>({
-
  // eslint-disable-next-line no-empty-pattern
-
  httpd: async ({}, use) => {
-
    const peerManager = await createPeerManager({
-
      dataDir: Path.resolve(Path.join(tmpDir, "peers")),
-
      outputLog: FsSync.createWriteStream(
-
        Path.join(tmpDir, "peerManager.log"),
-
      ).setMaxListeners(16),
-
    });
-
    const peer = await peerManager.createPeer({
-
      name: "palm",
-
      gitOptions: gitOptions["alice"],
-
    });
-
    await peer.startHttpd();
-
    const api = new HttpdClient(peer.httpdBaseUrl);
-
    await use({ api, peer });
-
    await peer.shutdown();
-
  },
-
});
deleted httpd-client/tests/support/httpd.ts
@@ -1,30 +0,0 @@
-
import type { HttpdClient } from "@httpd-client";
-
import type { RadiclePeer } from "@tests/support/peerManager.js";
-

-
import assert from "node:assert";
-

-
export async function authenticate(
-
  api: HttpdClient,
-
  peer: RadiclePeer,
-
): Promise<string> {
-
  const { stdout } = await peer.spawn("rad-web", [
-
    "http://localhost:3001",
-
    "--no-open",
-
    "--connect",
-
    `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
-
  ]);
-
  const match = stdout.match(/Visit (http:\/\/\S+) to connect/);
-
  assert(
-
    match !== null && match[1],
-
    `Failed to get authentication URL from: ${stdout}`,
-
  );
-

-
  const authUrl = new URL(match[1]);
-
  const sessionId = authUrl.pathname.split("/")[2];
-

-
  await api.session.update(sessionId, {
-
    sig: authUrl.searchParams.get("sig")!,
-
    pk: authUrl.searchParams.get("pk")!,
-
  });
-
  return sessionId;
-
}
deleted httpd-client/tests/support/support.ts
@@ -1,59 +0,0 @@
-
import type { HttpdClient } from "@httpd-client";
-

-
import { expect } from "vitest";
-
import isMatch from "lodash/isMatch";
-

-
import { cobRid } from "@tests/support/fixtures";
-

-
export async function createIssueToBeModified(
-
  api: HttpdClient,
-
  sessionId: string,
-
) {
-
  const { id } = await api.project.createIssue(
-
    cobRid,
-
    { title: "aaa", description: "bbb", embeds: [], assignees: [], labels: [] },
-
    sessionId,
-
  );
-

-
  return id;
-
}
-

-
export async function createPatchToBeModified(
-
  api: HttpdClient,
-
  sessionId: string,
-
) {
-
  const { id } = await api.project.createPatch(
-
    cobRid,
-
    {
-
      title: "rrr",
-
      description: "ttt",
-
      target: "d7dd8cecae16b1108234e09dbdb5d64ae394bc25",
-
      oid: "38c225e2a0b47ba59def211f4e4825c31d9463ec",
-
      labels: [],
-
    },
-
    sessionId,
-
  );
-

-
  return id;
-
}
-
export async function assertIssue(
-
  oid: string,
-
  change: Record<string, unknown>,
-
  api: HttpdClient,
-
) {
-
  expect(
-
    //@prettier-ignore looks more readable than what prettier suggests.
-
    isMatch(await api.project.getIssueById(cobRid, oid), change),
-
  ).toBe(true);
-
}
-

-
export async function assertPatch(
-
  oid: string,
-
  change: Record<string, unknown>,
-
  api: HttpdClient,
-
) {
-
  expect(
-
    //@prettier-ignore looks more readable than what prettier suggests.
-
    isMatch(await api.project.getPatchById(cobRid, oid), change),
-
  ).toBe(true);
-
}
deleted httpd-client/vite.config.ts
@@ -1,25 +0,0 @@
-
import nodeConfig from "config";
-
import path from "node:path";
-
import virtual from "vite-plugin-virtual";
-
import { defineConfig } from "vite";
-

-
export default defineConfig({
-
  plugins: [
-
    virtual({
-
      "virtual:config": nodeConfig.util.toObject(),
-
    }),
-
  ],
-
  test: {
-
    environment: "node",
-
    include: ["httpd-client/tests/*.test.ts"],
-
    reporters: "verbose",
-
    globalSetup: "./tests/support/globalSetup",
-
  },
-
  resolve: {
-
    alias: {
-
      "@tests": path.resolve("./tests"),
-
      "@app": path.resolve("./src"),
-
      "@httpd-client": path.resolve("./httpd-client"),
-
    },
-
  },
-
});
modified package.json
@@ -8,10 +8,12 @@
    "build": "vite build && scripts/copy-katex-assets && scripts/install-twemoji-assets",
    "postinstall": "scripts/copy-katex-assets && scripts/install-twemoji-assets",
    "check": "scripts/check",
+
    "check:http-server": "cd http-server && cargo clippy --all --tests && cargo fmt --all --check",
    "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
    "test:unit": "TZ='UTC' vitest run",
    "test:e2e": "NODE_CONFIG_ENV='test' TZ='UTC' playwright test",
-
    "test:httpd-api:unit": "NODE_CONFIG_ENV='test' TZ='UTC' vitest run --config httpd-client/vite.config.ts --reporter verbose"
+
    "test:http-client:unit": "NODE_CONFIG_ENV='test' TZ='UTC' vitest run --config http-client/vite.config.ts --reporter verbose",
+
    "test:http-server": "cd http-server && cargo test --all-features"
  },
  "type": "module",
  "engines": {
modified src/App/Header/Breadcrumbs/NodeSegment.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";

  import { isLocal } from "@app/lib/utils";

modified src/components/Comment.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Comment, Embed } from "@httpd-client";
+
  import type { Comment, Embed } from "@http-client";

  import { tick } from "svelte";

modified src/components/CommentToggleInput.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Embed } from "@httpd-client";
+
  import type { Embed } from "@http-client";

  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";

modified src/components/CompactCommitAuthorship.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { CommitHeader } from "@httpd-client";
+
  import type { CommitHeader } from "@http-client";

  import * as utils from "@app/lib/utils";
  import HoverPopover from "./HoverPopover.svelte";
modified src/components/ExtendedTextarea.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Embed } from "@httpd-client";
+
  import type { Embed } from "@http-client";

  import { createEventDispatcher } from "svelte";

modified src/components/Markdown.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Embed } from "@httpd-client";
+
  import type { Embed } from "@http-client";

  import dompurify from "dompurify";
  import matter from "@radicle/gray-matter";
modified src/components/ProjectCard.ts
@@ -1,4 +1,4 @@
-
import type { ProjectListQuery } from "@httpd-client";
+
import type { ProjectListQuery } from "@http-client";

import { loadProjectActivity, type WeeklyActivity } from "@app/lib/commit";
import {
@@ -6,7 +6,7 @@ import {
  type BaseUrl,
  type Commit,
  type Project,
-
} from "@httpd-client";
+
} from "@http-client";

export interface ProjectInfo {
  project: Project;
modified src/components/ReactionSelector.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Comment } from "@httpd-client";
+
  import type { Comment } from "@http-client";

  import { createEventDispatcher } from "svelte";

modified src/components/Reactions.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Comment } from "@httpd-client";
+
  import type { Comment } from "@http-client";

  import IconButton from "./IconButton.svelte";

modified src/components/ScopePolicyExplainer.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Scope, Policy } from "@httpd-client";
+
  import type { Scope, Policy } from "@http-client";

  import { capitalize } from "lodash";

modified src/components/Thread.svelte
@@ -1,6 +1,6 @@
<script lang="ts" strictEvents>
-
  import type { Embed } from "@httpd-client";
-
  import type { Comment } from "@httpd-client";
+
  import type { Embed } from "@http-client";
+
  import type { Comment } from "@http-client";

  import * as utils from "@app/lib/utils";
  import partial from "lodash/partial";
modified src/lib/commit.ts
@@ -1,7 +1,7 @@
-
import type { BaseUrl, CommitHeader } from "@httpd-client";
+
import type { BaseUrl, CommitHeader } from "@http-client";

import { getDaysPassed } from "@app/lib/utils";
-
import { HttpdClient } from "@httpd-client";
+
import { HttpdClient } from "@http-client";

// A set of commits grouped by time.
interface CommitGroup {
modified src/lib/file.ts
@@ -1,4 +1,4 @@
-
import type { Embed } from "@httpd-client";
+
import type { Embed } from "@http-client";

async function parseGitOid(bytes: Uint8Array): Promise<string> {
  // Create the header
modified src/lib/httpd.ts
@@ -1,9 +1,9 @@
-
import type { Node, Policy, Scope } from "@httpd-client";
+
import type { Node, Policy, Scope } from "@http-client";

import { get, writable } from "svelte/store";
import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";

-
import { HttpdClient } from "@httpd-client";
+
import { HttpdClient } from "@http-client";
import config from "virtual:config";
import { deduplicateStore } from "@app/lib/deduplicateStore";
import { experimental } from "./appearance";
modified src/lib/projects.ts
@@ -1,6 +1,6 @@
-
import type { BaseUrl, Project } from "@httpd-client";
+
import type { BaseUrl, Project } from "@http-client";

-
import { HttpdClient } from "@httpd-client";
+
import { HttpdClient } from "@http-client";
import { isFulfilled } from "@app/lib/utils";

export interface ProjectBaseUrl {
modified src/lib/router.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl } from "@httpd-client";
+
import type { BaseUrl } from "@http-client";
import type { LoadedRoute, Route } from "@app/lib/router/definitions";

import { get, writable } from "svelte/store";
modified src/lib/router/definitions.ts
@@ -1,7 +1,7 @@
import type {
  ResponseError,
  ResponseParseError,
-
} from "@httpd-client/lib/fetcher";
+
} from "@http-client/lib/fetcher";
import type { HomeRoute, HomeLoadedRoute } from "@app/views/home/router";
import type {
  ProjectLoadedRoute,
modified src/lib/seeds.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl } from "@httpd-client";
+
import type { BaseUrl } from "@http-client";

import storedWritable from "@efstajas/svelte-stored-writable";
import unionBy from "lodash/unionBy";
modified src/lib/utils.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl } from "@httpd-client";
+
import type { BaseUrl } from "@http-client";

import md5 from "md5";
import bs58 from "bs58";
modified src/views/home/Index.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { ComponentProps } from "svelte";
  import type { ProjectInfo } from "@app/components/ProjectCard";
-
  import type { BaseUrl, ProjectListQuery } from "@httpd-client";
+
  import type { BaseUrl, ProjectListQuery } from "@http-client";

  import storedWritable from "@efstajas/svelte-stored-writable";
  import { derived } from "svelte/store";
modified src/views/home/components/PreferredSeedDropdown.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import { HttpdClient, type BaseUrl } from "@httpd-client";
+
  import { HttpdClient, type BaseUrl } from "@http-client";

  import config from "virtual:config";
  import {
modified src/views/home/error.ts
@@ -1,4 +1,4 @@
-
import { ResponseParseError, ResponseError } from "@httpd-client/lib/fetcher";
+
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";

export function handleError(
  error: Error | ResponseParseError | ResponseError,
modified src/views/home/router.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl } from "@httpd-client";
+
import type { BaseUrl } from "@http-client";
import type { ErrorRoute } from "@app/lib/router/definitions";

import * as seeds from "@app/lib/seeds";
modified src/views/nodes/ScopePolicyPopover.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Policy, Scope } from "@httpd-client";
+
  import type { Policy, Scope } from "@http-client";

  import capitalize from "lodash/capitalize";

modified src/views/nodes/View.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, NodeStats, Policy, Scope } from "@httpd-client";
+
  import type { BaseUrl, NodeStats, Policy, Scope } from "@http-client";

  import { capitalize } from "lodash";

modified src/views/nodes/error.ts
@@ -1,5 +1,5 @@
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
-
import { ResponseParseError, ResponseError } from "@httpd-client/lib/fetcher";
+
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";

export function handleError(
  error: Error | ResponseParseError | ResponseError,
modified src/views/nodes/router.ts
@@ -1,9 +1,9 @@
-
import type { BaseUrl, NodeStats, Policy, Scope } from "@httpd-client";
+
import type { BaseUrl, NodeStats, Policy, Scope } from "@http-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";

import config from "virtual:config";
-
import { HttpdClient } from "@httpd-client";
-
import { ResponseError, ResponseParseError } from "@httpd-client/lib/fetcher";
+
import { HttpdClient } from "@http-client";
+
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { baseUrlToString } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/projects/error";
modified src/views/projects/Changeset.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, CommitBlob, Diff } from "@httpd-client";
+
  import type { BaseUrl, CommitBlob, Diff } from "@http-client";

  import FileDiff from "@app/views/projects/Changeset/FileDiff.svelte";
  import FileLocationChange from "@app/views/projects/Changeset/FileLocationChange.svelte";
modified src/views/projects/Changeset/FileDiff.svelte
@@ -4,7 +4,7 @@
    ChangesetWithDiff,
    DiffContent,
    HunkLine,
-
  } from "@httpd-client";
+
  } from "@http-client";

  import { onDestroy, onMount } from "svelte";
  import { toHtml } from "hast-util-to-html";
modified src/views/projects/Changeset/FileLocationChange.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, ChangesetWithoutDiff } from "@httpd-client";
+
  import type { BaseUrl, ChangesetWithoutDiff } from "@http-client";

  import Badge from "@app/components/Badge.svelte";
  import IconButton from "@app/components/IconButton.svelte";
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Reaction } from "@httpd-client";
+
  import type { Reaction } from "@http-client";

  import { createEventDispatcher } from "svelte";

modified src/views/projects/Cob/CobCommitTeaser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, CommitHeader } from "@httpd-client";
+
  import type { BaseUrl, CommitHeader } from "@http-client";

  import { twemoji } from "@app/lib/utils";

modified src/views/projects/Cob/Embeds.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { Embed } from "@httpd-client";
+
  import type { Embed } from "@http-client";

  import Badge from "@app/components/Badge.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
modified src/views/projects/Cob/Revision.svelte
@@ -7,11 +7,11 @@
    PatchState,
    Revision,
    Verdict,
-
  } from "@httpd-client";
+
  } from "@http-client";
  import type { Timeline } from "@app/views/projects/Patch.svelte";

  import * as utils from "@app/lib/utils";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { closeFocused } from "@app/components/Popover.svelte";
  import { onMount } from "svelte";
  import { parseEmbedIntoMap } from "@app/lib/file";
modified src/views/projects/Commit.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Commit, Node, Project } from "@httpd-client";
+
  import type { BaseUrl, Commit, Node, Project } from "@http-client";

  import { formatCommit } from "@app/lib/utils";

modified src/views/projects/Commit/CommitAuthorship.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { CommitHeader } from "@httpd-client";
+
  import type { CommitHeader } from "@http-client";

  import {
    absoluteTimestamp,
modified src/views/projects/Commit/CommitTeaser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, CommitHeader } from "@httpd-client";
+
  import type { BaseUrl, CommitHeader } from "@http-client";

  import { twemoji } from "@app/lib/utils";

modified src/views/projects/DiffStatBadgeLoader.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
-
  import type { BaseUrl, Patch, Revision } from "@httpd-client";
+
  import type { BaseUrl, Patch, Revision } from "@http-client";

-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { formatCommit } from "@app/lib/utils";

  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
modified src/views/projects/Header.svelte
@@ -3,7 +3,7 @@
</script>

<script lang="ts">
-
  import type { BaseUrl, Project } from "@httpd-client";
+
  import type { BaseUrl, Project } from "@http-client";

  import Link from "@app/components/Link.svelte";
  import Button from "@app/components/Button.svelte";
modified src/views/projects/Header/CloneButton.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";

  import config from "virtual:config";
  import { parseRepositoryId } from "@app/lib/utils";
modified src/views/projects/History.svelte
@@ -6,11 +6,11 @@
    Remote,
    Node,
    Tree,
-
  } from "@httpd-client";
+
  } from "@http-client";
  import type { Route } from "@app/lib/router";

  import { COMMITS_PER_PAGE } from "./router";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { baseUrlToString } from "@app/lib/utils";
  import { groupCommits } from "@app/lib/commit";

modified src/views/projects/Issue.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Reaction } from "@httpd-client/lib/project/comment";
+
  import type { Reaction } from "@http-client/lib/project/comment";
  import type {
    BaseUrl,
    Comment,
@@ -8,7 +8,7 @@
    IssueState,
    Project,
    Node,
-
  } from "@httpd-client";
+
  } from "@http-client";
  import type { Session } from "@app/lib/httpd";

  import capitalize from "lodash/capitalize";
@@ -21,7 +21,7 @@
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import { experimental } from "@app/lib/appearance";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { closeFocused } from "@app/components/Popover.svelte";
  import { httpdStore } from "@app/lib/httpd";
  import { parseEmbedIntoMap } from "@app/lib/file";
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Issue } from "@httpd-client";
+
  import type { BaseUrl, Issue } from "@http-client";

  import {
    absoluteTimestamp,
modified src/views/projects/Issue/New.svelte
@@ -1,10 +1,10 @@
<script lang="ts">
-
  import type { BaseUrl, Embed, Node, Project, Reaction } from "@httpd-client";
+
  import type { BaseUrl, Embed, Node, Project, Reaction } from "@http-client";

  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { httpdStore } from "@app/lib/httpd";

  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
modified src/views/projects/Issues.svelte
@@ -1,14 +1,8 @@
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Issue,
-
    IssueState,
-
    Node,
-
    Project,
-
  } from "@httpd-client";
+
  import type { BaseUrl, Issue, IssueState, Node, Project } from "@http-client";

  import capitalize from "lodash/capitalize";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { ISSUES_PER_PAGE } from "./router";
  import { baseUrlToString, isLocal } from "@app/lib/utils";
  import { closeFocused } from "@app/components/Popover.svelte";
modified src/views/projects/Layout.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import type { ActiveTab } from "./Header.svelte";
-
  import type { BaseUrl, Node, Project } from "@httpd-client";
+
  import type { BaseUrl, Node, Project } from "@http-client";

  import AppHeader from "@app/App/Header.svelte";

modified src/views/projects/Patch.svelte
@@ -9,7 +9,7 @@
    Revision,
    Diff,
    Node,
-
  } from "@httpd-client";
+
  } from "@http-client";

  interface Thread {
    root: Comment;
@@ -42,7 +42,7 @@
</script>

<script lang="ts">
-
  import type { BaseUrl, Embed, Patch } from "@httpd-client";
+
  import type { BaseUrl, Embed, Patch } from "@http-client";
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { ComponentProps } from "svelte";
@@ -57,7 +57,7 @@
  import isEqual from "lodash/isEqual";
  import partial from "lodash/partial";
  import uniqBy from "lodash/uniqBy";
-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";
  import { httpdStore } from "@app/lib/httpd";
  import { parseEmbedIntoMap } from "@app/lib/file";

modified src/views/projects/Patch/PatchTeaser.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
-
  import type { Patch } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";
+
  import type { Patch } from "@http-client";

  import {
    absoluteTimestamp,
modified src/views/projects/Patch/RevisionSelector.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import type { PatchView } from "../router";
-
  import type { BaseUrl, Patch, Project } from "@httpd-client";
+
  import type { BaseUrl, Patch, Project } from "@http-client";
  import * as utils from "@app/lib/utils";

  import Button from "@app/components/Button.svelte";
modified src/views/projects/Patches.svelte
@@ -1,13 +1,7 @@
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Node,
-
    Patch,
-
    PatchState,
-
    Project,
-
  } from "@httpd-client";
-

-
  import { HttpdClient } from "@httpd-client";
+
  import type { BaseUrl, Node, Patch, PatchState, Project } from "@http-client";
+

+
  import { HttpdClient } from "@http-client";
  import capitalize from "lodash/capitalize";

  import { PATCHES_PER_PAGE } from "./router";
modified src/views/projects/Share.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";

  import debounce from "lodash/debounce";
  import { api, httpdStore } from "@app/lib/httpd";
modified src/views/projects/Sidebar.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import type { ActiveTab } from "./Header.svelte";
-
  import type { BaseUrl, Node, Project } from "@httpd-client";
+
  import type { BaseUrl, Node, Project } from "@http-client";

  import { onMount } from "svelte";

modified src/views/projects/Sidebar/ContextHelp.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";
  import type { Route } from "@app/lib/router/definitions";

  import { activeUnloadedRouteStore } from "@app/lib/router";
modified src/views/projects/Sidebar/ContextRepo.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Node, Project } from "@httpd-client";
+
  import type { BaseUrl, Node, Project } from "@http-client";

  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
modified src/views/projects/Source.svelte
@@ -1,9 +1,9 @@
<script lang="ts">
-
  import type { BaseUrl, Node, Project, Remote, Tree } from "@httpd-client";
+
  import type { BaseUrl, Node, Project, Remote, Tree } from "@http-client";
  import type { BlobResult } from "./router";
  import type { Route } from "@app/lib/router";

-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";

  import Button from "@app/components/Button.svelte";
  import Header from "./Source/Header.svelte";
modified src/views/projects/Source/Blob.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Blob } from "@httpd-client";
+
  import type { BaseUrl, Blob } from "@http-client";

  import { afterUpdate, onDestroy, onMount } from "svelte";
  import { toHtml } from "hast-util-to-html";
modified src/views/projects/Source/BranchSelector.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Commit, Project } from "@httpd-client";
+
  import type { BaseUrl, Commit, Project } from "@http-client";
  import type { Route } from "@app/lib/router";

  import { activeUnloadedRouteStore } from "@app/lib/router";
modified src/views/projects/Source/Header.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
-
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
+
  import type { BaseUrl, Project, Remote, Tree } from "@http-client";
  import type { Route } from "@app/lib/router";

-
  import { HttpdClient } from "@httpd-client";
+
  import { HttpdClient } from "@http-client";

  import BranchSelector from "./BranchSelector.svelte";
  import PeerSelector from "./PeerSelector.svelte";
modified src/views/projects/Source/PeerSelector.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Project, Remote } from "@httpd-client";
+
  import type { BaseUrl, Project, Remote } from "@http-client";
  import type { Route } from "@app/lib/router";

  import { closeFocused } from "@app/components/Popover.svelte";
modified src/views/projects/Source/ProjectNameHeader.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Project } from "@httpd-client";
+
  import type { BaseUrl, Project } from "@http-client";

  import { twemoji } from "@app/lib/utils";

modified src/views/projects/Source/Tree.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@httpd-client";
+
  import type { BaseUrl, Tree } from "@http-client";

  import { createEventDispatcher } from "svelte";

modified src/views/projects/Source/Tree/Folder.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@httpd-client";
+
  import type { BaseUrl, Tree } from "@http-client";

  import { createEventDispatcher } from "svelte";

modified src/views/projects/components/CommitButton.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl, Commit } from "@httpd-client";
+
  import type { BaseUrl, Commit } from "@http-client";

  import Button from "@app/components/Button.svelte";
  import Link from "@app/components/Link.svelte";
modified src/views/projects/components/CommitLink.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
+
  import type { BaseUrl } from "@http-client";

  import { formatCommit } from "@app/lib/utils";
  import Link from "@app/components/Link.svelte";
modified src/views/projects/error.ts
@@ -2,7 +2,7 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import type { ProjectRoute } from "@app/views/projects/router";

import { baseUrlToString, isLocal } from "@app/lib/utils";
-
import { ResponseParseError, ResponseError } from "@httpd-client/lib/fetcher";
+
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
import { httpdStore } from "@app/lib/httpd";
import { get } from "svelte/store";

modified src/views/projects/router.ts
@@ -19,12 +19,12 @@ import type {
  Project,
  Remote,
  Tree,
-
} from "@httpd-client";
+
} from "@http-client";

import * as Syntax from "@app/lib/syntax";
import * as httpd from "@app/lib/httpd";
-
import { HttpdClient } from "@httpd-client";
-
import { ResponseError, ResponseParseError } from "@httpd-client/lib/fetcher";
+
import { HttpdClient } from "@http-client";
+
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { experimental } from "@app/lib/appearance";
import { get } from "svelte/store";
import { handleError, unreachableError } from "@app/views/projects/error";
modified tests/support/peerManager.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
-
import type { BaseUrl } from "@httpd-client";
+
import type { BaseUrl } from "@http-client";
import type * as Execa from "execa";
import { execa } from "execa";
import * as Fs from "node:fs/promises";
modified tsconfig.json
@@ -1,7 +1,7 @@
{
  "extends": "@tsconfig/svelte/tsconfig.json",
-
  "include": ["src", "tests", "httpd-client", "./*.js", "./*.ts"],
-
  "exclude": ["node_modules/*"],
+
  "include": ["src", "tests", "http-client", "./*.js", "./*.ts"],
+
  "exclude": ["node_modules/*", "http-server/*"],
  "compilerOptions": {
    "noEmit": true,
    "target": "es2020",
@@ -20,8 +20,8 @@
    "skipLibCheck": true,
    "paths": {
      "@app/*": ["./src/*"],
-
      "@httpd-client": ["./httpd-client/index.ts"],
-
      "@httpd-client/*": ["./httpd-client/*"],
+
      "@http-client": ["./http-client/index.ts"],
+
      "@http-client/*": ["./http-client/*"],
      "@public/*": ["./public/*"],
      "@tests/*": ["./tests/*"]
    }
modified vite.config.ts
@@ -39,7 +39,7 @@ export default defineConfig({
    alias: {
      "@app": path.resolve("./src"),
      "@public": path.resolve("./public"),
-
      "@httpd-client": path.resolve("./httpd-client"),
+
      "@http-client": path.resolve("./http-client"),
      "@tests": path.resolve("./tests"),
    },
  },