Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ assigned at release.

## Unreleased

- Added: `SELFHOST_DESKTOP_RELEASES_REPO` (owner/repository, unset by default)
and `GET /api/v1/desktop/releases`: the latest release of a public GitHub
releases repo, in the downloads card's manifest shape, with checksums from
each asset's `.sha256` sidecar. Fixed GitHub hosts only, bounded, cached 5
minutes, `release: null` on any upstream failure. For installs without the
signed Desktop feed. See docs/desktop-feed.md, "GitHub releases source".

- Added: events shim, a webhook inbox that wakes an agent, behind a new
`events_shim` feature (off by default). `POST /api/v1/events-shim/shims`
(signed-in person, 20 per person per hour) returns an MCP URL, webhook URLs
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ is explicit. History is served only by the sessions pipeline: see
| `SELFHOST_PERSONAL_ORG_ON_SIGNIN` | Multi mode only, default `true`: a first sign-in with no invite, membership or assigned domain creates a personal organization. The bootstrap bearer can override it at runtime via `PATCH /api/v1/installation/settings` |
| `SELFHOST_SELF_SERVE_ORGANIZATIONS` | Multi mode only, default `true`: signed-in people may create organizations. Overridable the same way |
| `SELFHOST_INVITE_TTL_HOURS` | Invite link lifetime, default 168 (7 days), 1-720 |
| `SELFHOST_DESKTOP_RELEASES_REPO` | Optional `owner/repository` of a public GitHub releases repo the downloads card shows when the signed Desktop feed has nothing. Unset: `GET /api/v1/desktop/releases` is 404. See [docs/desktop-feed.md](docs/desktop-feed.md#github-releases-source) |
| `SELFHOST_FEATURES` | Comma list of optional components to serve: `flows`, `relayfile`, `chief`, `usage`, `launch`, `usage_people`, `events_shim`. Unset selects `usage,usage_people`; `""` selects nothing. Unselected routes answer 403 `feature_not_enabled` (`events_shim` routes answer 404). See [docs/features.md](docs/features.md) |
| `SELFHOST_EVENTS_SHIM_RELAY_ORIGINS` | Optional comma list of https origins (no path) an events-shim inbox may forward wake posts to (`link_relay_wake`). Default: the `RELAYCAST_PUBLIC_URL` origin. Set it, e.g. to `https://cast.agentrelay.com`, when your agents' Agent Relay uses another Relaycast. See [docs/events-shim.md](docs/events-shim.md) |
| `SELFHOST_FACTORY_ENABLED` | Optional `true`/`false`, default `false`. Factory sessions: when off every Factory route is 404 and the worker does not start. See [docs/factory-sessions.md](docs/factory-sessions.md) |
Expand Down
33 changes: 33 additions & 0 deletions docs/desktop-feed.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,3 +767,36 @@ endpoints now exist (see "Implementation"); the remaining items still hold.
I-JSON rule (it states it in prose only), the check precedence, the quota arithmetic or the Range arithmetic. Those are in this
document and in the reference functions in
`test/desktop-feed-contract.test.ts`.

## GitHub releases source

An install without the signed feed (for example a local Kind stack whose
Desktop is published to a public GitHub releases repository) can still show
downloads. Set `SELFHOST_DESKTOP_RELEASES_REPO=<owner>/<repository>`
(`^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$`). Unset, `GET /api/v1/desktop/releases`
answers 404 and nothing changes.

`GET /api/v1/desktop/releases` (any signed-in member) returns
`{ source: { kind: "github", repo, releasesUrl }, release }`. `release` is the
newest non-draft release (pre-releases included) in the downloads card's
manifest shape (`version`, `tag`, `prerelease`, `published_at`, `assets`), or
`null` when there is none or GitHub cannot be read. Each asset carries the
card's `platform`, `arch`, `kind` and macOS `os_floor`, parsed from
`Desktop-<profile>-<version>-<OS>-<arch>[-probe|-Setup].<ext>`; anything else is
skipped.

- Control calls only `https://api.github.com/repos/<owner>/<repo>/releases`:
the release list, and each checksum sidecar through its API asset URL, whose
single redirect may go only to a `*.githubusercontent.com` host. No token.
The list is capped at 2 MiB, a sidecar at 1 KiB, the whole read at 8
seconds; the result (also a failure) is cached for 5 minutes.
- An asset is served only with a `<name>.sha256` sidecar from the same release
whose digest (and file name, when present) matches; if GitHub reports its
own `sha256:` digest for the asset, it must agree.
- `url` is the asset's `browser_download_url`, kept only when it is
`https://github.com` or `https://objects.githubusercontent.com` under
`/<owner>/<repo>/releases/download/`.

The checksums give integrity, not publisher authenticity: they come from the
same release. The dashboard labels this source as GitHub releases, not the
signed Desktop feed, and uses it only when the signed feed has no profiles.
14 changes: 14 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,20 @@ paths:
responses:
"200": { description: "`{ events: [{ viewerUserId, viewerName, subjectUserId, subjectName, days, page, createdAt }] }`" }
"403": { description: "`forbidden` or `feature_not_enabled`" }
/api/v1/desktop/releases:
get:
operationId: getDesktopGitHubReleases
security: [{ bearerAuth: [] }, { cookieAuth: [] }]
description: |
The latest release of the public GitHub releases repo in
SELFHOST_DESKTOP_RELEASES_REPO, in the downloads card's manifest shape,
with sidecar checksums (docs/desktop-feed.md, "GitHub releases
source"). Any signed-in member. 404 when the setting is unset.
`release` is null when there is none or GitHub cannot be read.
responses:
"200": { description: "`{ source: { kind: github, repo, releasesUrl }, release: null | { version, tag, prerelease, published_at, assets[] } }`" }
"401": { description: Missing, invalid, or expired user token }
"404": { description: No releases repo configured }
/api/v1/analytics/events:
post:
operationId: captureClientAnalyticsEvents
Expand Down
16 changes: 16 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { FlowRunRelayfileCredentials } from "./flows/relayfile-credentials.js";
import { handleSessionRoutes } from "./sessions/routes.js";
import { UsageRangeError, usageDays, usageReport } from "./usage/routes.js";
import { UsageStore } from "./usage/store.js";
import { GitHubDesktopReleases } from "./desktop-releases.js";
import { listReadAudit, parseReadAuditQuery, readAuditCsv, ReadAuditQueryError } from "./mcp/read-audit.js";
import {
PeopleInputError, PersonNotFoundError, peopleAudit, peopleReport, personSessions, recordPeopleView, UsagePeopleStore,
Expand Down Expand Up @@ -403,6 +404,8 @@ export function createApp(input: {
factory?: FactoryModule;
/** Internal dormant Capy send resolver; server wiring requires a complete runtime. */
capy?: McpContext["capy"];
/** Test hook: the GitHub fetch behind GET /api/v1/desktop/releases. */
desktopReleasesFetch?: typeof fetch;
/** Desktop feed overrides for tests; the feed itself is on only when config.desktopFeed is set. */
desktopFeed?: {
signer?: DesktopFeedSigner;
Expand All @@ -427,6 +430,9 @@ export function createApp(input: {
const features = input.features ?? NO_FEATURES;
// Usage page counters: written only while the `usage` feature is selected.
const usage = new UsageStore(store.database, () => input.now?.() ?? new Date());
const desktopReleases = config.desktopReleasesRepo
? new GitHubDesktopReleases(config.desktopReleasesRepo, input.desktopReleasesFetch ?? globalThis.fetch)
: null;
const people = new UsagePeopleStore(store.database, () => input.now?.() ?? new Date());
// Counters never fail or slow a request; each is written only while its feature is on.
const recordUsage = (write: () => Promise<void>): void => {
Expand Down Expand Up @@ -1839,6 +1845,16 @@ setTimeout(poll,${Math.max(1, started.intervalSeconds) * 1_000});
return;
}

// Desktop downloads from a configured public GitHub releases repo
// (docs/desktop-feed.md, "GitHub releases source"). Unset: 404 like any
// unknown path. Any signed-in member, like the downloads card.
if (path === "/api/v1/desktop/releases" && method === "GET" && desktopReleases) {
const auth = await authenticate(request);
if (auth.kind !== "user") throw new HttpError(401, "unauthorized", "User token required");
json(response, 200, await desktopReleases.latest(), true);
return;
}

// Usage page People drill-down (docs/usage.md, "People"): owner-only, per member.
// routeMatch would turn an absent optional group into "undefined".
const peopleRoute = /^\/api\/v1\/organizations\/([^/]+)\/usage\/people(?:\/(audit|[^/]+\/sessions))?$/.exec(path);
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
strictRawPublicKey,
} from "./acquisition/assertion.js";

import { loadDesktopReleasesRepo } from "./desktop-releases.js";

export type SelfhostConfig = {
databaseUrl: string;
publicBaseUrl: string;
Expand Down Expand Up @@ -81,6 +83,12 @@ export type SelfhostConfig = {
* and every feed route answers 404 like any unknown path.
*/
desktopFeed?: DesktopFeedConfig | null;
/**
* SELFHOST_DESKTOP_RELEASES_REPO (owner/repository): a public GitHub releases
* repo whose latest release the downloads card shows when the signed feed
* has nothing. Null or absent: GET /api/v1/desktop/releases answers 404.
*/
desktopReleasesRepo?: string | null;
/** Direct-to-S3 session uploads (docs/sessions-read-api.md). null: the upload-credentials route answers 404. */
historyUpload?: HistoryUploadConfig | null;
/**
Expand Down Expand Up @@ -768,6 +776,7 @@ function loadAdminConfig(env: Env, publicBaseUrl: string): {
dashboardUrl: string;
operatorOAuthApps: Record<string, OperatorOAuthApp>;
desktopFeed: DesktopFeedConfig | null;
desktopReleasesRepo: string | null;
} {
const internalServiceToken = readSecret(env, "SELFHOST_INTERNAL_TOKEN") ?? null;
if (internalServiceToken !== null && Buffer.byteLength(internalServiceToken) < 32) {
Expand All @@ -787,6 +796,7 @@ function loadAdminConfig(env: Env, publicBaseUrl: string): {
dashboardUrl: absoluteHttpUrl(env, "SELFHOST_DASHBOARD_URL", `${publicBaseUrl}/`),
operatorOAuthApps: loadOperatorOAuthApps(env),
desktopFeed: loadDesktopFeedConfig(env),
desktopReleasesRepo: loadDesktopReleasesRepo(env),
};
}

Expand Down
Loading
Loading