diff --git a/.changeset/chrono-bucket-download-proxy.md b/.changeset/chrono-bucket-download-proxy.md
new file mode 100644
index 00000000..336fa74b
--- /dev/null
+++ b/.changeset/chrono-bucket-download-proxy.md
@@ -0,0 +1,6 @@
+---
+"ornn-api": minor
+"ornn-web": minor
+---
+
+Migrate skill package downloads to chrono-bucket's streaming endpoint (#1196). chrono-bucket (replacing chrono-storage) removed presigned URLs and the object-copy endpoint and now serves downloads via a streaming `GET /objects/download` behind the NyxID proxy. ornn-api's storage client gains a streaming `downloadObject()` (replacing the removed `getPresignedUrl`/`copy`); package reads for diff/json/audit go through it, and a new `GET /skills/:idOrName/versions/:version/download` route streams the ZIP through ornn-api (the route the TS SDK's `downloadPackage()` already targets). The client-facing `presignedPackageUrl` field is dropped from the skill detail response, and the web viewer now pulls packages through that authenticated ornn-api route instead of fetching chrono-bucket / MinIO directly. Also removes the wasted presigned round-trip and dead code in the audit pipeline (#995).
diff --git a/.changeset/fix-manual-cli-frontmatter-yaml.md b/.changeset/fix-manual-cli-frontmatter-yaml.md
new file mode 100644
index 00000000..ca456395
--- /dev/null
+++ b/.changeset/fix-manual-cli-frontmatter-yaml.md
@@ -0,0 +1,4 @@
+---
+---
+
+docs(skill): fix `ornn-agent-manual-cli` v1.4 frontmatter — the `description` contained an unquoted `: ` (colon-space) that broke YAML parsing on ingest/refresh, and exceeded the 1024-char cap. Reworded to remove the colon and trimmed to 993 chars; verified against the API's `yaml.parse` + `validateSkillFrontmatter`. Docs-only.
diff --git a/.changeset/gh-source-auth-drift-foundation.md b/.changeset/gh-source-auth-drift-foundation.md
new file mode 100644
index 00000000..f938c08c
--- /dev/null
+++ b/.changeset/gh-source-auth-drift-foundation.md
@@ -0,0 +1,5 @@
+---
+"ornn-api": minor
+---
+
+Add the efficient-read foundation for automatic GitHub source sync (#1175): GitHub source reads can now authenticate with a service-account token (settings section `sourceSync` or `ORNN_SOURCE_SYNC_GITHUB_TOKEN`) and use ETag-conditional requests, a cheap `git/ref` HEAD-SHA drift probe, and a read-only `checkSourceDrift` that records drift state on the skill. No behavior change to existing flows — the manual refresh path simply sends an `Authorization` header when a token is configured.
diff --git a/.changeset/gh-source-auto-publish.md b/.changeset/gh-source-auto-publish.md
new file mode 100644
index 00000000..8d9ebd8e
--- /dev/null
+++ b/.changeset/gh-source-auto-publish.md
@@ -0,0 +1,5 @@
+---
+"ornn-api": minor
+---
+
+Add unattended auto-publish for drifted GitHub-sourced skills (#1177): when the `sourceSync.autoPublish` switch is on, the scheduled drift check now automatically re-pulls and publishes a new version of any skill whose upstream moved — no manual trigger. The auto path runs the same validation as a manual refresh, refuses to publish when the `SKILL.md` version wasn't bumped (surfacing "changed but not versioned" instead of clobbering the immutable version), records the new version under a dedicated system actor, and notifies the owner on both success and refusal. Ships off by default.
diff --git a/.changeset/gh-source-auto-sync-ui.md b/.changeset/gh-source-auto-sync-ui.md
new file mode 100644
index 00000000..d958bd99
--- /dev/null
+++ b/.changeset/gh-source-auto-sync-ui.md
@@ -0,0 +1,5 @@
+---
+"ornn-web": minor
+---
+
+Surface automatic GitHub source-sync status in the UI (#1178): the skill detail page now shows a passive badge driven by the source's drift state — "Auto-synced", "Update in progress", "Upstream changed — version not bumped" (warning), or "Source unavailable" (error) — next to the existing "Synced from GitHub" chip and in the advanced GitHub-link panel, so owners see what auto-sync did without polling. The `skill.auto_synced` / `skill.auto_sync_failed` / `skill.source_broken` notifications render with proper labels, and opening a skill opportunistically refreshes a stale drift state once (no polling loop). The GET skill response now includes the drift fields that drive this (ornn-api).
diff --git a/.changeset/gh-source-drift-scheduler.md b/.changeset/gh-source-drift-scheduler.md
new file mode 100644
index 00000000..86187116
--- /dev/null
+++ b/.changeset/gh-source-drift-scheduler.md
@@ -0,0 +1,5 @@
+---
+"ornn-api": minor
+---
+
+Add the scheduled GitHub source drift-check job (#1176): a multi-pod-safe Agenda scheduler periodically probes every GitHub-sourced skill's upstream (coalescing skills that share a repo/ref into one request), records whether it has drifted, and notifies the owner when a source repo becomes unreachable. Cadence is driven by the `sourceSync.pollSchedule` setting; the job honors GitHub rate limits and never lets one skill's failure abort the run. It only records drift state — automatic re-publish is a later change.
diff --git a/.changeset/ornn-manual-cli-skillset-refresh.md b/.changeset/ornn-manual-cli-skillset-refresh.md
new file mode 100644
index 00000000..dfcac21a
--- /dev/null
+++ b/.changeset/ornn-manual-cli-skillset-refresh.md
@@ -0,0 +1,10 @@
+---
+---
+
+docs(skill): refresh the `ornn-agent-manual-cli` skill to v1.4.
+
+- Trigger-oriented frontmatter `description` so Claude Code reliably auto-invokes the skill (covers skills **and** skillsets, plugin export, ownership transfer, etc.).
+- Full skillset lifecycle across `SKILL.md` §2.16 and `api-reference.md` §5a: remove the phantom `PUT /skillsets/:id/permissions`, add plugin-export + transfer-ownership, document derived visibility, auto-revision, the real response/closure shapes, and the error-code table.
+- Broader api-reference drift fixed against current `ornn-api`: skill transfer-ownership + dist-tags, assistant SSE, manifest JSON Schema, public/admin `github/repo` mirror coords, launch-promo, the per-model `assistant` surface flags, and the phantom `ornn:quota:admin` scope (real scope is `ornn:admin:skill`).
+
+Docs-only: no `ornn-api` / `ornn-web` runtime change (the skill is registry-sourced, not bundled into either package image). The skill's own registry version bumps 1.3 → 1.4; publishing the refreshed content to the live registry is a separate operational step.
diff --git a/.changeset/raise-skill-description-cap.md b/.changeset/raise-skill-description-cap.md
new file mode 100644
index 00000000..d5558fb6
--- /dev/null
+++ b/.changeset/raise-skill-description-cap.md
@@ -0,0 +1,6 @@
+---
+"ornn-api": patch
+"ornn-web": patch
+---
+
+Raise the skill frontmatter `description` cap from 1024 to 1536 characters, aligned with Claude Code's skill-listing truncation limit (`skillListingMaxDescChars`, default 1536) — so an author can write a description as rich as the runtime actually uses for auto-invocation routing, instead of relying on `skip_validation` to smuggle a longer one past the schema. Skillset descriptions are unchanged (still 1024).
diff --git a/.changeset/release-notes-20260707.md b/.changeset/release-notes-20260707.md
new file mode 100644
index 00000000..a845151c
--- /dev/null
+++ b/.changeset/release-notes-20260707.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.github/release-notes-20260707.md b/.github/release-notes-20260707.md
new file mode 100644
index 00000000..139653f6
--- /dev/null
+++ b/.github/release-notes-20260707.md
@@ -0,0 +1,14 @@
+## Fixed
+
+- Few technical bugs fixed.
+
+## New Feature
+
+- GitHub-sourced skills detect upstream changes and can auto-publish.
+- Skill detail page shows GitHub source sync status.
+- Technical enhancement.
+
+## Changed
+
+- Skill package downloads move to a streaming API route.
+- Skill description limit raised from 1024 to 1536 characters.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5fa022e1..98e65e4c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -46,6 +46,8 @@ so dashboards can disambiguate from frontend events of the same name):
| `skill.visibility_changed` / `.permissions_changed` | visibility + sharing flips | `skillId`, `isPrivate`, `sharedWithUsers`, `sharedWithOrgs`, `writeGrants` (count of `write` grants, #1123) |
| `skill.ownership_transferred` | ownership handed to another user (#1123) | `skillId`, `skillName`, `priorOwnerId`, `newOwnerId` |
| `skill.refresh` / `.source_linked` / `.source_unlinked` | source-pointer ops | `skillId`, `repo`, `ref`, `commit` |
+| `skill.source_drift_detected` | scheduled drift check found upstream moved (#1176/#1177) | `skillId`, `repo`, `ref`, `upstreamHeadSha` |
+| `skill.auto_synced` / `.auto_sync_failed` | unattended auto-publish outcome (#1177) | `skillId`, `fromVersion`, `toVersion` / `reason` |
| `skill.nyxid_service_tied` / `.agentseal_rescanned` | tie + admin-rescan | `skillId`, `isSystemSkill`, `score` |
| `settings.exported` / `.imported` | settings IO | `schemaVersion`, `aggregateStatus`, `dryRun`, `sections` |
diff --git a/ornn-api/src/bootstrap.ts b/ornn-api/src/bootstrap.ts
index 48efec60..a7c7a01d 100644
--- a/ornn-api/src/bootstrap.ts
+++ b/ornn-api/src/bootstrap.ts
@@ -109,6 +109,11 @@ import {
createMirrorScheduler,
type MirrorScheduler,
} from "./domains/skills/mirror/scheduler";
+import {
+ createSourceSyncScheduler,
+ type SourceSyncScheduler,
+} from "./domains/skills/crud/sourceSyncScheduler";
+import { runSourceDriftJob } from "./domains/skills/crud/sourceDriftJob";
// Domain: Me (caller-scoped endpoints)
import { createMeRoutes } from "./domains/me/routes";
@@ -487,6 +492,10 @@ export async function bootstrap(
maxEntryUncompressedBytes: config.maxEntryUncompressedBytes,
maxPackageFileCount: config.maxPackageFileCount,
maxCompressionRatio: config.maxCompressionRatio,
+ // Source-sync (#1175): authenticate GitHub source reads. The settings
+ // token wins; the env token is the fallback.
+ sourceSyncSettings: settingsService,
+ sourceSyncGithubTokenFallback: config.sourceSyncGithubToken,
});
// ---- Domain: Notifications + Broadcasts ----
@@ -544,9 +553,6 @@ export async function bootstrap(
const auditService = new AuditService({
auditRepo,
skillService,
- storageClient,
- storageBucketResolver: async () =>
- (await settingsService.getNyxid()).chronoStorageBucket,
llmClient: nyxLlmClient,
defaultsResolver: async () => resolveAuditDefaults(),
// Audits are re-run automatically when the cached record ages past
@@ -780,6 +786,43 @@ export async function bootstrap(
mirrorScheduler,
});
+ // In-process source-drift scheduler (#1176/#1177). Same multi-pod-safe
+ // Agenda pattern as the mirror scheduler; cadence driven by
+ // `settings.sourceSync.pollSchedule`. Detects when a GitHub-sourced skill's
+ // upstream moved, records drift state, and — when `sourceSync.autoPublish`
+ // is on — auto-publishes the new version. A start failure logs and leaves
+ // this pod without the scheduled scan rather than crashing boot.
+ let sourceSyncScheduler: SourceSyncScheduler | null = null;
+ try {
+ sourceSyncScheduler = createSourceSyncScheduler({
+ db,
+ logger,
+ settingsService,
+ runDriftJob: () =>
+ runSourceDriftJob({
+ skillRepo,
+ settingsService,
+ envTokenFallback: config.sourceSyncGithubToken,
+ notifier: notificationService,
+ analyticsEmitter,
+ // #1177 — unattended auto-publish of drifted skills when the
+ // sourceSync.autoPublish switch is on.
+ autoPublish: (guid) => skillService.autoPublishFromSource(guid),
+ logger,
+ // Small per-group jitter so a large catalogue spreads its probes
+ // across the tick instead of bursting one egress IP.
+ jitterMs: 250,
+ }),
+ });
+ await sourceSyncScheduler.start();
+ } catch (err) {
+ logger.error(
+ { err: err instanceof Error ? err.message : String(err) },
+ "source-sync scheduler failed to start — scheduled drift checks will not run on this pod",
+ );
+ sourceSyncScheduler = null;
+ }
+
// Skill routes — sharing is now a direct PUT /permissions write; the
// audit signal is surfaced as a per-version label, not a gate.
// #1136 — forward reference: the skill routes fire a reactive skillset
@@ -1130,9 +1173,16 @@ export async function bootstrap(
// ---- Shutdown ----
async function shutdown(): Promise {
logger.info("Shutting down ornn-api...");
- // Stop the scheduler first so no new mirror reconciles start while
- // we're tearing the Mongo connection down. `stop()` is idempotent +
- // already swallows its own errors.
+ // Stop the schedulers first so no new reconciles / drift checks start
+ // while we're tearing the Mongo connection down. `stop()` is idempotent +
+ // already swallows its own errors. Source-sync first, then mirror.
+ if (sourceSyncScheduler) {
+ try {
+ await sourceSyncScheduler.stop();
+ } catch (err) {
+ logger.warn({ err }, "Source-sync scheduler stop failed — continuing");
+ }
+ }
if (mirrorScheduler) {
try {
await mirrorScheduler.stop();
diff --git a/ornn-api/src/clients/storageClient.test.ts b/ornn-api/src/clients/storageClient.test.ts
index 3f5e97a2..e452caed 100644
--- a/ornn-api/src/clients/storageClient.test.ts
+++ b/ornn-api/src/clients/storageClient.test.ts
@@ -75,18 +75,13 @@ describe("StorageClient SSRF preflight (#811)", () => {
expect(fetchCalls).toHaveLength(0);
});
- it("getPresignedUrl() refuses a rebound host before issuing the request", async () => {
- await expect(makeClient().getPresignedUrl("b", "k")).rejects.toBeInstanceOf(
+ it("downloadObject() refuses a rebound host before issuing the request", async () => {
+ await expect(makeClient().downloadObject("b", "k")).rejects.toBeInstanceOf(
SsrfRefusalError,
);
expect(fetchCalls).toHaveLength(0);
});
- it("copy() refuses a rebound host before issuing the request", async () => {
- await expect(makeClient().copy("b", "s", "d")).rejects.toBeInstanceOf(SsrfRefusalError);
- expect(fetchCalls).toHaveLength(0);
- });
-
it("allowlisted host passes the preflight and reaches fetch", async () => {
process.env[ALLOWLIST_ENV] = "rebind.test";
const out = await makeClient().upload("b", "k", new Uint8Array([1]), "text/plain");
@@ -94,4 +89,26 @@ describe("StorageClient SSRF preflight (#811)", () => {
expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0]).toContain("http://rebind.test/api/buckets/b/objects");
});
+
+ it("downloadObject() streams raw bytes from the /objects/download endpoint", async () => {
+ process.env[ALLOWLIST_ENV] = "rebind.test";
+ // The default beforeEach stub returns a JSON envelope; downloadObject reads
+ // the body as raw bytes, so this test serves a binary Response instead.
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+ fetchCalls.push(url);
+ return new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { "Content-Type": "application/zip", "Content-Length": "3" },
+ });
+ }) as typeof fetch;
+
+ const out = await makeClient().downloadObject("b", "skills/g/1.0.zip");
+ expect(Array.from(out.bytes)).toEqual([1, 2, 3]);
+ expect(out.contentType).toBe("application/zip");
+ expect(out.contentLength).toBe(3);
+ expect(fetchCalls).toHaveLength(1);
+ expect(fetchCalls[0]).toContain("/api/buckets/b/objects/download?key=");
+ });
});
diff --git a/ornn-api/src/clients/storageClient.ts b/ornn-api/src/clients/storageClient.ts
index a2574f52..04b193df 100644
--- a/ornn-api/src/clients/storageClient.ts
+++ b/ornn-api/src/clients/storageClient.ts
@@ -11,8 +11,17 @@ const logger = createLogger("storageClient");
export interface IStorageClient {
upload(bucket: string, key: string, data: Uint8Array, contentType: string): Promise<{ url: string }>;
delete(bucket: string, key: string): Promise;
- getPresignedUrl(bucket: string, key: string, expiresIn?: number): Promise<{ presignedUrl: string; expiresAt: string }>;
- copy(bucket: string, sourceKey: string, destKey: string): Promise;
+ /**
+ * Stream an object's raw bytes from chrono-bucket's
+ * `GET /api/buckets/:bucket/objects/download?key=` endpoint. Replaces the
+ * removed presigned-URL flow (#1196): chrono-bucket no longer hands out
+ * direct S3 URLs, so every read is proxied through this client instead of a
+ * client fetching MinIO directly.
+ */
+ downloadObject(
+ bucket: string,
+ key: string,
+ ): Promise<{ bytes: Uint8Array; contentType: string; contentLength?: number }>;
}
/**
@@ -102,48 +111,33 @@ export class StorageClient implements IStorageClient {
logger.debug({ bucket, key }, "File deleted from storage");
}
- async getPresignedUrl(
+ async downloadObject(
bucket: string,
key: string,
- expiresIn?: number,
- ): Promise<{ presignedUrl: string; expiresAt: string }> {
+ ): Promise<{ bytes: Uint8Array; contentType: string; contentLength?: number }> {
const baseUrl = await this.resolveBaseUrl();
const params = new URLSearchParams({ key });
- if (expiresIn !== undefined) {
- params.set("expiresIn", String(expiresIn));
- }
- const url = `${baseUrl}/api/buckets/${bucket}/presigned-url?${params.toString()}`;
+ const url = `${baseUrl}/api/buckets/${bucket}/objects/download?${params.toString()}`;
const auth = await this.authHeaders();
const res = await safeFetch(url, { method: "GET", headers: auth });
if (!res.ok) {
const text = await res.text().catch(() => "");
- logger.error({ status: res.status, bucket, key }, "Storage presigned URL failed");
- throw new Error(`Storage presigned URL failed (${res.status}): ${text}`);
- }
-
- const json = (await res.json()) as { data: { presignedUrl: string; expiresAt: string } };
- return json.data;
- }
-
- async copy(bucket: string, sourceKey: string, destKey: string): Promise {
- const baseUrl = await this.resolveBaseUrl();
- const url = `${baseUrl}/api/buckets/${bucket}/objects/copy`;
-
- const auth = await this.authHeaders();
- const res = await safeFetch(url, {
- method: "POST",
- headers: { "Content-Type": "application/json", ...auth },
- body: JSON.stringify({ sourceKey, destKey }),
- });
-
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- logger.error({ status: res.status, bucket, sourceKey, destKey }, "Storage copy failed");
- throw new Error(`Storage copy failed (${res.status}): ${text}`);
+ logger.error({ status: res.status, bucket, key }, "Storage download failed");
+ throw new Error(`Storage download failed (${res.status}): ${text}`);
}
- logger.debug({ bucket, sourceKey, destKey }, "File copied in storage");
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
+ const lenHeader = res.headers.get("content-length");
+ const contentLength = lenHeader !== null ? Number(lenHeader) : NaN;
+ logger.debug({ bucket, key, bytes: bytes.byteLength }, "File downloaded from storage");
+
+ // exactOptionalPropertyTypes (#657): only attach contentLength when the
+ // header was present and numeric — never assign an explicit `undefined`.
+ return Number.isFinite(contentLength)
+ ? { bytes, contentType, contentLength }
+ : { bytes, contentType };
}
}
diff --git a/ornn-api/src/domains/analytics/routes.test.ts b/ornn-api/src/domains/analytics/routes.test.ts
index db9f74dc..5666d307 100644
--- a/ornn-api/src/domains/analytics/routes.test.ts
+++ b/ornn-api/src/domains/analytics/routes.test.ts
@@ -57,7 +57,6 @@ function skill(overrides: Partial = {}): SkillDetailRespons
metadata: {},
tags: [],
skillHash: "hash-1",
- presignedPackageUrl: "https://storage.test/skill.zip",
isPrivate: false,
createdBy: OWNER_ID,
createdOn: "2026-01-01T00:00:00Z",
diff --git a/ornn-api/src/domains/notifications/service.ts b/ornn-api/src/domains/notifications/service.ts
index ebc3ca4d..d43593ab 100644
--- a/ornn-api/src/domains/notifications/service.ts
+++ b/ornn-api/src/domains/notifications/service.ts
@@ -442,6 +442,79 @@ export class NotificationService {
});
}
+ /**
+ * Owner-side notification (#1176) fired when an automatic drift check finds
+ * a GitHub-sourced skill's upstream repo/ref can no longer be resolved
+ * (deleted, made private, or the branch/tag was removed). Lets the owner
+ * re-link or fix the source. Deep-links to the skill detail page.
+ */
+ async notifySourceBroken(params: {
+ ownerId: string;
+ skillGuid: string;
+ repo: string;
+ ref: string;
+ }): Promise {
+ const title = `The GitHub source for one of your skills is unavailable`;
+ const body =
+ `Ornn could not reach the upstream source (${params.repo}@${params.ref}) during an ` +
+ `automatic sync check — it may have been deleted, made private, or the branch/tag ` +
+ `was removed. Re-link the skill to a valid GitHub folder to resume automatic syncing.`;
+ await this.emit(params.ownerId, {
+ category: "skill.source_broken",
+ title,
+ body,
+ link: `/skills/${encodeURIComponent(params.skillGuid)}`,
+ data: { skillGuid: params.skillGuid, repo: params.repo, ref: params.ref },
+ });
+ }
+
+ /**
+ * Owner notification (#1177) fired when a GitHub-sourced skill was
+ * automatically re-published from upstream — so the owner knows a new
+ * version shipped without their action, and to what.
+ */
+ async notifyAutoSynced(params: {
+ ownerId: string;
+ skillGuid: string;
+ fromVersion: string;
+ toVersion: string;
+ }): Promise {
+ await this.emit(params.ownerId, {
+ category: "skill.auto_synced",
+ title: `A skill of yours auto-synced from GitHub to v${params.toVersion}`,
+ body:
+ `Ornn detected an upstream change and automatically published a new version ` +
+ `(v${params.fromVersion} → v${params.toVersion}) from the linked GitHub source.`,
+ link: `/skills/${encodeURIComponent(params.skillGuid)}`,
+ data: {
+ skillGuid: params.skillGuid,
+ fromVersion: params.fromVersion,
+ toVersion: params.toVersion,
+ },
+ });
+ }
+
+ /**
+ * Owner notification (#1177) fired when an automatic re-publish was
+ * refused — the upstream changed but the SKILL.md version wasn't bumped,
+ * or the pulled package failed validation. The owner must fix upstream.
+ */
+ async notifyAutoSyncFailed(params: {
+ ownerId: string;
+ skillGuid: string;
+ reason: string;
+ }): Promise {
+ await this.emit(params.ownerId, {
+ category: "skill.auto_sync_failed",
+ title: `Auto-sync could not publish a new version of one of your skills`,
+ body:
+ `Ornn detected an upstream change but did not publish it: ${params.reason}. ` +
+ `Your current published version is unchanged — fix the issue upstream to resume auto-syncing.`,
+ link: `/skills/${encodeURIComponent(params.skillGuid)}`,
+ data: { skillGuid: params.skillGuid, reason: params.reason },
+ });
+ }
+
private async emit(
userId: string,
payload: {
diff --git a/ornn-api/src/domains/notifications/types.ts b/ornn-api/src/domains/notifications/types.ts
index 17189e2f..833445be 100644
--- a/ornn-api/src/domains/notifications/types.ts
+++ b/ornn-api/src/domains/notifications/types.ts
@@ -35,6 +35,13 @@ export const NOTIFICATION_CATEGORIES = [
"launchPromo.codeDelivered",
// A member skill became unreadable to the skillset owner (#1136).
"skillset.member_unreadable",
+ // A GitHub-sourced skill's upstream repo/ref could not be resolved during
+ // an automatic drift check — the source is broken (404/private/deleted) (#1176).
+ "skill.source_broken",
+ // A GitHub-sourced skill was automatically re-published from upstream (#1177).
+ "skill.auto_synced",
+ // An automatic re-publish was refused (version not bumped / validation failed) (#1177).
+ "skill.auto_sync_failed",
] as const;
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
diff --git a/ornn-api/src/domains/settings/exportImport/exporter.test.ts b/ornn-api/src/domains/settings/exportImport/exporter.test.ts
index 11837185..873a1cd3 100644
--- a/ornn-api/src/domains/settings/exportImport/exporter.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/exporter.test.ts
@@ -92,6 +92,7 @@ function fakeSettingsService(): SettingsService {
listLlmProviders: async () => providers,
getLlmProvider: async (id: string) => providers.find((p) => p._id === id) ?? null,
getLaunchPromo: () => make("launchPromo"),
+ getSourceSync: () => make("sourceSync"),
invalidateCache: () => {},
};
}
diff --git a/ornn-api/src/domains/settings/exportImport/importer.test.ts b/ornn-api/src/domains/settings/exportImport/importer.test.ts
index 69085235..1be1c8c2 100644
--- a/ornn-api/src/domains/settings/exportImport/importer.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/importer.test.ts
@@ -49,6 +49,7 @@ function fakeSettingsService(initial?: Partial [],
getLlmProvider: async () => null,
getLaunchPromo: async () => store.get("launchPromo") as never,
+ getSourceSync: async () => store.get("sourceSync") as never,
invalidateCache: () => {},
};
return Object.assign(svc, {
diff --git a/ornn-api/src/domains/settings/exportImport/routes.test.ts b/ornn-api/src/domains/settings/exportImport/routes.test.ts
index adacc5b9..b3e0ef3b 100644
--- a/ornn-api/src/domains/settings/exportImport/routes.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/routes.test.ts
@@ -43,6 +43,7 @@ function fakeSettingsService(): SettingsService {
listLlmProviders: async () => [],
getLlmProvider: async () => null,
getLaunchPromo: async () => store.get("launchPromo") as never,
+ getSourceSync: async () => store.get("sourceSync") as never,
invalidateCache: () => {},
};
}
diff --git a/ornn-api/src/domains/settings/sections/cronSchedule.ts b/ornn-api/src/domains/settings/sections/cronSchedule.ts
new file mode 100644
index 00000000..2ca97ff6
--- /dev/null
+++ b/ornn-api/src/domains/settings/sections/cronSchedule.ts
@@ -0,0 +1,34 @@
+/**
+ * Shared cron-expression Zod validator for settings sections.
+ *
+ * Extracted from `sections/mirror.ts` so multiple scheduled-work sections
+ * (mirror reconcile, source-sync poll, …) validate their cron field the
+ * same way instead of copy-pasting the refine.
+ *
+ * An empty string means "disabled" and is always accepted. A non-empty
+ * value must parse under `cron-parser` (which accepts both 5-field UNIX
+ * and 6-field with-seconds forms — either is fine for our schedulers).
+ *
+ * @module domains/settings/sections/cronSchedule
+ */
+import { z } from "zod";
+import { CronExpressionParser } from "cron-parser";
+
+export const cronSchedule = z
+ .string()
+ .refine(
+ (s) => {
+ if (s.length === 0) return true;
+ try {
+ CronExpressionParser.parse(s);
+ return true;
+ } catch {
+ // Intentional silent (#579): the false return becomes a Zod
+ // validation error with the message below — that's the
+ // user-facing signal. Logging the cron-parser exception would
+ // be noisy on every form-validation typo.
+ return false;
+ }
+ },
+ { message: "must be a valid cron expression or empty (disabled)" },
+ );
diff --git a/ornn-api/src/domains/settings/sections/index.ts b/ornn-api/src/domains/settings/sections/index.ts
index 0a3b976c..b614a8fc 100644
--- a/ornn-api/src/domains/settings/sections/index.ts
+++ b/ornn-api/src/domains/settings/sections/index.ts
@@ -18,6 +18,7 @@ import { skillGenSection, type SkillGenSection } from "./skillGen";
import { telemetrySection, type TelemetrySection } from "./telemetry";
import { extrasSection, type ExtrasSection } from "./extras";
import { launchPromoSection, type LaunchPromoSection } from "./launchPromo";
+import { sourceSyncSection, type SourceSyncSection } from "./sourceSync";
export {
assistantSection,
@@ -29,6 +30,7 @@ export {
telemetrySection,
extrasSection,
launchPromoSection,
+ sourceSyncSection,
};
export type {
@@ -41,6 +43,7 @@ export type {
TelemetrySection,
ExtrasSection,
LaunchPromoSection,
+ SourceSyncSection,
};
export type SectionId =
@@ -52,7 +55,8 @@ export type SectionId =
| "skillAudit"
| "telemetry"
| "extras"
- | "launchPromo";
+ | "launchPromo"
+ | "sourceSync";
export interface SectionMeta {
/** Stable section id, also the Mongo `_id` of the section row. */
@@ -77,4 +81,5 @@ export const sections = {
telemetry: telemetrySection,
extras: extrasSection,
launchPromo: launchPromoSection,
+ sourceSync: sourceSyncSection,
} as const;
diff --git a/ornn-api/src/domains/settings/sections/mirror.ts b/ornn-api/src/domains/settings/sections/mirror.ts
index 386c5f1e..82200b5c 100644
--- a/ornn-api/src/domains/settings/sections/mirror.ts
+++ b/ornn-api/src/domains/settings/sections/mirror.ts
@@ -14,35 +14,10 @@
* @module domains/settings/sections/mirror
*/
import { z } from "zod";
-import { CronExpressionParser } from "cron-parser";
import { OWNER_RE, REPO_RE } from "../../../shared/githubNaming";
+import { cronSchedule } from "./cronSchedule";
import type { SectionMeta } from "./index";
-/**
- * Validates that the input is either an empty string (disabled) or a
- * cron expression accepted by `cron-parser`. We do NOT require a
- * 5-field UNIX cron specifically — `cron-parser` also accepts 6-field
- * forms with seconds; either is fine for our purposes.
- */
-const cronSchedule = z
- .string()
- .refine(
- (s) => {
- if (s.length === 0) return true;
- try {
- CronExpressionParser.parse(s);
- return true;
- } catch {
- // Intentional silent (#579): the false return becomes a Zod
- // validation error with the message below — that's the
- // user-facing signal. Logging the cron-parser exception would
- // be noisy on every form-validation typo.
- return false;
- }
- },
- { message: "must be a valid cron expression or empty (disabled)" },
- );
-
export const mirrorSchema = z.object({
enabled: z.boolean(),
owner: z.string().refine((v) => v === "" || OWNER_RE.test(v), {
diff --git a/ornn-api/src/domains/settings/sections/sourceSync.test.ts b/ornn-api/src/domains/settings/sections/sourceSync.test.ts
new file mode 100644
index 00000000..1ad9206a
--- /dev/null
+++ b/ornn-api/src/domains/settings/sections/sourceSync.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test";
+import {
+ sourceSyncSchema,
+ sourceSyncDefaults,
+ sourceSyncSection,
+} from "./sourceSync";
+
+describe("sourceSync section", () => {
+ test("defaults parse and ship inert (disabled, no token, no auto-publish)", () => {
+ const parsed = sourceSyncSchema.parse(sourceSyncDefaults);
+ expect(parsed.enabled).toBe(false);
+ expect(parsed.githubToken).toBe("");
+ expect(parsed.autoPublish).toBe(false);
+ expect(parsed.minCheckIntervalMinutes).toBe(60);
+ });
+
+ test("githubToken is a secret field (encrypted at rest + masked on GET)", () => {
+ expect(sourceSyncSection.secretFields).toContain("githubToken");
+ });
+
+ test("empty pollSchedule (disabled) is accepted", () => {
+ const parsed = sourceSyncSchema.parse({ ...sourceSyncDefaults, pollSchedule: "" });
+ expect(parsed.pollSchedule).toBe("");
+ });
+
+ test("a valid cron is accepted", () => {
+ const parsed = sourceSyncSchema.parse({
+ ...sourceSyncDefaults,
+ pollSchedule: "0 * * * *",
+ });
+ expect(parsed.pollSchedule).toBe("0 * * * *");
+ });
+
+ test("an invalid cron is rejected", () => {
+ expect(() =>
+ sourceSyncSchema.parse({ ...sourceSyncDefaults, pollSchedule: "not a cron" }),
+ ).toThrow();
+ });
+
+ test("minCheckIntervalMinutes must be a positive integer", () => {
+ expect(() =>
+ sourceSyncSchema.parse({ ...sourceSyncDefaults, minCheckIntervalMinutes: 0 }),
+ ).toThrow();
+ expect(() =>
+ sourceSyncSchema.parse({ ...sourceSyncDefaults, minCheckIntervalMinutes: 1.5 }),
+ ).toThrow();
+ });
+
+ test("section id + publicPath are stable", () => {
+ expect(sourceSyncSection.id).toBe("sourceSync");
+ expect(sourceSyncSection.publicPath).toBe("sourceSync");
+ });
+});
diff --git a/ornn-api/src/domains/settings/sections/sourceSync.ts b/ornn-api/src/domains/settings/sections/sourceSync.ts
new file mode 100644
index 00000000..ae20a917
--- /dev/null
+++ b/ornn-api/src/domains/settings/sections/sourceSync.ts
@@ -0,0 +1,66 @@
+/**
+ * GitHub source-sync section schema.
+ *
+ * Controls the automatic sync of GitHub-sourced skills — the poller that
+ * detects when a linked upstream repo has moved and (later phases) re-pulls
+ * it. This section (#1175) only carries the config; the scheduler (#1176)
+ * and auto-publish (#1177) consume it.
+ *
+ * `githubToken` is a service-account GitHub token used ONLY to authenticate
+ * reads of **public** repos so drift checks escape the unauthenticated
+ * 60-req/hr-per-IP ceiling (authenticated is 5,000/hr with free `304`s). It
+ * grants no access the public web doesn't already have — it exists purely to
+ * lift the rate limit. It is encrypted at rest by the SettingsService (like
+ * `mirror.appPrivateKey`), mid-masked on GET, and redacted on export. When
+ * empty here, the runtime falls back to the `ORNN_SOURCE_SYNC_GITHUB_TOKEN`
+ * env var; when both are empty the poller runs unauthenticated (rate-limited).
+ *
+ * `pollSchedule` is a cron expression interpreted in `Asia/Singapore`
+ * (matching the mirror scheduler). Empty string disables the schedule.
+ *
+ * @module domains/settings/sections/sourceSync
+ */
+import { z } from "zod";
+import { cronSchedule } from "./cronSchedule";
+import type { SectionMeta } from "./index";
+
+export const sourceSyncSchema = z.object({
+ /** Master switch — when false the poller (#1176) does nothing. */
+ enabled: z.boolean(),
+ /**
+ * Service-account GitHub token (fine-grained/classic, public-read).
+ * Encrypted at rest + masked. Empty ⇒ fall back to env, then to
+ * unauthenticated reads.
+ */
+ githubToken: z.string(),
+ /** Cron for the drift poll; "" disables. Interpreted Asia/Singapore. */
+ pollSchedule: cronSchedule,
+ /** A skill is not re-checked more often than this many minutes. */
+ minCheckIntervalMinutes: z.number().int().min(1),
+ /**
+ * Full unattended auto-publish switch. Consumed by #1177 — when true,
+ * detected drift auto-publishes a new version. Default false so the
+ * foundation ships inert.
+ */
+ autoPublish: z.boolean(),
+});
+
+export type SourceSyncSection = z.infer;
+
+export const sourceSyncDefaults: SourceSyncSection = {
+ enabled: false,
+ githubToken: "",
+ // Every 15 minutes. Applied by the (future) source-sync scheduler with
+ // `timezone: "Asia/Singapore"`, matching the mirror scheduler.
+ pollSchedule: "*/15 * * * *",
+ minCheckIntervalMinutes: 60,
+ autoPublish: false,
+};
+
+export const sourceSyncSection: SectionMeta = {
+ id: "sourceSync",
+ publicPath: "sourceSync",
+ schema: sourceSyncSchema,
+ secretFields: ["githubToken"],
+ defaults: sourceSyncDefaults,
+};
diff --git a/ornn-api/src/domains/settings/service.ts b/ornn-api/src/domains/settings/service.ts
index 935939db..b7b56446 100644
--- a/ornn-api/src/domains/settings/service.ts
+++ b/ornn-api/src/domains/settings/service.ts
@@ -4,7 +4,8 @@
*
* Responsibilities:
* • Decrypt secret fields on read (apiKey / clientSecret / password /
- * appPrivateKey / postHogApiKey) so internal callers see plaintext.
+ * appPrivateKey / postHogApiKey / githubToken) so internal callers see
+ * plaintext.
* • Encrypt secret fields on write before they hit Mongo.
* • Run the section's Zod schema before persisting (caller may also
* validate, but the service is the last line of defence).
@@ -40,6 +41,7 @@ import {
type SectionId,
type SkillAuditSection,
type SkillGenSection,
+ type SourceSyncSection,
type TelemetrySection,
} from "./sections";
import type {
@@ -122,6 +124,10 @@ export class SettingsServiceImpl implements SettingsService {
return this.getSection("launchPromo");
}
+ async getSourceSync(): Promise {
+ return this.getSection("sourceSync");
+ }
+
async getSection(id: SectionId): Promise {
const cached = this.cache.get(id);
const now = this.clock();
diff --git a/ornn-api/src/domains/settings/types.ts b/ornn-api/src/domains/settings/types.ts
index a9f47c69..1025a048 100644
--- a/ornn-api/src/domains/settings/types.ts
+++ b/ornn-api/src/domains/settings/types.ts
@@ -22,6 +22,7 @@ import type {
SectionId,
SkillAuditSection,
SkillGenSection,
+ SourceSyncSection,
TelemetrySection,
} from "./sections";
import type { LlmProvider } from "./llmProviders/types";
@@ -60,6 +61,7 @@ export interface SettingsService {
getTelemetry(): Promise;
getExtras(): Promise;
getLaunchPromo(): Promise;
+ getSourceSync(): Promise;
/**
* Read a section by id. Returns the typed payload, applying defaults
diff --git a/ornn-api/src/domains/skills/audit/routes.test.ts b/ornn-api/src/domains/skills/audit/routes.test.ts
index 57c2c9ff..9eef8864 100644
--- a/ornn-api/src/domains/skills/audit/routes.test.ts
+++ b/ornn-api/src/domains/skills/audit/routes.test.ts
@@ -60,7 +60,6 @@ function skill(overrides: Partial = {}): SkillDetailRespons
metadata: {},
tags: [],
skillHash: "hash-1",
- presignedPackageUrl: "https://storage.test/skill.zip",
isPrivate: false,
createdBy: OWNER_ID,
createdOn: "2026-01-01T00:00:00Z",
diff --git a/ornn-api/src/domains/skills/audit/service.test.ts b/ornn-api/src/domains/skills/audit/service.test.ts
index c97e1494..78741bb9 100644
--- a/ornn-api/src/domains/skills/audit/service.test.ts
+++ b/ornn-api/src/domains/skills/audit/service.test.ts
@@ -9,8 +9,9 @@
* - notification → LOCAL typed recorder (the shared mock's
* notifyAuditCompleted signature doesn't match the
* real {ownerUserId,...} call site)
- * - storage/orgs → minimal fakes
- * - globalThis.fetch → swapped to a Response wrapping a real JSZip zip
+ * - orgs → minimal fake
+ * - package bytes → FakeSkillService.getPackageBytes returns a JSZip zip
+ * (audit reads bytes through the skill service, #1196)
*
* `runAudit` finalizes in a fire-and-forget microtask; tests flush it
* with `flushFinalize()` before asserting on the recorder.
@@ -19,8 +20,6 @@
*/
import {
- afterEach,
- beforeEach,
describe,
expect,
test,
@@ -42,7 +41,6 @@ import type {
import type { SkillService } from "../crud/service";
import type { NotificationService } from "../../notifications/service";
import type { NyxidOrgsClient } from "../../../clients/nyxid/orgs";
-import type { IStorageClient } from "../../../clients/storageClient";
import type { SkillDetailResponse } from "../../../shared/types/index";
// ---- Local typed notification recorder -------------------------------
@@ -122,7 +120,6 @@ function baseSkill(overrides: Partial = {}): SkillDetailRes
metadata: { category: "devtools", runtimes: [{ runtime: "node" }] },
tags: ["alpha", "beta"],
skillHash: "hash-1",
- presignedPackageUrl: "https://storage.test/skill.zip",
isPrivate: false,
createdBy: "owner-1",
createdOn: "2026-01-01T00:00:00Z",
@@ -136,6 +133,9 @@ function baseSkill(overrides: Partial = {}): SkillDetailRes
class FakeSkillService {
getSkillCalls: Array<{ idOrName: string; version?: string | undefined }> = [];
+ getPackageBytesCalls: Array<{ idOrName: string; version?: string | undefined }> = [];
+ /** When set, getPackageBytes throws this instead of returning bytes. */
+ packageError: Error | null = null;
constructor(private skill: SkillDetailResponse) {}
setSkill(s: SkillDetailResponse) {
this.skill = s;
@@ -144,6 +144,26 @@ class FakeSkillService {
this.getSkillCalls.push({ idOrName, version });
return this.skill;
}
+ // Mirrors SkillService.getPackageBytes (#1196) — audit now reads package
+ // bytes through the skill service. Driven by the module-level `fetchPlan`
+ // (ok/status/bytes/throws) so the existing test bodies still control the
+ // download outcome; `packageError` models a resolve-time failure
+ // (e.g. skill_package_not_found).
+ async getPackageBytes(
+ idOrName: string,
+ _actor: unknown,
+ version?: string,
+ ): Promise<{ bytes: Uint8Array; name: string; version: string }> {
+ this.getPackageBytesCalls.push({ idOrName, version });
+ if (this.packageError) throw this.packageError;
+ if (fetchPlan.throws) throw new Error("network down");
+ if (!fetchPlan.ok) {
+ throw new Error(
+ `Failed to download package for key 'skills/${this.skill.guid}/${this.skill.version}.zip' (HTTP ${fetchPlan.status})`,
+ );
+ }
+ return { bytes: fetchPlan.bytes, name: this.skill.name, version: this.skill.version };
+ }
}
// ---- Fake audit repository -------------------------------------------
@@ -196,13 +216,7 @@ class FakeAuditRepo {
}
}
-// ---- Fake storage / orgs ---------------------------------------------
-
-class FakeStorageClient {
- async getPresignedUrl(): Promise<{ presignedUrl: string; expiresAt: string }> {
- return { presignedUrl: "https://storage.test/skill.zip", expiresAt: "2026-01-01T01:00:00Z" };
- }
-}
+// ---- Fake orgs -------------------------------------------------------
class FakeOrgsClient {
membersByOrg = new Map>();
@@ -238,8 +252,6 @@ function makeLlmClient(text: string): NyxLlmClient {
// ---- fetch swap ------------------------------------------------------
-const originalFetch = globalThis.fetch;
-
interface FetchPlan {
ok: boolean;
status: number;
@@ -247,6 +259,8 @@ interface FetchPlan {
throws?: boolean;
}
+// Drives FakeSkillService.getPackageBytes — audit reads package bytes through
+// the skill service now (#1196), not a direct global fetch of a presigned URL.
let fetchPlan: FetchPlan;
async function buildZip(files: Record): Promise {
@@ -257,21 +271,6 @@ async function buildZip(files: Record): Promise {
return zip.generateAsync({ type: "uint8array" });
}
-beforeEach(() => {
- const fakeFetch = async (): Promise => {
- if (fetchPlan.throws) throw new Error("network down");
- return new Response(fetchPlan.bytes as unknown as BodyInit, {
- status: fetchPlan.status,
- statusText: fetchPlan.ok ? "OK" : "Error",
- });
- };
- globalThis.fetch = fakeFetch as unknown as typeof fetch;
-});
-
-afterEach(() => {
- globalThis.fetch = originalFetch;
-});
-
// ---- helpers ---------------------------------------------------------
/**
@@ -310,8 +309,6 @@ function buildService(
const deps: AuditServiceDeps = {
auditRepo: repo as unknown as AuditServiceDeps["auditRepo"],
skillService: skillService as unknown as SkillService,
- storageClient: new FakeStorageClient() as unknown as IStorageClient,
- storageBucketResolver: async () => "skills-bucket",
llmClient: client,
defaultsResolver: async () => ({
model: "gpt-test",
@@ -529,15 +526,15 @@ describe("buildAuditContext", () => {
expect(repo.markCompletedCalls).toHaveLength(1);
});
- test("missing presignedPackageUrl marks failed with audit_package_unavailable", async () => {
+ test("missing package (no storage key) marks failed", async () => {
fetchPlan = { ok: true, status: 200, bytes: await buildZip({ "SKILL.md": "# demo" }) };
- const { service, repo } = buildService({
- skill: baseSkill({ presignedPackageUrl: "" }),
- });
+ const { service, repo, skillService } = buildService();
+ // getPackageBytes 404s when the skill has no stored package (#1196).
+ skillService.packageError = new Error("No package stored for skill 'demo-skill'");
await service.runAudit("demo-skill", { triggeredBy: "owner-1" });
await flushFinalize();
expect(repo.markFailedCalls).toHaveLength(1);
- expect(repo.markFailedCalls[0]!.errorMessage).toContain("No storage URL");
+ expect(repo.markFailedCalls[0]!.errorMessage).toContain("No package stored");
});
test("non-ok package fetch marks failed with the download-failed message", async () => {
diff --git a/ornn-api/src/domains/skills/audit/service.ts b/ornn-api/src/domains/skills/audit/service.ts
index 1145d6c7..871d1396 100644
--- a/ornn-api/src/domains/skills/audit/service.ts
+++ b/ornn-api/src/domains/skills/audit/service.ts
@@ -13,11 +13,10 @@
import { createLogger } from "../../../shared/logger";
import type { NyxLlmClient, ResponsesApiInputMessage } from "../../../clients/nyxid/llm";
-import type { IStorageClient } from "../../../clients/storageClient";
import type { NyxidOrgsClient } from "../../../clients/nyxid/orgs";
import type { SkillService } from "../crud/service";
+import { SYSTEM_ACTOR } from "../crud/authorize";
import type { NotificationService } from "../../notifications/service";
-import { AppError } from "../../../shared/types/index";
import type { AuditRepository } from "./repository";
import {
type AuditFinding,
@@ -54,19 +53,9 @@ export interface AuditLlmDefaults {
export type AuditDefaultsResolver = () => Promise;
-/**
- * Per-bucket storage resolver — the audit pipeline reads skill packages
- * from the same bucket the upload path used. Sourced from admin
- * settings (services section) so a bucket rename doesn't redeploy.
- */
-export type AuditStorageBucketResolver = () => Promise;
-
export interface AuditServiceDeps {
readonly auditRepo: AuditRepository;
readonly skillService: SkillService;
- readonly storageClient: IStorageClient;
- /** Resolves the active storage bucket from settings. */
- readonly storageBucketResolver: AuditStorageBucketResolver;
readonly llmClient: NyxLlmClient;
/** Resolves audit knobs (LLM toggle/model, AgentSeal toggle/timeout, threshold) from settings. */
readonly defaultsResolver: AuditDefaultsResolver;
@@ -87,8 +76,6 @@ export interface AuditOptions {
export class AuditService {
private readonly auditRepo: AuditRepository;
private readonly skillService: SkillService;
- private readonly storageClient: IStorageClient;
- private readonly storageBucketResolver: AuditStorageBucketResolver;
private readonly llmClient: NyxLlmClient;
private readonly defaultsResolver: AuditDefaultsResolver;
private readonly cacheTtlMs: number;
@@ -99,8 +86,6 @@ export class AuditService {
constructor(deps: AuditServiceDeps) {
this.auditRepo = deps.auditRepo;
this.skillService = deps.skillService;
- this.storageClient = deps.storageClient;
- this.storageBucketResolver = deps.storageBucketResolver;
this.llmClient = deps.llmClient;
this.defaultsResolver = deps.defaultsResolver;
this.cacheTtlMs = deps.cacheTtlMs;
@@ -344,28 +329,14 @@ export class AuditService {
private async buildAuditContext(
guid: string,
): Promise<{ filesBundle: string; metadataSummary: string }> {
- const storageBucket = await this.storageBucketResolver();
- const presigned = await this.storageClient.getPresignedUrl(storageBucket, `skills/${guid}.zip`).catch(() => null);
- // The canonical pointer is `skills/{guid}/{version}.zip`; fall back via
- // the skill doc's stored `storageKey` for migrated/legacy rows.
+ // Fetch the latest version's package bytes through the skill service, which
+ // proxies chrono-bucket's streaming download (#1196). Audit is a trusted
+ // system pipeline, so it passes SYSTEM_ACTOR to bypass the per-skill
+ // visibility gate. This replaces the old dead presigned-URL round-trip and
+ // the cast-riddled `skillDoc.presignedPackageUrl` read (#995).
+ const { bytes } = await this.skillService.getPackageBytes(guid, SYSTEM_ACTOR);
+ // Metadata/tags for the audit summary come from the skill doc (latest).
const skillDoc = await this.skillService.getSkill(guid);
- const storageKey = presigned
- ? `skills/${guid}.zip`
- : (skillDoc as unknown as { presignedPackageUrl?: string; storageKey?: string });
-
- // Prefer the skill doc's presigned URL — `getSkill` already minted one.
- const url = (skillDoc as unknown as { presignedPackageUrl?: string }).presignedPackageUrl;
- if (!url) {
- throw AppError.internalError("audit_package_unavailable", "No storage URL for skill package");
- }
- const res = await fetch(url);
- if (!res.ok) {
- throw AppError.internalError(
- "AUDIT_PACKAGE_DOWNLOAD_FAILED",
- `Failed to download package for audit (HTTP ${res.status})`,
- );
- }
- const bytes = new Uint8Array(await res.arrayBuffer());
const zip = await JSZip.loadAsync(bytes);
const allPaths = Object.keys(zip.files);
resolveZipRoot(zip, allPaths);
@@ -412,7 +383,6 @@ export class AuditService {
`tags=${(skillDoc.tags ?? []).join(",")}`,
].join("; ");
- void storageKey; // used only in legacy fallback; keep reference to satisfy lint
return { filesBundle: chunks.join("\n\n"), metadataSummary };
}
}
diff --git a/ornn-api/src/domains/skills/crud/repository.sourceDrift.test.ts b/ornn-api/src/domains/skills/crud/repository.sourceDrift.test.ts
new file mode 100644
index 00000000..56a8b983
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/repository.sourceDrift.test.ts
@@ -0,0 +1,133 @@
+/**
+ * Repository tests for the source-drift query + setter (#1176), against a
+ * real in-memory MongoDB so the `$not`-regex query, the partial index, and
+ * the dot-path `$set` are exercised for real.
+ *
+ * @module domains/skills/crud/repository.sourceDrift.test
+ */
+import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { MongoClient, type Db } from "mongodb";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import { SkillRepository } from "./repository";
+
+let mongo: MongoMemoryServer;
+let client: MongoClient;
+let db: Db;
+let repo: SkillRepository;
+
+beforeAll(async () => {
+ mongo = await MongoMemoryServer.create();
+ client = new MongoClient(mongo.getUri());
+ await client.connect();
+ db = client.db("source_drift_test");
+ repo = new SkillRepository(db);
+ await repo.ensureIndexes();
+});
+
+afterAll(async () => {
+ await client.close();
+ await mongo.stop();
+});
+
+beforeEach(async () => {
+ await db.collection("skills").deleteMany({});
+});
+
+function skillDoc(overrides: Record): Record {
+ const now = new Date();
+ return {
+ name: `s-${overrides._id}`,
+ description: "d",
+ metadata: { category: "plain" },
+ skillHash: "h",
+ storageKey: "k",
+ createdBy: "owner",
+ createdOn: now,
+ updatedBy: "owner",
+ updatedOn: now,
+ isPrivate: true,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: "1.0",
+ ...overrides,
+ };
+}
+
+async function seed(...docs: Array>): Promise {
+ await db.collection("skills").insertMany(docs.map(skillDoc) as never);
+}
+
+describe("findGithubSourcedSkills", () => {
+ test("selects due github skills only; excludes fresh, pinned, and source-less", async () => {
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
+ await seed(
+ // github, never checked → included
+ { _id: "g1", createdBy: "owner-1", source: { type: "github", repo: "a/x", ref: "main", path: "" } },
+ // github, checked long ago → included
+ {
+ _id: "g2",
+ createdBy: "owner-2",
+ source: { type: "github", repo: "a/x", ref: "main", path: "", lastCheckedAt: threeHoursAgo },
+ },
+ // github, checked just now → excluded (fresh)
+ {
+ _id: "g3",
+ createdBy: "owner-3",
+ source: { type: "github", repo: "b/y", ref: "main", path: "", lastCheckedAt: new Date() },
+ },
+ // pinned 40-hex ref → excluded (never drifts)
+ { _id: "g4", createdBy: "owner-4", source: { type: "github", repo: "c/z", ref: "a".repeat(40), path: "" } },
+ // hand-uploaded (no source) → excluded
+ { _id: "g5", createdBy: "owner-5" },
+ );
+
+ const cutoff = new Date(Date.now() - 60 * 60 * 1000); // 1h ago
+ const due = await repo.findGithubSourcedSkills({ notCheckedSince: cutoff });
+
+ expect(due.map((d) => d.guid).sort()).toEqual(["g1", "g2"]);
+ const g1 = due.find((d) => d.guid === "g1")!;
+ expect(g1.ownerId).toBe("owner-1");
+ expect(g1.source.repo).toBe("a/x");
+ expect(g1.source.ref).toBe("main");
+ });
+});
+
+describe("updateSourceDriftState", () => {
+ test("sets only drift dot-paths; preserves lastSyncedCommit and does NOT bump updatedOn", async () => {
+ const created = new Date("2026-06-01T00:00:00.000Z");
+ await seed({
+ _id: "g1",
+ updatedOn: created,
+ updatedBy: "orig",
+ source: { type: "github", repo: "a/x", ref: "main", path: "", lastSyncedCommit: "keepme" },
+ });
+
+ const checkedAt = new Date("2026-07-01T08:00:00.000Z");
+ await repo.updateSourceDriftState("g1", {
+ driftState: "drifted",
+ upstreamHeadSha: "newsha",
+ etag: 'W/"e1"',
+ lastCheckedAt: checkedAt,
+ });
+
+ const doc = await repo.findByGuid("g1");
+ expect(doc).not.toBeNull();
+ const src = doc!.source!;
+ expect(src.driftState).toBe("drifted");
+ expect(src.upstreamHeadSha).toBe("newsha");
+ expect(src.etag).toBe('W/"e1"');
+ expect(src.lastCheckedAt?.toISOString()).toBe(checkedAt.toISOString());
+ // Load-bearing: the refresh-owned field is untouched, and a background
+ // drift check must not disturb sort-by-updated ordering.
+ expect(src.lastSyncedCommit).toBe("keepme");
+ expect(doc!.updatedOn.toISOString()).toBe(created.toISOString());
+ expect(doc!.updatedBy).toBe("orig");
+ });
+
+ test("is a no-op on a skill with no source", async () => {
+ await seed({ _id: "g1" }); // no source
+ await repo.updateSourceDriftState("g1", { driftState: "broken", lastCheckedAt: new Date() });
+ const doc = await repo.findByGuid("g1");
+ expect(doc!.source).toBeUndefined();
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/repository.ts b/ornn-api/src/domains/skills/crud/repository.ts
index a5836311..fa457e05 100644
--- a/ornn-api/src/domains/skills/crud/repository.ts
+++ b/ornn-api/src/domains/skills/crud/repository.ts
@@ -4,7 +4,13 @@
*/
import type { Collection, Db, Document } from "mongodb";
-import type { SkillDocument, SkillGrant, SkillMetadata } from "../../../shared/types/index";
+import type {
+ SkillDocument,
+ SkillGrant,
+ SkillMetadata,
+ SkillSource,
+ SkillSourceDriftState,
+} from "../../../shared/types/index";
import { AppError } from "../../../shared/types/index";
import { createLogger } from "../../../shared/logger";
import { coerceStoredGrants, legacyListsFromGrants } from "./grants";
@@ -165,6 +171,13 @@ export class SkillRepository {
this.collection.createIndex({ createdBy: 1, createdOn: -1 }),
this.collection.createIndex({ createdOn: -1 }),
this.collection.createIndex({ isPrivate: 1, createdOn: -1 }),
+ // Source-drift scan (#1176): partial index over github-sourced skills
+ // only, ordered by last-checked so the scheduler's "due for a check"
+ // query stays cheap as the catalogue grows.
+ this.collection.createIndex(
+ { "source.lastCheckedAt": 1 },
+ { partialFilterExpression: { "source.type": "github" } },
+ ),
]);
}
@@ -349,6 +362,79 @@ export class SkillRepository {
return this.findByGuid(guid);
}
+ /**
+ * Persist the drift-detection fields on a skill's `source` (#1175). Only
+ * touches `source.*` drift keys via dot-paths — it never rewrites the
+ * whole `source` object (so it can't race with a concurrent refresh that
+ * updated `lastSyncedCommit`) and never touches the package or version
+ * history. A no-op when the skill has no `source`.
+ *
+ * `updatedOn`/`updatedBy` are deliberately NOT bumped: a drift check is a
+ * background read, not a user edit, and must not disturb sort-by-updated
+ * ordering.
+ */
+ async updateSourceDriftState(
+ guid: string,
+ patch: {
+ driftState?: SkillSourceDriftState;
+ upstreamHeadSha?: string;
+ etag?: string;
+ lastCheckedAt?: Date;
+ },
+ ): Promise {
+ const setFields: Record = {};
+ if (patch.driftState !== undefined) setFields["source.driftState"] = patch.driftState;
+ if (patch.upstreamHeadSha !== undefined)
+ setFields["source.upstreamHeadSha"] = patch.upstreamHeadSha;
+ if (patch.etag !== undefined) setFields["source.etag"] = patch.etag;
+ if (patch.lastCheckedAt !== undefined)
+ setFields["source.lastCheckedAt"] = patch.lastCheckedAt;
+ if (Object.keys(setFields).length === 0) return;
+
+ await this.collection.updateOne(
+ { _id: skillId(guid), source: { $exists: true } },
+ { $set: setFields },
+ );
+ }
+
+ /**
+ * Enumerate GitHub-sourced skills due for a drift check (#1176). Selects
+ * skills whose source is github, whose `ref` is NOT a pinned 40-hex SHA
+ * (those can never drift), and which were either never checked or last
+ * checked before `notCheckedSince`. Returns light `{ guid, source }`
+ * projections — the scheduler coalesces them by `(repo, ref)`.
+ */
+ async findGithubSourcedSkills(opts: {
+ notCheckedSince: Date;
+ }): Promise> {
+ const docs = await this.collection
+ .find(
+ {
+ "source.type": "github",
+ // Exclude pinned commit SHAs — a 40-hex ref never moves.
+ "source.ref": { $not: /^[0-9a-f]{40}$/i },
+ $or: [
+ { "source.lastCheckedAt": { $exists: false } },
+ { "source.lastCheckedAt": { $lt: opts.notCheckedSince } },
+ ],
+ },
+ { projection: { source: 1, createdBy: 1 } },
+ )
+ .toArray();
+ const out: Array<{ guid: string; source: SkillSource; ownerId: string }> = [];
+ for (const doc of docs) {
+ const source = coerceSkillSource(doc.source);
+ if (source) {
+ out.push({
+ guid: String(doc._id),
+ source,
+ ownerId: typeof doc.createdBy === "string" ? doc.createdBy : "",
+ });
+ }
+ }
+ return out;
+ }
+
/**
* Set or clear a NyxID-service tie. When `data.nyxidServiceId` is `null`
* we wipe all four cached fields. Caller must have already validated
@@ -890,6 +976,44 @@ export class SkillRepository {
}
}
+/**
+ * Coerce a stored `source` sub-document into the typed `SkillSource`,
+ * omitting absent optional fields so we never fabricate an Invalid Date.
+ * Shared by `mapDoc` and `findGithubSourcedSkills` (#1176). Returns
+ * `undefined` for hand-uploaded skills (no `source`).
+ */
+function coerceSkillSource(raw: unknown): SkillSource | undefined {
+ if (!raw || typeof raw !== "object") return undefined;
+ const s = raw as Record;
+ return {
+ type: "github",
+ repo: String(s.repo ?? ""),
+ ref: String(s.ref ?? ""),
+ path: String(s.path ?? ""),
+ ...(s.lastSyncedAt instanceof Date
+ ? { lastSyncedAt: s.lastSyncedAt }
+ : s.lastSyncedAt != null
+ ? { lastSyncedAt: new Date(s.lastSyncedAt as string | number) }
+ : {}),
+ ...(typeof s.lastSyncedCommit === "string" && s.lastSyncedCommit
+ ? { lastSyncedCommit: s.lastSyncedCommit }
+ : {}),
+ // Drift-detection fields (#1175). Absent until the first drift check.
+ ...(typeof s.upstreamHeadSha === "string" && s.upstreamHeadSha
+ ? { upstreamHeadSha: s.upstreamHeadSha }
+ : {}),
+ ...(typeof s.etag === "string" && s.etag ? { etag: s.etag } : {}),
+ ...(s.lastCheckedAt instanceof Date
+ ? { lastCheckedAt: s.lastCheckedAt }
+ : s.lastCheckedAt != null
+ ? { lastCheckedAt: new Date(s.lastCheckedAt as string | number) }
+ : {}),
+ ...(typeof s.driftState === "string"
+ ? { driftState: s.driftState as SkillSourceDriftState }
+ : {}),
+ };
+}
+
function mapDoc(doc: Document | null): SkillDocument | null {
if (!doc) return null;
return {
@@ -919,26 +1043,7 @@ function mapDoc(doc: Document | null): SkillDocument | null {
// to fall back to read-grants derived from the legacy lists.
grants: coerceStoredGrants(doc.grants),
latestVersion: doc.latestVersion ?? "0.1",
- source: doc.source
- ? {
- type: "github",
- repo: String(doc.source.repo ?? ""),
- ref: String(doc.source.ref ?? ""),
- path: String(doc.source.path ?? ""),
- // Both fields are optional — when the user attached a GitHub
- // link via PUT /skills/:id/source without an immediate sync,
- // they're absent from the doc. Don't fabricate an Invalid
- // Date by feeding `undefined` to the Date constructor.
- ...(doc.source.lastSyncedAt instanceof Date
- ? { lastSyncedAt: doc.source.lastSyncedAt }
- : doc.source.lastSyncedAt != null
- ? { lastSyncedAt: new Date(doc.source.lastSyncedAt) }
- : {}),
- ...(typeof doc.source.lastSyncedCommit === "string" && doc.source.lastSyncedCommit
- ? { lastSyncedCommit: doc.source.lastSyncedCommit }
- : {}),
- }
- : undefined,
+ source: coerceSkillSource(doc.source),
nyxidServiceId: typeof doc.nyxidServiceId === "string" ? doc.nyxidServiceId : null,
nyxidServiceSlug: typeof doc.nyxidServiceSlug === "string" ? doc.nyxidServiceSlug : null,
nyxidServiceLabel: typeof doc.nyxidServiceLabel === "string" ? doc.nyxidServiceLabel : null,
diff --git a/ornn-api/src/domains/skills/crud/routes.test.ts b/ornn-api/src/domains/skills/crud/routes.test.ts
index 06d67353..fafe89bf 100644
--- a/ornn-api/src/domains/skills/crud/routes.test.ts
+++ b/ornn-api/src/domains/skills/crud/routes.test.ts
@@ -52,7 +52,6 @@ function detail(overrides: Partial = {}): SkillDetailRespon
metadata: {},
tags: [],
skillHash: "hash-1",
- presignedPackageUrl: "https://storage.test/skill.zip",
isPrivate: false,
createdBy: OWNER,
createdOn: "2026-01-01T00:00:00Z",
@@ -525,6 +524,60 @@ describe("GET /skills/:idOrName/json", () => {
});
});
+// ======================================================================
+// GET /skills/:idOrName/versions/:version/download (#1196)
+// ======================================================================
+
+describe("GET /skills/:idOrName/versions/:version/download", () => {
+ const PKG = new Uint8Array([80, 75, 3, 4, 9, 8, 7]); // "PK.." + noise
+
+ test("200 streams the ZIP bytes with attachment headers", async () => {
+ const app = buildApp({
+ permissions: [READ],
+ service: {
+ getPackageBytes: async () => ({ bytes: PKG, name: "demo-skill", version: "1.0" }),
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/versions/1.0/download");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/zip");
+ expect(res.headers.get("content-disposition")).toContain('filename="demo-skill-1.0.zip"');
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(PKG);
+ });
+
+ test("404 (problem+json) when getPackageBytes denies a private/unknown skill", async () => {
+ const app = buildApp({
+ userId: "stranger",
+ permissions: [READ],
+ service: {
+ getPackageBytes: async () => {
+ // The visibility gate lives in getPackageBytes and 404s without
+ // leaking existence — same shape as GET /skills/:idOrName.
+ throw Object.assign(new Error("Skill 'demo-skill' not found"), {
+ code: "skill_not_found",
+ statusCode: 404,
+ });
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/versions/1.0/download");
+ expect(res.status).toBe(404);
+ expect(((await res.json()) as { code: string }).code).toBe("skill_not_found");
+ });
+
+ test("downloads a public skill anonymously (optional auth)", async () => {
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ getPackageBytes: async () => ({ bytes: PKG, name: "demo-skill", version: "1.0" }),
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/versions/1.0/download");
+ expect(res.status).toBe(200);
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(PKG);
+ });
+});
+
// ======================================================================
// GET /skills/:idOrName/versions
// ======================================================================
diff --git a/ornn-api/src/domains/skills/crud/routes.ts b/ornn-api/src/domains/skills/crud/routes.ts
index a23f6878..f1f1dfff 100644
--- a/ornn-api/src/domains/skills/crud/routes.ts
+++ b/ornn-api/src/domains/skills/crud/routes.ts
@@ -719,6 +719,61 @@ export function createSkillRoutes(config: SkillRoutesConfig): Hono<{ Variables:
},
);
+ /**
+ * GET /skills/:idOrName/versions/:version/download — Stream the raw ZIP
+ * package for a specific version, proxied from chrono-bucket (#1196).
+ *
+ * Replaces the removed presigned-URL flow: clients no longer receive a
+ * direct storage URL — the bytes are streamed through ornn-api so the
+ * storage backend (chrono-bucket / MinIO) is never exposed. `version` may
+ * be a literal (e.g. `1.2`) or a dist-tag (e.g. `latest`).
+ *
+ * Visibility rules match GET /skills/:idOrName: anonymous callers may
+ * download public skills only; a private skill the caller cannot read 404s
+ * without leaking existence (the gate lives in `getPackageBytes`, #806).
+ *
+ * Auth: Optional. Returns `application/zip` bytes on success; errors use the
+ * RFC 7807 problem+json envelope (the TS SDK `downloadPackage()` contract).
+ * Deliberately does NOT record an analytics pull — this endpoint also backs
+ * the web file-tree viewer, and counting UI views would inflate the pull
+ * metric (programmatic reads are counted on /json).
+ */
+ app.get(
+ "/skills/:idOrName/versions/:version/download",
+ optionalAuth,
+ async (c) => {
+ const idOrName = c.req.param("idOrName");
+ const version = c.req.param("version");
+ const authCtx = c.get("auth");
+
+ // Authenticated callers get their full org/admin context; anonymous
+ // callers get a read-only actor that can see public skills only.
+ const actor = authCtx
+ ? await buildActorContext(c)
+ : {
+ userId: "",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+
+ const pkg = await skillService.getPackageBytes(idOrName, actor, version);
+
+ const safeName = pkg.name.replace(/[^A-Za-z0-9._-]/g, "_");
+ // Raw binary body — NOT the JSON envelope. Returned as a Response so the
+ // Uint8Array streams verbatim (Hono's `c.body` types exclude
+ // ArrayBufferView). Errors thrown above still flow through the RFC 7807
+ // error middleware.
+ return new Response(pkg.bytes as unknown as BodyInit, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/zip",
+ "Content-Disposition": `attachment; filename="${safeName}-${pkg.version}.zip"`,
+ },
+ });
+ },
+ );
+
/**
* GET /skills/:idOrName/closure — Resolve the full transitive
* dependency closure of a skill version (#968).
diff --git a/ornn-api/src/domains/skills/crud/service.autoPublish.test.ts b/ornn-api/src/domains/skills/crud/service.autoPublish.test.ts
new file mode 100644
index 00000000..206bbc35
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/service.autoPublish.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, test } from "bun:test";
+import { SkillService, type SkillServiceDeps } from "./service";
+import { SYSTEM_SYNC_ACTOR } from "./sourceDrift";
+import { AppError } from "../../../shared/types/index";
+import type { SkillDocument, SkillDetailResponse } from "../../../shared/types/index";
+
+function driftedSkill(overrides: Record = {}): SkillDocument {
+ return {
+ guid: "g1",
+ latestVersion: "1.0",
+ createdBy: "owner-1",
+ source: {
+ type: "github",
+ repo: "a/x",
+ ref: "main",
+ path: "",
+ lastSyncedCommit: "old",
+ upstreamHeadSha: "new",
+ driftState: "drifted",
+ },
+ ...overrides,
+ } as unknown as SkillDocument;
+}
+
+function makeService(skill: SkillDocument | null): {
+ svc: SkillService;
+ persisted: Array<{ guid: string; patch: Record }>;
+} {
+ const persisted: Array<{ guid: string; patch: Record }> = [];
+ const deps = {
+ skillRepo: {
+ findByGuid: async () => skill,
+ updateSourceDriftState: async (guid: string, patch: Record) => {
+ persisted.push({ guid, patch });
+ },
+ },
+ } as unknown as SkillServiceDeps;
+ return { svc: new SkillService(deps), persisted };
+}
+
+/** Override the instance's refreshSkillFromSource so we test autoPublish's
+ * outcome mapping without the real pull/publish chain. */
+function stubRefresh(
+ svc: SkillService,
+ impl: (...args: unknown[]) => Promise,
+): { calls: unknown[][] } {
+ const calls: unknown[][] = [];
+ (svc as unknown as { refreshSkillFromSource: unknown }).refreshSkillFromSource = (
+ ...args: unknown[]
+ ) => {
+ calls.push(args);
+ return impl(...args);
+ };
+ return { calls };
+}
+
+describe("SkillService.autoPublishFromSource", () => {
+ test("published: refresh succeeds → published outcome + in_sync, under system actor", async () => {
+ const { svc, persisted } = makeService(driftedSkill());
+ const { calls } = stubRefresh(svc, async () => ({ version: "1.1" }) as SkillDetailResponse);
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome).toEqual({ status: "published", fromVersion: "1.0", toVersion: "1.1" });
+ expect(persisted.at(-1)!.patch.driftState).toBe("in_sync");
+ // refresh called with the system source-sync actor, not the linking user.
+ expect(calls[0]![1]).toBe(SYSTEM_SYNC_ACTOR.userId);
+ });
+
+ test("VERSION_NOT_INCREMENTED → changed_unversioned; drift persisted, not published", async () => {
+ const { svc, persisted } = makeService(driftedSkill());
+ stubRefresh(svc, async () => {
+ throw AppError.conflict("VERSION_NOT_INCREMENTED", "bump the version");
+ });
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("changed_unversioned");
+ expect(persisted.at(-1)!.patch.driftState).toBe("changed_unversioned");
+ });
+
+ test("validation AppError → validation_failed + driftState broken", async () => {
+ const { svc, persisted } = makeService(driftedSkill());
+ stubRefresh(svc, async () => {
+ throw AppError.badRequest("validation_failed", "[rule] bad frontmatter");
+ });
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("validation_failed");
+ if (outcome.status === "validation_failed") {
+ expect(outcome.reason).toContain("validation_failed");
+ }
+ expect(persisted.at(-1)!.patch.driftState).toBe("broken");
+ });
+
+ test("non-AppError (transient) → error; driftState left drifted (no persist)", async () => {
+ const { svc, persisted } = makeService(driftedSkill());
+ stubRefresh(svc, async () => {
+ throw new Error("ECONNRESET");
+ });
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("error");
+ // Nothing persisted — the skill stays `drifted` for the next tick to retry.
+ expect(persisted.length).toBe(0);
+ });
+
+ test("not drifted → skipped, refresh never called", async () => {
+ const skill = driftedSkill({
+ source: {
+ type: "github",
+ repo: "a/x",
+ ref: "main",
+ path: "",
+ lastSyncedCommit: "old",
+ driftState: "in_sync",
+ },
+ });
+ const { svc } = makeService(skill);
+ const { calls } = stubRefresh(svc, async () => ({ version: "1.1" }) as SkillDetailResponse);
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("skipped");
+ expect(calls.length).toBe(0);
+ });
+
+ test("idempotency: upstreamHeadSha == lastSyncedCommit → skipped (already synced)", async () => {
+ const skill = driftedSkill({
+ source: {
+ type: "github",
+ repo: "a/x",
+ ref: "main",
+ path: "",
+ lastSyncedCommit: "same",
+ upstreamHeadSha: "same",
+ driftState: "drifted",
+ },
+ });
+ const { svc } = makeService(skill);
+ const { calls } = stubRefresh(svc, async () => ({ version: "1.1" }) as SkillDetailResponse);
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome).toEqual({ status: "skipped", reason: "already_synced" });
+ expect(calls.length).toBe(0);
+ });
+
+ test("no github source → skipped", async () => {
+ const { svc } = makeService({ source: undefined } as unknown as SkillDocument);
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("skipped");
+ });
+
+ test("missing skill → skipped", async () => {
+ const { svc } = makeService(null);
+ const outcome = await svc.autoPublishFromSource("g1");
+ expect(outcome.status).toBe("skipped");
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/service.sourceDrift.test.ts b/ornn-api/src/domains/skills/crud/service.sourceDrift.test.ts
new file mode 100644
index 00000000..9002a7ce
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/service.sourceDrift.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, test } from "bun:test";
+import { SkillService, type SkillServiceDeps } from "./service";
+import type { SkillDocument } from "../../../shared/types/index";
+
+/**
+ * End-to-end coverage of `SkillService.checkSourceDrift` — token precedence
+ * (settings > env > anonymous) and the delegation into the real
+ * `resolveRefHeadSha` probe. We patch `globalThis.fetch` so the whole chain
+ * runs without network and without adding test-only surface to prod deps.
+ */
+
+function githubSkillDoc(): SkillDocument {
+ return {
+ source: {
+ type: "github",
+ repo: "acme/x",
+ ref: "main",
+ path: "",
+ lastSyncedCommit: "old",
+ },
+ } as unknown as SkillDocument;
+}
+
+function makeService(opts: {
+ settingsToken?: string;
+ envToken?: string;
+ skill?: SkillDocument | null;
+}): { svc: SkillService; persisted: Array> } {
+ const persisted: Array> = [];
+ const deps = {
+ skillRepo: {
+ findByGuid: async () =>
+ opts.skill === undefined ? githubSkillDoc() : opts.skill,
+ updateSourceDriftState: async (
+ _g: string,
+ patch: Record,
+ ) => {
+ persisted.push(patch);
+ },
+ },
+ ...(opts.settingsToken !== undefined
+ ? {
+ sourceSyncSettings: {
+ getSourceSync: async () =>
+ ({ githubToken: opts.settingsToken }) as never,
+ },
+ }
+ : {}),
+ ...(opts.envToken !== undefined
+ ? { sourceSyncGithubTokenFallback: opts.envToken }
+ : {}),
+ } as unknown as SkillServiceDeps;
+ return { svc: new SkillService(deps), persisted };
+}
+
+function recordingFetch(responder: (url: string) => Response): {
+ impl: typeof fetch;
+ calls: Array<{ url: string; auth: string | undefined }>;
+} {
+ const calls: Array<{ url: string; auth: string | undefined }> = [];
+ const impl = (async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise => {
+ const url = typeof input === "string" ? input : input.toString();
+ calls.push({
+ url,
+ auth: (init?.headers as Record | undefined)?.Authorization,
+ });
+ return responder(url);
+ }) as unknown as typeof fetch;
+ return { impl, calls };
+}
+
+async function withFetch(impl: typeof fetch, fn: () => Promise): Promise {
+ const orig = globalThis.fetch;
+ globalThis.fetch = impl;
+ try {
+ return await fn();
+ } finally {
+ globalThis.fetch = orig;
+ }
+}
+
+describe("SkillService.checkSourceDrift", () => {
+ test("settings token wins over env fallback and authenticates the probe", async () => {
+ const { impl, calls } = recordingFetch(
+ () =>
+ new Response(JSON.stringify({ object: { sha: "new" } }), {
+ status: 200,
+ headers: { etag: 'W/"e"' },
+ }),
+ );
+ await withFetch(impl, async () => {
+ const { svc, persisted } = makeService({
+ settingsToken: "SETTINGS_TOK",
+ envToken: "ENV_TOK",
+ });
+ const res = await svc.checkSourceDrift("g1");
+ expect(res).toEqual({
+ applicable: true,
+ driftState: "drifted",
+ upstreamHeadSha: "new",
+ });
+ expect(calls[0]!.auth).toBe("Bearer SETTINGS_TOK");
+ expect(persisted[0]).toMatchObject({ driftState: "drifted", upstreamHeadSha: "new" });
+ });
+ });
+
+ test("empty settings token falls back to the env token", async () => {
+ const { impl, calls } = recordingFetch(
+ () => new Response(JSON.stringify({ object: { sha: "old" } }), { status: 200 }),
+ );
+ await withFetch(impl, async () => {
+ const { svc } = makeService({ settingsToken: "", envToken: "ENV_TOK" });
+ await svc.checkSourceDrift("g1");
+ expect(calls[0]!.auth).toBe("Bearer ENV_TOK");
+ });
+ });
+
+ test("no token anywhere → anonymous probe (no Authorization header)", async () => {
+ const { impl, calls } = recordingFetch(
+ () => new Response(JSON.stringify({ object: { sha: "old" } }), { status: 200 }),
+ );
+ await withFetch(impl, async () => {
+ const { svc } = makeService({ skill: githubSkillDoc() });
+ await svc.checkSourceDrift("g1");
+ expect(calls[0]!.auth).toBeUndefined();
+ });
+ });
+
+ test("non-github skill → applicable:false with no network call", async () => {
+ const { impl, calls } = recordingFetch(() => new Response("x", { status: 500 }));
+ await withFetch(impl, async () => {
+ const { svc } = makeService({
+ skill: { source: undefined } as unknown as SkillDocument,
+ });
+ const res = await svc.checkSourceDrift("g1");
+ expect(res).toEqual({ applicable: false });
+ expect(calls.length).toBe(0);
+ });
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/service.test.ts b/ornn-api/src/domains/skills/crud/service.test.ts
index ef3cf420..f817b7ea 100644
--- a/ornn-api/src/domains/skills/crud/service.test.ts
+++ b/ornn-api/src/domains/skills/crud/service.test.ts
@@ -29,12 +29,12 @@ import { AppError } from "../../../shared/types/index";
const SECRET_BODY = "SECRET_FROM_PACKAGE_b91c";
-/** Build a one-file zip and hand it back as a fetchable `data:` URL. */
-async function zipDataUrl(): Promise {
+/** Build a one-file zip and hand it back as raw bytes (what the storage
+ * client's `downloadObject` returns). */
+async function zipBytes(): Promise {
const zip = new JSZip();
zip.file("SKILL.md", `# demo\n${SECRET_BODY}`);
- const buf = await zip.generateAsync({ type: "uint8array" });
- return `data:application/zip;base64,${Buffer.from(buf).toString("base64")}`;
+ return zip.generateAsync({ type: "uint8array" });
}
/** Minimal SkillDocument carrying just the fields getSkillJson reads. */
@@ -54,14 +54,14 @@ function makeSkillDoc(overrides: Partial = {}): SkillDocument {
} as SkillDocument;
}
-function makeService(skill: SkillDocument, presignedUrl: string): SkillService {
+function makeService(skill: SkillDocument, packageBytes: Uint8Array): SkillService {
const skillRepo = {
findByGuid: async (guid: string) => (guid === skill.guid ? skill : null),
findByName: async (name: string) => (name === skill.name ? skill : null),
} as unknown as SkillRepository;
const skillVersionRepo = {} as unknown as SkillVersionRepository;
const storageClient = {
- getPresignedUrl: async () => ({ presignedUrl, expiresAt: new Date().toISOString() }),
+ downloadObject: async () => ({ bytes: packageBytes, contentType: "application/zip" }),
} as unknown as IStorageClient;
return new SkillService({
skillRepo,
@@ -79,9 +79,9 @@ describe("SkillService.getSkillJson — object-level authorization (#806)", () =
it("private skill + stranger actor throws skill_not_found before any download", async () => {
let downloaded = false;
const storageClient = {
- getPresignedUrl: async () => {
+ downloadObject: async () => {
downloaded = true;
- return { presignedUrl: "http://unused", expiresAt: "" };
+ return { bytes: new Uint8Array(), contentType: "application/zip" };
},
} as unknown as IStorageClient;
const skill = makeSkillDoc({ isPrivate: true });
@@ -103,35 +103,95 @@ describe("SkillService.getSkillJson — object-level authorization (#806)", () =
}
expect(thrown).toBeInstanceOf(AppError);
expect((thrown as AppError).code).toBe("skill_not_found");
- // Gate runs before storage — no presigned URL was ever requested.
+ // Gate runs before storage — no package download was ever requested.
expect(downloaded).toBe(false);
});
it("private skill succeeds for SYSTEM_ACTOR and returns package contents", async () => {
- const service = makeService(makeSkillDoc({ isPrivate: true }), await zipDataUrl());
+ const service = makeService(makeSkillDoc({ isPrivate: true }), await zipBytes());
const result = await service.getSkillJson("guid-1", SYSTEM_ACTOR);
expect(result.files["SKILL.md"]).toContain(SECRET_BODY);
});
it("private skill succeeds for the author", async () => {
- const service = makeService(makeSkillDoc({ isPrivate: true, createdBy: "owner-1" }), await zipDataUrl());
+ const service = makeService(makeSkillDoc({ isPrivate: true, createdBy: "owner-1" }), await zipBytes());
const result = await service.getSkillJson("guid-1", OWNER);
expect(result.files["SKILL.md"]).toContain(SECRET_BODY);
});
it("private skill succeeds for a platform admin", async () => {
- const service = makeService(makeSkillDoc({ isPrivate: true }), await zipDataUrl());
+ const service = makeService(makeSkillDoc({ isPrivate: true }), await zipBytes());
const result = await service.getSkillJson("guid-1", ADMIN);
expect(result.files["SKILL.md"]).toContain(SECRET_BODY);
});
it("public skill succeeds for any actor (including a stranger)", async () => {
- const service = makeService(makeSkillDoc({ isPrivate: false }), await zipDataUrl());
+ const service = makeService(makeSkillDoc({ isPrivate: false }), await zipBytes());
const result = await service.getSkillJson("guid-1", STRANGER);
expect(result.files["SKILL.md"]).toContain(SECRET_BODY);
});
});
+// The download route (GET /skills/:idOrName/versions/:version/download, #1196)
+// is optionalAuth and anonymous-reachable, so its object-level gate lives
+// entirely in getPackageBytes. This suite exercises the REAL gate (not a route
+// mock) so deleting the canReadSkill check can never ship green.
+describe("SkillService.getPackageBytes — object-level authorization (#806/#1196)", () => {
+ it("private skill + stranger actor throws skill_not_found before any download", async () => {
+ let downloaded = false;
+ const storageClient = {
+ downloadObject: async () => {
+ downloaded = true;
+ return { bytes: new Uint8Array(), contentType: "application/zip" };
+ },
+ } as unknown as IStorageClient;
+ const skill = makeSkillDoc({ isPrivate: true });
+ const service = new SkillService({
+ skillRepo: {
+ findByGuid: async () => skill,
+ findByName: async () => null,
+ } as unknown as SkillRepository,
+ skillVersionRepo: {} as unknown as SkillVersionRepository,
+ storageClient,
+ storageBucketResolver: async () => "test-bucket",
+ });
+
+ let thrown: unknown;
+ try {
+ await service.getPackageBytes("guid-1", STRANGER);
+ } catch (err) {
+ thrown = err;
+ }
+ expect(thrown).toBeInstanceOf(AppError);
+ expect((thrown as AppError).code).toBe("skill_not_found");
+ // Gate runs before storage — no package download was ever requested.
+ expect(downloaded).toBe(false);
+ });
+
+ it("public skill returns package bytes + name/version for a stranger", async () => {
+ const bytes = await zipBytes();
+ const service = makeService(makeSkillDoc({ isPrivate: false }), bytes);
+ const result = await service.getPackageBytes("guid-1", STRANGER);
+ expect(result.name).toBe("demo-skill");
+ expect(result.version).toBe("1.0");
+ expect(Array.from(result.bytes)).toEqual(Array.from(bytes));
+ });
+
+ it("private skill succeeds for the author", async () => {
+ const bytes = await zipBytes();
+ const service = makeService(makeSkillDoc({ isPrivate: true, createdBy: "owner-1" }), bytes);
+ const result = await service.getPackageBytes("guid-1", OWNER);
+ expect(Array.from(result.bytes)).toEqual(Array.from(bytes));
+ });
+
+ it("private skill succeeds for SYSTEM_ACTOR (audit pipeline path)", async () => {
+ const bytes = await zipBytes();
+ const service = makeService(makeSkillDoc({ isPrivate: true }), bytes);
+ const result = await service.getPackageBytes("guid-1", SYSTEM_ACTOR);
+ expect(Array.from(result.bytes)).toEqual(Array.from(bytes));
+ });
+});
+
/**
* #807 (CWE-22) — `extractSkillInfoLenient` is the `skip_validation=true`
* import path. It used to accept ANY non-empty `name`, including
@@ -249,9 +309,9 @@ describe("SkillService.setSkillPermissions — org-membership gate (#815)", () =
const skillVersionRepo = {
findLatestBySkill: async () => null,
} as unknown as SkillVersionRepository;
- const storageClient = {
- getPresignedUrl: async () => ({ presignedUrl: "http://unused", expiresAt: "" }),
- } as unknown as IStorageClient;
+ // buildDetailResponse no longer touches storage (#1196), so these flows
+ // need no storage stub.
+ const storageClient = {} as unknown as IStorageClient;
const service = new SkillService({
skillRepo,
skillVersionRepo,
@@ -579,9 +639,9 @@ describe("SkillService.transferSkillOwnership (#1123)", () => {
},
} as unknown as SkillRepository;
const skillVersionRepo = { findLatestBySkill: async () => null } as unknown as SkillVersionRepository;
- const storageClient = {
- getPresignedUrl: async () => ({ presignedUrl: "http://unused", expiresAt: "" }),
- } as unknown as IStorageClient;
+ // buildDetailResponse no longer touches storage (#1196), so these flows
+ // need no storage stub.
+ const storageClient = {} as unknown as IStorageClient;
const service = new SkillService({
skillRepo,
skillVersionRepo,
@@ -847,7 +907,6 @@ function makeFakeDeps(seed?: Partial): { deps: SkillServiceDeps; stat
delete: async (_bucket: string, key: string) => {
state.deletes.push(key);
},
- getPresignedUrl: async () => ({ presignedUrl: "http://unused", expiresAt: "" }),
} as unknown as IStorageClient;
return {
diff --git a/ornn-api/src/domains/skills/crud/service.ts b/ornn-api/src/domains/skills/crud/service.ts
index 760ae439..b72b3f86 100644
--- a/ornn-api/src/domains/skills/crud/service.ts
+++ b/ornn-api/src/domains/skills/crud/service.ts
@@ -12,6 +12,14 @@ import type { IStorageClient } from "../../../clients/storageClient";
import type { SkillDocument, SkillMetadata, SkillDetailResponse, SkillVersionDocument, SkillSource } from "../../../shared/types/index";
import { AppError } from "../../../shared/types/index";
import { fetchSkillFromGitHub, parseGithubUrl, type GitHubPullInput } from "./utils/githubPull";
+import {
+ runSourceDriftCheck,
+ pickSourceSyncToken,
+ SYSTEM_SYNC_ACTOR,
+ type SourceDriftResult,
+ type AutoPublishOutcome,
+} from "./sourceDrift";
+import type { SourceSyncSection } from "../../settings/sections/sourceSync";
import { computeVersionDiff, type VersionDiffResult } from "./utils/versionDiff";
import { isReservedVerb } from "../../../shared/reservedVerbs";
import { canReadSkill, isMemberOfOrg, SYSTEM_ACTOR, type ActorContext } from "./authorize";
@@ -115,6 +123,15 @@ export function resolveDistTag(skill: SkillDocument, version: string): string |
);
}
+/**
+ * Narrow settings surface the source-drift path needs (#1175). Decoupled
+ * from the full SettingsService so tests stub just this one method — same
+ * pattern as `MirrorSettingsReader`.
+ */
+export interface SourceSyncSettingsReader {
+ getSourceSync(): Promise;
+}
+
export interface SkillServiceDeps {
skillRepo: SkillRepository;
skillVersionRepo: SkillVersionRepository;
@@ -148,6 +165,19 @@ export interface SkillServiceDeps {
maxEntryUncompressedBytes?: number;
maxPackageFileCount?: number;
maxCompressionRatio?: number;
+
+ /**
+ * Source-sync settings reader (#1175). Optional — when absent the GitHub
+ * source reads/drift checks fall back to the env token (below) or run
+ * anonymously. Production bootstrap passes the SettingsService.
+ */
+ sourceSyncSettings?: SourceSyncSettingsReader;
+ /**
+ * Env-provided GitHub token fallback (`ORNN_SOURCE_SYNC_GITHUB_TOKEN`),
+ * used only when the settings `githubToken` is empty. Lets ops supply the
+ * credential without the admin UI. Never hardcoded, never logged.
+ */
+ sourceSyncGithubTokenFallback?: string;
}
export class SkillService {
@@ -163,6 +193,8 @@ export class SkillService {
private readonly maxEntryUncompressedBytes: number | undefined;
private readonly maxPackageFileCount: number | undefined;
private readonly maxCompressionRatio: number | undefined;
+ private readonly sourceSyncSettings: SourceSyncSettingsReader | undefined;
+ private readonly sourceSyncGithubTokenFallback: string | undefined;
constructor(deps: SkillServiceDeps) {
this.skillRepo = deps.skillRepo;
@@ -175,6 +207,113 @@ export class SkillService {
this.maxEntryUncompressedBytes = deps.maxEntryUncompressedBytes;
this.maxPackageFileCount = deps.maxPackageFileCount;
this.maxCompressionRatio = deps.maxCompressionRatio;
+ this.sourceSyncSettings = deps.sourceSyncSettings;
+ this.sourceSyncGithubTokenFallback = deps.sourceSyncGithubTokenFallback;
+ }
+
+ /**
+ * Resolve the GitHub token for authenticated source reads (#1175): the
+ * admin-set settings value wins; the env fallback is next; empty ⇒ run
+ * anonymous (rate-limited). Trimmed so stray whitespace never becomes a
+ * bogus `Authorization` header. NEVER logged.
+ */
+ private async resolveSourceSyncToken(): Promise {
+ const settingsToken = (await this.sourceSyncSettings?.getSourceSync())?.githubToken;
+ return pickSourceSyncToken(settingsToken, this.sourceSyncGithubTokenFallback);
+ }
+
+ /**
+ * Read-only drift check for a GitHub-sourced skill (#1175). Probes the
+ * upstream HEAD via the cheap `git/ref` endpoint, compares it to the
+ * last-synced commit, and persists the verdict on `source`. NEVER
+ * re-pulls or publishes — the scheduler (#1176) and auto-publish (#1177)
+ * consume the state this writes.
+ */
+ async checkSourceDrift(guid: string): Promise {
+ const token = await this.resolveSourceSyncToken();
+ return runSourceDriftCheck({ skillRepo: this.skillRepo }, guid, token);
+ }
+
+ /**
+ * Unattended auto-publish of a drifted GitHub-sourced skill (#1177). Called
+ * by the drift scheduler when `sourceSync.autoPublish` is on. Re-pulls the
+ * upstream and publishes a new version under the system source-sync actor,
+ * running the SAME validation the manual refresh runs.
+ *
+ * Never throws for known outcomes — returns a typed {@link AutoPublishOutcome}
+ * the caller maps to a notification + telemetry:
+ * - `published` — a new version shipped (from → to).
+ * - `changed_unversioned` — upstream changed but SKILL.md version not bumped;
+ * the immutable current version is left untouched (updateSkill's
+ * VERSION_NOT_INCREMENTED guard fires BEFORE any storage write).
+ * - `validation_failed` — the pulled package was rejected by the publish
+ * rules (validation / breaking-change / deps); nothing was written.
+ * - `skipped` — no github source, not drifted, or already synced
+ * (idempotency guard against a concurrent pod).
+ * - `error` — a mechanical/transient failure; drift state is
+ * left `drifted` so the next tick retries. Never publishes bad content.
+ */
+ async autoPublishFromSource(guid: string): Promise {
+ const existing = await this.skillRepo.findByGuid(guid);
+ if (!existing || existing.source?.type !== "github") {
+ return { status: "skipped", reason: "no_github_source" };
+ }
+ const source = existing.source;
+ if (source.driftState !== "drifted") {
+ return { status: "skipped", reason: `not_drifted:${source.driftState ?? "unknown"}` };
+ }
+ // Idempotency: a concurrent pod may have already published this HEAD.
+ if (source.upstreamHeadSha && source.upstreamHeadSha === source.lastSyncedCommit) {
+ return { status: "skipped", reason: "already_synced" };
+ }
+
+ const fromVersion = existing.latestVersion;
+ const now = new Date();
+ try {
+ // skipValidation deliberately unset — the auto path MUST validate.
+ const detail = await this.refreshSkillFromSource(guid, SYSTEM_SYNC_ACTOR.userId, {
+ userEmail: SYSTEM_SYNC_ACTOR.userEmail,
+ userDisplayName: SYSTEM_SYNC_ACTOR.userDisplayName,
+ });
+ // refreshSkillFromSource rewrites `source` without the drift fields, so
+ // stamp the resolved verdict back on.
+ await this.skillRepo.updateSourceDriftState(guid, {
+ driftState: "in_sync",
+ lastCheckedAt: now,
+ });
+ logger.info(
+ { guid, fromVersion, toVersion: detail.version, actor: SYSTEM_SYNC_ACTOR.userId },
+ "auto-sync published new version",
+ );
+ return { status: "published", fromVersion, toVersion: detail.version };
+ } catch (err) {
+ if (err instanceof AppError && err.code === "VERSION_NOT_INCREMENTED") {
+ await this.skillRepo.updateSourceDriftState(guid, {
+ driftState: "changed_unversioned",
+ lastCheckedAt: now,
+ });
+ logger.warn({ guid }, "auto-sync skipped: upstream changed but SKILL.md version not bumped");
+ return { status: "changed_unversioned" };
+ }
+ if (err instanceof AppError) {
+ // A deliberate publish-rule rejection (validation / breaking change /
+ // deps). The upstream content is unacceptable — never publish it. The
+ // guard fired before any storage write, so nothing partial landed.
+ await this.skillRepo.updateSourceDriftState(guid, {
+ driftState: "broken",
+ lastCheckedAt: now,
+ });
+ logger.warn({ guid, code: err.code }, "auto-sync refused: pulled package failed publish rules");
+ return { status: "validation_failed", reason: `${err.code}: ${err.message}` };
+ }
+ // Mechanical/transient (network, rate limit, malformed fetch) — leave
+ // driftState `drifted` so the next tick retries. Never mislabel or publish.
+ logger.error(
+ { guid, err: err instanceof Error ? err.message : String(err) },
+ "auto-sync errored unexpectedly — leaving drifted for retry",
+ );
+ return { status: "error", reason: err instanceof Error ? err.message : String(err) };
+ }
}
/**
@@ -849,7 +988,10 @@ export class SkillService {
skipValidation?: boolean | undefined;
},
): Promise<{ guid: string; source: SkillSource }> {
- const pulled = await fetchSkillFromGitHub(input);
+ // Authenticate the pull when a token is configured (#1175) — lifts the
+ // 60/hr anonymous ceiling. An explicit input token (tests) wins.
+ const token = await this.resolveSourceSyncToken();
+ const pulled = await fetchSkillFromGitHub({ ...input, token: input.token ?? token });
const source: SkillSource = {
type: "github",
repo: pulled.source.repo,
@@ -897,6 +1039,8 @@ export class SkillService {
repo: existing.source.repo,
ref: existing.source.ref,
path: existing.source.path,
+ // Authenticated re-pull when a token is configured (#1175).
+ token: await this.resolveSourceSyncToken(),
});
const newSource: SkillSource = {
@@ -1128,18 +1272,21 @@ export class SkillService {
}
private async downloadPackage(storageKey: string): Promise {
- const presigned = await this.storageClient.getPresignedUrl(
- (await this.storageBucketResolver()),
- storageKey,
- );
- const res = await fetch(presigned.presignedUrl);
- if (!res.ok) {
+ // Proxied through chrono-bucket's streaming download (#1196). The storage
+ // client throws on a non-2xx, which we surface as `package_download_failed`
+ // to keep the existing error contract for internal byte-consumers.
+ try {
+ const { bytes } = await this.storageClient.downloadObject(
+ (await this.storageBucketResolver()),
+ storageKey,
+ );
+ return bytes;
+ } catch (err) {
throw AppError.internalError(
"package_download_failed",
- `Failed to download package for key '${storageKey}' (HTTP ${res.status})`,
+ `Failed to download package for key '${storageKey}': ${err instanceof Error ? err.message : String(err)}`,
);
}
- return new Uint8Array(await res.arrayBuffer());
}
/**
@@ -1365,44 +1512,16 @@ export class SkillService {
throw AppError.notFound("skill_not_found", `Skill '${idOrName}' not found`);
}
- // 1a. Resolve the requested version (literal or dist-tag, #463).
- // When unset OR empty-string, fall through to the skill doc's
- // latest storage. `resolveDistTag` returns `undefined` for
- // empty input, which we treat as "no pin requested".
- let resolvedVersion = skill.latestVersion;
- let storageKey = skill.storageKey;
- let metadata: SkillMetadata = skill.metadata;
- if (version !== undefined && version.length > 0) {
- const literal = resolveDistTag(skill, version);
- if (!literal) {
- // Defensive — resolveDistTag's contract returns string for any
- // non-empty input, but the type system widens to `| undefined`.
- // Treat as malformed rather than crash.
- throw AppError.badRequest(
- "invalid_version",
- `Could not resolve version '${version}'`,
- );
- }
- parseVersion(literal); // 400 if malformed, before Mongo lookup
- const versionDoc = await this.skillVersionRepo.findBySkillAndVersion(skill.guid, literal);
- if (!versionDoc) {
- throw AppError.notFound(
- "skill_version_not_found",
- `Version '${literal}' not found for skill '${skill.name}'`,
- );
- }
- resolvedVersion = versionDoc.version;
- storageKey = versionDoc.storageKey;
- metadata = versionDoc.metadata;
- }
+ // 1a. Resolve the requested version (literal or dist-tag, #463) to its
+ // concrete version + storage key + metadata. Empty/undefined → latest.
+ const { resolvedVersion, storageKey, metadata } = await this.resolveVersionStorage(
+ skill,
+ version,
+ );
- // 2. Download ZIP from storage
- const presigned = await this.storageClient.getPresignedUrl((await this.storageBucketResolver()), storageKey);
- const response = await fetch(presigned.presignedUrl);
- if (!response.ok) {
- throw AppError.internalError("package_download_failed", "Failed to download skill package from storage");
- }
- const zipBuffer = new Uint8Array(await response.arrayBuffer());
+ // 2. Download the ZIP from storage, proxied through chrono-bucket's
+ // streaming endpoint (#1196) — no presigned URL / direct MinIO fetch.
+ const zipBuffer = await this.downloadPackage(storageKey);
// 3. Extract all files
const zip = await JSZip.loadAsync(zipBuffer);
@@ -1454,6 +1573,80 @@ export class SkillService {
};
}
+ /**
+ * Resolve a requested version (literal or dist-tag; empty/undefined →
+ * latest) to its concrete version string, storage key, and metadata.
+ * Shared by the package-content read paths (getSkillJson, getPackageBytes)
+ * so version resolution lives in one place (#463, #1196).
+ */
+ private async resolveVersionStorage(
+ skill: SkillDocument,
+ version?: string,
+ ): Promise<{ resolvedVersion: string; storageKey: string; metadata: SkillMetadata }> {
+ if (version === undefined || version.length === 0) {
+ return {
+ resolvedVersion: skill.latestVersion,
+ storageKey: skill.storageKey,
+ metadata: skill.metadata,
+ };
+ }
+ const literal = resolveDistTag(skill, version);
+ if (!literal) {
+ // Defensive — resolveDistTag returns a string for any non-empty input,
+ // but the type system widens to `| undefined`.
+ throw AppError.badRequest("invalid_version", `Could not resolve version '${version}'`);
+ }
+ parseVersion(literal); // 400 if malformed, before the Mongo lookup
+ const versionDoc = await this.skillVersionRepo.findBySkillAndVersion(skill.guid, literal);
+ if (!versionDoc) {
+ throw AppError.notFound(
+ "skill_version_not_found",
+ `Version '${literal}' not found for skill '${skill.name}'`,
+ );
+ }
+ return {
+ resolvedVersion: versionDoc.version,
+ storageKey: versionDoc.storageKey,
+ metadata: versionDoc.metadata,
+ };
+ }
+
+ /**
+ * Resolve a skill version's package ZIP bytes from storage.
+ *
+ * Enforces the same object-level visibility gate as the metadata / json
+ * paths (#806): a private skill the actor cannot read 404s as
+ * `skill_not_found` before any storage read. `version` may be a literal or
+ * dist-tag; empty/undefined → latest. Bytes are streamed from chrono-bucket
+ * via the storage client — no presigned URL ever leaves the server (#1196).
+ * Backs `GET /skills/:idOrName/versions/:version/download` and the audit
+ * pipeline. Trusted server jobs pass `SYSTEM_ACTOR`.
+ */
+ async getPackageBytes(
+ idOrName: string,
+ actor: ActorContext,
+ version?: string,
+ ): Promise<{ bytes: Uint8Array; name: string; version: string }> {
+ let skill = await this.skillRepo.findByGuid(idOrName);
+ if (!skill) skill = await this.skillRepo.findByName(idOrName);
+ if (!skill) {
+ throw AppError.notFound("skill_not_found", `Skill '${idOrName}' not found`);
+ }
+ if (!canReadSkill(skill, actor)) {
+ logger.info({ idOrName, actorUserId: actor.userId }, "getPackageBytes visibility denied");
+ throw AppError.notFound("skill_not_found", `Skill '${idOrName}' not found`);
+ }
+ const { resolvedVersion, storageKey } = await this.resolveVersionStorage(skill, version);
+ if (!storageKey) {
+ throw AppError.notFound(
+ "skill_package_not_found",
+ `No package stored for skill '${skill.name}'`,
+ );
+ }
+ const bytes = await this.downloadPackage(storageKey);
+ return { bytes, name: skill.name, version: resolvedVersion };
+ }
+
/**
* Manually re-trigger an AgentSeal scan on a single version (#253). Used
* by the admin endpoint when a false positive needs re-checking after a
@@ -1999,7 +2192,10 @@ export class SkillService {
(await this.skillVersionRepo.findLatestBySkill(skill.guid)) ?? undefined;
}
- const storageKey = effectiveOverlay?.storageKey ?? skill.storageKey;
+ // Package downloads are proxied through `GET /skills/:idOrName/versions/
+ // :version/download` (#1196); the detail response no longer carries a
+ // presigned URL, so there is no per-read storage round-trip here and no
+ // direct-to-MinIO URL ever reaches a client.
const metadata = effectiveOverlay?.metadata ?? skill.metadata;
const skillHash = effectiveOverlay?.skillHash ?? skill.skillHash;
const license = effectiveOverlay ? effectiveOverlay.license : skill.license;
@@ -2008,16 +2204,6 @@ export class SkillService {
const isDeprecated = effectiveOverlay?.isDeprecated === true;
const deprecationNote = effectiveOverlay?.deprecationNote ?? null;
- let presignedPackageUrl = "";
- if (storageKey) {
- try {
- const result = await this.storageClient.getPresignedUrl((await this.storageBucketResolver()), storageKey);
- presignedPackageUrl = result.presignedUrl;
- } catch (err) {
- logger.warn({ guid: skill.guid, version, err }, "Presigned URL generation failed");
- }
- }
-
const tags: string[] = metadata?.tags ?? [];
return {
@@ -2029,7 +2215,6 @@ export class SkillService {
metadata: metadata as unknown as Record,
tags,
skillHash,
- presignedPackageUrl,
isPrivate: skill.isPrivate,
createdBy: skill.createdBy,
createdByEmail: skill.createdByEmail,
@@ -2060,6 +2245,19 @@ export class SkillService {
...(typeof skill.source.lastSyncedCommit === "string" && skill.source.lastSyncedCommit
? { lastSyncedCommit: skill.source.lastSyncedCommit }
: {}),
+ // Drift-detection state (#1176/#1177) — surfaced on GET so the
+ // frontend renders the auto-sync badge (#1178) from the last
+ // scheduled check without a bespoke endpoint. `etag` stays
+ // internal (a conditional-request cache detail, not client-facing).
+ ...(typeof skill.source.upstreamHeadSha === "string" && skill.source.upstreamHeadSha
+ ? { upstreamHeadSha: skill.source.upstreamHeadSha }
+ : {}),
+ ...(skill.source.lastCheckedAt instanceof Date
+ ? { lastCheckedAt: skill.source.lastCheckedAt.toISOString() }
+ : {}),
+ ...(typeof skill.source.driftState === "string"
+ ? { driftState: skill.source.driftState }
+ : {}),
}
: undefined,
agentsealScan: effectiveOverlay?.agentsealScan ?? null,
diff --git a/ornn-api/src/domains/skills/crud/sourceDrift.test.ts b/ornn-api/src/domains/skills/crud/sourceDrift.test.ts
new file mode 100644
index 00000000..67c8a726
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceDrift.test.ts
@@ -0,0 +1,191 @@
+import { describe, expect, test } from "bun:test";
+import {
+ runSourceDriftCheck,
+ classifyProbeResult,
+ pickSourceSyncToken,
+ type SourceDriftDeps,
+} from "./sourceDrift";
+import {
+ GitHubSourceNotFoundError,
+ type RefHeadProbeResult,
+} from "./utils/githubPull";
+import type { SkillDocument } from "../../../shared/types/index";
+
+type Probe = NonNullable;
+
+function githubSkill(
+ source: Partial<{
+ repo: string;
+ ref: string;
+ path: string;
+ lastSyncedCommit: string;
+ etag: string;
+ }>,
+): SkillDocument {
+ return {
+ source: { type: "github", repo: "acme/x", ref: "main", path: "", ...source },
+ } as unknown as SkillDocument;
+}
+
+function fakeDeps(opts: { skill: SkillDocument | null; probe?: Probe }): {
+ deps: SourceDriftDeps;
+ persisted: Array>;
+} {
+ const persisted: Array> = [];
+ const deps: SourceDriftDeps = {
+ skillRepo: {
+ findByGuid: async () => opts.skill,
+ updateSourceDriftState: async (_g, patch) => {
+ persisted.push(patch as Record);
+ },
+ },
+ ...(opts.probe ? { probeRefHead: opts.probe } : {}),
+ };
+ return { deps, persisted };
+}
+
+describe("runSourceDriftCheck", () => {
+ test("HEAD != lastSyncedCommit → drifted; persists sha + etag + lastCheckedAt", async () => {
+ const { deps, persisted } = fakeDeps({
+ skill: githubSkill({ lastSyncedCommit: "old", etag: 'W/"e0"' }),
+ probe: async (): Promise => ({
+ sha: "new",
+ etag: 'W/"e1"',
+ notModified: false,
+ }),
+ });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(res).toEqual({ applicable: true, driftState: "drifted", upstreamHeadSha: "new" });
+ expect(persisted[0]).toMatchObject({
+ driftState: "drifted",
+ upstreamHeadSha: "new",
+ etag: 'W/"e1"',
+ });
+ expect(persisted[0]!.lastCheckedAt).toBeInstanceOf(Date);
+ });
+
+ test("HEAD == lastSyncedCommit → in_sync", async () => {
+ const { deps, persisted } = fakeDeps({
+ skill: githubSkill({ lastSyncedCommit: "same" }),
+ probe: async () => ({ sha: "same", notModified: false }),
+ });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(res.driftState).toBe("in_sync");
+ expect(persisted[0]).toMatchObject({ driftState: "in_sync", upstreamHeadSha: "same" });
+ });
+
+ test("304 notModified → in_sync; probe receives the stored etag", async () => {
+ let seenEtag: string | undefined;
+ const { deps, persisted } = fakeDeps({
+ skill: githubSkill({ lastSyncedCommit: "x", etag: 'W/"stored"' }),
+ probe: async (_r, _ref, opts) => {
+ seenEtag = opts?.etag;
+ return { notModified: true };
+ },
+ });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(seenEtag).toBe('W/"stored"');
+ expect(res.driftState).toBe("in_sync");
+ expect(persisted[0]).toMatchObject({ driftState: "in_sync" });
+ });
+
+ test("GitHubSourceNotFoundError → broken (persisted, not thrown)", async () => {
+ const { deps, persisted } = fakeDeps({
+ skill: githubSkill({}),
+ probe: async () => {
+ throw new GitHubSourceNotFoundError("acme/x", "main");
+ },
+ });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(res.driftState).toBe("broken");
+ expect(persisted[0]).toMatchObject({ driftState: "broken" });
+ });
+
+ test("transient (non-404) error is re-thrown and nothing is persisted", async () => {
+ const { deps, persisted } = fakeDeps({
+ skill: githubSkill({}),
+ probe: async () => {
+ throw new Error("network boom");
+ },
+ });
+ await expect(runSourceDriftCheck(deps, "g1", "tok")).rejects.toThrow(/network boom/);
+ expect(persisted.length).toBe(0);
+ });
+
+ test("empty token → probe called anonymously (token undefined)", async () => {
+ let seenToken: string | undefined = "unset";
+ const { deps } = fakeDeps({
+ skill: githubSkill({ lastSyncedCommit: "a" }),
+ probe: async (_r, _ref, opts) => {
+ seenToken = opts?.token;
+ return { sha: "a", notModified: false };
+ },
+ });
+ await runSourceDriftCheck(deps, "g1", "");
+ expect(seenToken).toBeUndefined();
+ });
+
+ test("skill without a github source → applicable:false, no persistence", async () => {
+ const { deps, persisted } = fakeDeps({
+ skill: { source: undefined } as unknown as SkillDocument,
+ });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(res).toEqual({ applicable: false });
+ expect(persisted.length).toBe(0);
+ });
+
+ test("missing skill → applicable:false", async () => {
+ const { deps } = fakeDeps({ skill: null });
+ const res = await runSourceDriftCheck(deps, "g1", "tok");
+ expect(res.applicable).toBe(false);
+ });
+});
+
+describe("classifyProbeResult", () => {
+ test("304 notModified → in_sync, no upstreamHeadSha", () => {
+ const { driftState, patch } = classifyProbeResult(
+ { lastSyncedCommit: "x" },
+ { notModified: true },
+ );
+ expect(driftState).toBe("in_sync");
+ expect(patch).toEqual({ driftState: "in_sync" });
+ });
+
+ test("HEAD == lastSyncedCommit → in_sync with sha + etag", () => {
+ const { driftState, patch } = classifyProbeResult(
+ { lastSyncedCommit: "same" },
+ { sha: "same", etag: 'W/"e"', notModified: false },
+ );
+ expect(driftState).toBe("in_sync");
+ expect(patch).toEqual({ driftState: "in_sync", upstreamHeadSha: "same", etag: 'W/"e"' });
+ });
+
+ test("HEAD != lastSyncedCommit → drifted", () => {
+ const { driftState, patch } = classifyProbeResult(
+ { lastSyncedCommit: "old" },
+ { sha: "new", notModified: false },
+ );
+ expect(driftState).toBe("drifted");
+ expect(patch.upstreamHeadSha).toBe("new");
+ expect(patch.etag).toBeUndefined();
+ });
+});
+
+describe("pickSourceSyncToken", () => {
+ test("settings token wins", () => {
+ expect(pickSourceSyncToken("settings", "env")).toBe("settings");
+ });
+ test("empty/whitespace settings → env fallback", () => {
+ expect(pickSourceSyncToken("", "env")).toBe("env");
+ expect(pickSourceSyncToken(" ", "env")).toBe("env");
+ expect(pickSourceSyncToken(undefined, "env")).toBe("env");
+ });
+ test("neither → empty string (anonymous)", () => {
+ expect(pickSourceSyncToken(undefined, undefined)).toBe("");
+ expect(pickSourceSyncToken("", "")).toBe("");
+ });
+ test("trims the chosen token", () => {
+ expect(pickSourceSyncToken(" tok ", undefined)).toBe("tok");
+ expect(pickSourceSyncToken("", " env ")).toBe("env");
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/sourceDrift.ts b/ornn-api/src/domains/skills/crud/sourceDrift.ts
new file mode 100644
index 00000000..9efe39c1
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceDrift.ts
@@ -0,0 +1,185 @@
+/**
+ * Source-drift check (#1175) — the read-only "did upstream move?" probe for
+ * a GitHub-sourced skill.
+ *
+ * Extracted from `SkillService` (which is already large) so the logic is
+ * unit-testable in isolation and the service exposes only a thin delegating
+ * method. This NEVER re-pulls, publishes, or mutates the skill package — it
+ * only reads the upstream HEAD via the cheap `git/ref` probe and persists
+ * the drift verdict + ETag on `source`. The scheduler (#1176) and
+ * auto-publish (#1177) build on the state this writes.
+ *
+ * @module domains/skills/crud/sourceDrift
+ */
+import { createLogger } from "../../../shared/logger";
+import type { SkillSourceDriftState } from "../../../shared/types/index";
+import type { SkillRepository } from "./repository";
+import {
+ resolveRefHeadSha,
+ GitHubSourceNotFoundError,
+ type RefHeadProbeInput,
+ type RefHeadProbeResult,
+} from "./utils/githubPull";
+
+const logger = createLogger("sourceDrift");
+
+/**
+ * Identity recorded as `createdBy` on an auto-published version (#1177).
+ * Distinct from the authz `SYSTEM_ACTOR` (`__system__`) so the audit trail
+ * plainly shows automatic source-sync — not the original linking user, who
+ * may have lost repo access or left — authored the version.
+ */
+export const SYSTEM_SYNC_ACTOR = {
+ userId: "system:source-sync",
+ userEmail: "source-sync@ornn.internal",
+ userDisplayName: "Ornn Auto-Sync",
+} as const;
+
+/**
+ * Result of an auto-publish attempt (#1177). `autoPublishFromSource` never
+ * throws for these known outcomes — the batch job maps the status to a
+ * notification + telemetry event and keeps going.
+ */
+export type AutoPublishOutcome =
+ | { status: "published"; fromVersion: string; toVersion: string }
+ | { status: "changed_unversioned" }
+ | { status: "validation_failed"; reason: string }
+ | { status: "skipped"; reason: string }
+ | { status: "error"; reason: string };
+
+export interface SourceDriftResult {
+ /** False when the skill has no GitHub source — nothing to check. */
+ readonly applicable: boolean;
+ /** The verdict written to `source.driftState`. Absent when not applicable. */
+ readonly driftState?: SkillSourceDriftState;
+ /** The upstream HEAD SHA observed (present on a live probe, not on `broken`). */
+ readonly upstreamHeadSha?: string;
+}
+
+/** Persist patch (sans `lastCheckedAt`, which the caller stamps). */
+export interface DriftPatch {
+ readonly driftState: SkillSourceDriftState;
+ readonly upstreamHeadSha?: string;
+ readonly etag?: string;
+}
+
+/**
+ * Turn a probe result + a skill's last-synced commit into a drift verdict
+ * and the fields to persist. Pure — shared by the single-skill check and the
+ * batch scheduler (#1176), which probes once per `(repo, ref)` group and
+ * classifies each member against its own `lastSyncedCommit`.
+ *
+ * A `304` (nothing changed since the stored ETag) is `in_sync`; a live HEAD
+ * equal to `lastSyncedCommit` is `in_sync`; anything else is `drifted`.
+ */
+export function classifyProbeResult(
+ source: { lastSyncedCommit?: string | undefined },
+ result: RefHeadProbeResult,
+): { driftState: SkillSourceDriftState; patch: DriftPatch } {
+ if (result.notModified) {
+ return { driftState: "in_sync", patch: { driftState: "in_sync" } };
+ }
+ const sha = result.sha!;
+ const driftState: SkillSourceDriftState =
+ sha === source.lastSyncedCommit ? "in_sync" : "drifted";
+ return {
+ driftState,
+ patch: {
+ driftState,
+ upstreamHeadSha: sha,
+ ...(result.etag ? { etag: result.etag } : {}),
+ },
+ };
+}
+
+/**
+ * Resolve the effective GitHub token: an admin-set settings value wins, then
+ * the env fallback, else `""` (anonymous). Trimmed so stray whitespace never
+ * becomes a bogus bearer. Pure — shared by SkillService and the scheduler.
+ */
+export function pickSourceSyncToken(
+ settingsToken: string | undefined,
+ envFallback: string | undefined,
+): string {
+ const configured = settingsToken?.trim();
+ if (configured) return configured;
+ return envFallback?.trim() ?? "";
+}
+
+export interface SourceDriftDeps {
+ readonly skillRepo: Pick<
+ SkillRepository,
+ "findByGuid" | "updateSourceDriftState"
+ >;
+ /**
+ * Cheap HEAD-SHA probe. Injectable so tests can drive the 304 / drift /
+ * 404 branches without real network. Defaults to the real
+ * {@link resolveRefHeadSha}.
+ */
+ readonly probeRefHead?: (
+ repo: string,
+ ref: string,
+ opts?: RefHeadProbeInput,
+ ) => Promise;
+}
+
+/**
+ * Check a single skill for upstream drift and persist the verdict.
+ *
+ * @param token Service-account token, or `""` for anonymous (rate-limited)
+ * reads. Empty logs a warning but still probes.
+ */
+export async function runSourceDriftCheck(
+ deps: SourceDriftDeps,
+ guid: string,
+ token: string,
+): Promise {
+ const probe = deps.probeRefHead ?? resolveRefHeadSha;
+ const skill = await deps.skillRepo.findByGuid(guid);
+ if (!skill || !skill.source || skill.source.type !== "github") {
+ return { applicable: false };
+ }
+ const source = skill.source;
+ if (token.length === 0) {
+ logger.warn(
+ { guid, repo: source.repo },
+ "source drift check running unauthenticated — GitHub reads are limited to 60/hr per IP",
+ );
+ }
+ const now = new Date();
+
+ try {
+ const result = await probe(source.repo, source.ref, {
+ token: token || undefined,
+ etag: source.etag,
+ });
+
+ const { driftState, patch } = classifyProbeResult(source, result);
+ await deps.skillRepo.updateSourceDriftState(guid, { ...patch, lastCheckedAt: now });
+ logger.info(
+ { guid, repo: source.repo, ref: source.ref, driftState, upstreamHeadSha: patch.upstreamHeadSha },
+ "source drift check complete",
+ );
+ return {
+ applicable: true,
+ driftState,
+ ...(patch.upstreamHeadSha ? { upstreamHeadSha: patch.upstreamHeadSha } : {}),
+ };
+ } catch (err) {
+ // A genuinely missing repo/ref is a terminal, per-skill state — record
+ // it and return. Transient failures (network, 5xx) are re-thrown so the
+ // caller/scheduler can retry rather than mislabel a blip as broken.
+ if (err instanceof GitHubSourceNotFoundError) {
+ await deps.skillRepo.updateSourceDriftState(guid, {
+ driftState: "broken",
+ lastCheckedAt: now,
+ });
+ logger.warn(
+ { guid, repo: source.repo, ref: source.ref },
+ "source drift check: upstream not found — marked broken",
+ );
+ return { applicable: true, driftState: "broken" };
+ }
+ throw err;
+ }
+}
diff --git a/ornn-api/src/domains/skills/crud/sourceDriftJob.test.ts b/ornn-api/src/domains/skills/crud/sourceDriftJob.test.ts
new file mode 100644
index 00000000..663e0c9c
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceDriftJob.test.ts
@@ -0,0 +1,377 @@
+import { describe, expect, test } from "bun:test";
+import pino from "pino";
+import { runSourceDriftJob, type SourceDriftJobDeps } from "./sourceDriftJob";
+import {
+ GitHubSourceNotFoundError,
+ GitHubRateLimitError,
+ type RefHeadProbeInput,
+ type RefHeadProbeResult,
+} from "./utils/githubPull";
+import type { AutoPublishOutcome } from "./sourceDrift";
+import type { SkillSource } from "../../../shared/types/index";
+
+const logger = pino({ level: "silent" });
+
+type Candidate = { guid: string; source: SkillSource; ownerId: string };
+type Probe = (
+ repo: string,
+ ref: string,
+ opts?: RefHeadProbeInput,
+) => Promise;
+
+function cand(
+ guid: string,
+ repo: string,
+ ref: string,
+ sourceExtra: Partial> = {},
+): Candidate {
+ return {
+ guid,
+ ownerId: `owner-${guid}`,
+ source: { type: "github", repo, ref, path: "", ...sourceExtra },
+ };
+}
+
+function makeDeps(opts: {
+ enabled?: boolean;
+ candidates: Candidate[];
+ probe: Probe;
+ concurrency?: number;
+ autoPublishSetting?: boolean;
+ autoPublish?: (guid: string) => Promise;
+}): {
+ deps: SourceDriftJobDeps;
+ persisted: Array<{ guid: string; patch: Record }>;
+ notified: Array<{ ownerId: string; skillGuid: string; repo: string; ref: string }>;
+ autoSynced: Array<{ skillGuid: string; fromVersion: string; toVersion: string }>;
+ autoFailed: Array<{ skillGuid: string; reason: string }>;
+ activity: Array<{ action: string; properties: Record }>;
+} {
+ const persisted: Array<{ guid: string; patch: Record }> = [];
+ const notified: Array<{ ownerId: string; skillGuid: string; repo: string; ref: string }> = [];
+ const autoSynced: Array<{ skillGuid: string; fromVersion: string; toVersion: string }> = [];
+ const autoFailed: Array<{ skillGuid: string; reason: string }> = [];
+ const activity: Array<{ action: string; properties: Record }> = [];
+ const deps: SourceDriftJobDeps = {
+ skillRepo: {
+ findGithubSourcedSkills: async () => opts.candidates,
+ updateSourceDriftState: async (guid, patch) => {
+ persisted.push({ guid, patch: patch as Record });
+ },
+ },
+ settingsService: {
+ getSourceSync: async () => ({
+ enabled: opts.enabled ?? true,
+ githubToken: "tok",
+ pollSchedule: "*/15 * * * *",
+ minCheckIntervalMinutes: 60,
+ autoPublish: opts.autoPublishSetting ?? false,
+ }),
+ },
+ notifier: {
+ notifySourceBroken: async (p) => {
+ notified.push(p);
+ },
+ notifyAutoSynced: async (p) => {
+ autoSynced.push({ skillGuid: p.skillGuid, fromVersion: p.fromVersion, toVersion: p.toVersion });
+ },
+ notifyAutoSyncFailed: async (p) => {
+ autoFailed.push({ skillGuid: p.skillGuid, reason: p.reason });
+ },
+ },
+ analyticsEmitter: {
+ trackPlatformActivity: (input) => {
+ activity.push({ action: input.action, properties: input.properties ?? {} });
+ },
+ },
+ logger,
+ probeRefHead: opts.probe,
+ ...(opts.autoPublish ? { autoPublish: opts.autoPublish } : {}),
+ concurrency: opts.concurrency ?? 5,
+ jitterMs: 0,
+ };
+ return { deps, persisted, notified, autoSynced, autoFailed, activity };
+}
+
+describe("runSourceDriftJob", () => {
+ test("disabled settings → no-op (no probe, no persist)", async () => {
+ let probed = 0;
+ const { deps, persisted } = makeDeps({
+ enabled: false,
+ candidates: [cand("g1", "acme/x", "main")],
+ probe: async () => {
+ probed++;
+ return { sha: "s", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.enabled).toBe(false);
+ expect(probed).toBe(0);
+ expect(persisted.length).toBe(0);
+ });
+
+ test("coalesces by (repo,ref): N skills sharing an upstream → ONE probe", async () => {
+ let probes = 0;
+ const { deps, persisted, notified } = makeDeps({
+ candidates: [
+ cand("g1", "acme/x", "main", { lastSyncedCommit: "c1" }),
+ cand("g2", "acme/x", "main", { lastSyncedCommit: "c1" }),
+ cand("g3", "acme/x", "main", { lastSyncedCommit: "cOLD" }),
+ ],
+ probe: async () => {
+ probes++;
+ return { sha: "c1", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(probes).toBe(1); // one probe for the shared (repo,ref)
+ expect(res.groups).toBe(1);
+ expect(res.checked).toBe(3); // fanned out to all three
+ expect(res.drifted).toBe(1); // only g3's lastSyncedCommit differs
+ expect(persisted.length).toBe(3);
+ expect(notified.length).toBe(0);
+ });
+
+ test("pinned 40-hex ref is skipped, never probed", async () => {
+ let probes = 0;
+ const { deps, persisted } = makeDeps({
+ candidates: [cand("g1", "acme/x", "a".repeat(40))],
+ probe: async () => {
+ probes++;
+ return { sha: "x", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(probes).toBe(0);
+ expect(res.skipped).toBe(1);
+ expect(persisted.length).toBe(0);
+ });
+
+ test("broken source → each skill marked broken + notified; loop continues", async () => {
+ const { deps, persisted, notified } = makeDeps({
+ concurrency: 1,
+ candidates: [
+ cand("g1", "gone/x", "main"),
+ cand("g2", "gone/x", "main"),
+ cand("g3", "live/y", "main", { lastSyncedCommit: "c1" }),
+ ],
+ probe: async (repo) => {
+ if (repo === "gone/x") throw new GitHubSourceNotFoundError(repo, "main");
+ return { sha: "c1", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.broken).toBe(2);
+ expect(notified.map((n) => n.skillGuid).sort()).toEqual(["g1", "g2"]);
+ expect(persisted.filter((p) => p.patch.driftState === "broken").length).toBe(2);
+ // The live group after the broken one still got processed.
+ expect(res.checked).toBe(1);
+ expect(persisted.some((p) => p.guid === "g3" && p.patch.driftState === "in_sync")).toBe(true);
+ });
+
+ test("rate-limit → short-circuits remaining groups; counts them skipped", async () => {
+ let calls = 0;
+ const { deps, persisted } = makeDeps({
+ concurrency: 1, // deterministic order
+ candidates: [
+ cand("g1", "a/x", "main"),
+ cand("g2", "b/y", "main"),
+ cand("g3", "c/z", "main"),
+ ],
+ probe: async () => {
+ calls++;
+ if (calls === 1) throw new GitHubRateLimitError(403, 1000);
+ return { sha: "s", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(calls).toBe(1); // no probing after the rate-limit signal
+ expect(res.skipped).toBe(3); // the rate-limited group + the two never attempted
+ expect(res.checked).toBe(0);
+ expect(persisted.length).toBe(0);
+ });
+
+ test("drift persists upstreamHeadSha + etag + lastCheckedAt", async () => {
+ const { deps, persisted } = makeDeps({
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", etag: 'W/"e"', notModified: false }),
+ });
+ await runSourceDriftJob(deps);
+ const patch = persisted.find((p) => p.guid === "g1")!.patch;
+ expect(patch.driftState).toBe("drifted");
+ expect(patch.upstreamHeadSha).toBe("new");
+ expect(patch.etag).toBe('W/"e"');
+ expect(patch.lastCheckedAt).toBeInstanceOf(Date);
+ });
+
+ test("304 notModified → in_sync; the stored etag is sent", async () => {
+ let seenEtag: string | undefined;
+ const { deps, persisted } = makeDeps({
+ candidates: [cand("g1", "a/x", "main", { etag: 'W/"prev"' })],
+ probe: async (_r, _ref, o) => {
+ seenEtag = o?.etag;
+ return { notModified: true };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(seenEtag).toBe('W/"prev"');
+ expect(res.checked).toBe(1);
+ expect(res.drifted).toBe(0);
+ expect(persisted[0]!.patch.driftState).toBe("in_sync");
+ });
+
+ test("transient probe error → group skipped, other groups still processed", async () => {
+ const { deps, persisted } = makeDeps({
+ concurrency: 1,
+ candidates: [
+ cand("g1", "flaky/x", "main"),
+ cand("g2", "ok/y", "main", { lastSyncedCommit: "c1" }),
+ ],
+ probe: async (repo) => {
+ if (repo === "flaky/x") throw new Error("ECONNRESET");
+ return { sha: "c1", notModified: false };
+ },
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.skipped).toBe(1);
+ expect(res.checked).toBe(1);
+ expect(persisted.some((p) => p.guid === "g2")).toBe(true);
+ expect(persisted.some((p) => p.guid === "g1")).toBe(false);
+ });
+
+ test("a persist failure for one skill does not abort the tick", async () => {
+ const persisted: string[] = [];
+ const deps: SourceDriftJobDeps = {
+ skillRepo: {
+ findGithubSourcedSkills: async () => [
+ cand("g1", "a/x", "main", { lastSyncedCommit: "c1" }),
+ cand("g2", "b/y", "main", { lastSyncedCommit: "c1" }),
+ ],
+ updateSourceDriftState: async (guid) => {
+ if (guid === "g1") throw new Error("mongo write failed");
+ persisted.push(guid);
+ },
+ },
+ settingsService: {
+ getSourceSync: async () => ({
+ enabled: true,
+ githubToken: "",
+ pollSchedule: "*/15 * * * *",
+ minCheckIntervalMinutes: 60,
+ autoPublish: false,
+ }),
+ },
+ notifier: {
+ notifySourceBroken: async () => {},
+ notifyAutoSynced: async () => {},
+ notifyAutoSyncFailed: async () => {},
+ },
+ logger,
+ probeRefHead: async () => ({ sha: "c1", notModified: false }),
+ concurrency: 1,
+ jitterMs: 0,
+ };
+ const res = await runSourceDriftJob(deps);
+ // Both counted as checked; g2 still persisted despite g1's write throwing.
+ expect(res.checked).toBe(2);
+ expect(persisted).toContain("g2");
+ });
+
+ test("emits skill.source_drift_detected for every drift, regardless of autoPublish", async () => {
+ const { deps, activity } = makeDeps({
+ autoPublishSetting: false, // OFF
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ await runSourceDriftJob(deps);
+ const drift = activity.filter((a) => a.action === "skill.source_drift_detected");
+ expect(drift.length).toBe(1);
+ expect(drift[0]!.properties.skillId).toBe("g1");
+ });
+
+ test("autoPublish OFF → autoPublish callback is never invoked", async () => {
+ let called = 0;
+ const { deps } = makeDeps({
+ autoPublishSetting: false,
+ autoPublish: async () => {
+ called++;
+ return { status: "published", fromVersion: "1.0", toVersion: "1.1" };
+ },
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ await runSourceDriftJob(deps);
+ expect(called).toBe(0);
+ });
+
+ test("autoPublish ON + published → notifyAutoSynced + skill.auto_synced event", async () => {
+ const { deps, autoSynced, activity } = makeDeps({
+ autoPublishSetting: true,
+ autoPublish: async () => ({ status: "published", fromVersion: "1.0", toVersion: "1.1" }),
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.autoPublished).toBe(1);
+ expect(autoSynced).toEqual([{ skillGuid: "g1", fromVersion: "1.0", toVersion: "1.1" }]);
+ expect(activity.some((a) => a.action === "skill.auto_synced")).toBe(true);
+ });
+
+ test("autoPublish ON + changed_unversioned → notifyAutoSyncFailed, counted", async () => {
+ const { deps, autoFailed } = makeDeps({
+ autoPublishSetting: true,
+ autoPublish: async () => ({ status: "changed_unversioned" }),
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.autoSyncFailed).toBe(1);
+ expect(autoFailed).toEqual([
+ { skillGuid: "g1", reason: "upstream changed but SKILL.md version not bumped" },
+ ]);
+ });
+
+ test("autoPublish ON + validation_failed → notifyAutoSyncFailed with reason", async () => {
+ const { deps, autoFailed } = makeDeps({
+ autoPublishSetting: true,
+ autoPublish: async () => ({ status: "validation_failed", reason: "validation_failed: bad zip" }),
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.autoSyncFailed).toBe(1);
+ expect(autoFailed[0]!.reason).toContain("bad zip");
+ });
+
+ test("autoPublish ON + error → no notification, tick continues", async () => {
+ const { deps, autoFailed, autoSynced } = makeDeps({
+ autoPublishSetting: true,
+ autoPublish: async () => ({ status: "error", reason: "network blip" }),
+ candidates: [cand("g1", "a/x", "main", { lastSyncedCommit: "old" })],
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.autoPublished).toBe(0);
+ expect(res.autoSyncFailed).toBe(0);
+ expect(autoFailed.length).toBe(0);
+ expect(autoSynced.length).toBe(0);
+ expect(res.drifted).toBe(1);
+ });
+
+ test("autoPublish callback throwing does not abort the tick", async () => {
+ const { deps } = makeDeps({
+ autoPublishSetting: true,
+ autoPublish: async () => {
+ throw new Error("unexpected");
+ },
+ candidates: [
+ cand("g1", "a/x", "main", { lastSyncedCommit: "old" }),
+ cand("g2", "b/y", "main", { lastSyncedCommit: "old" }),
+ ],
+ concurrency: 1,
+ probe: async () => ({ sha: "new", notModified: false }),
+ });
+ const res = await runSourceDriftJob(deps);
+ expect(res.drifted).toBe(2); // both still processed despite the throw
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/sourceDriftJob.ts b/ornn-api/src/domains/skills/crud/sourceDriftJob.ts
new file mode 100644
index 00000000..2b3a0a9f
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceDriftJob.ts
@@ -0,0 +1,380 @@
+/**
+ * Source-drift batch job (#1176/#1177) — the body the scheduler fires.
+ *
+ * Enumerates GitHub-sourced skills due for a check, coalesces them by
+ * `(repo, ref)` so a shared upstream costs exactly one probe, and persists
+ * each skill's drift verdict. A broken source (404/private/deleted) marks
+ * the skill `broken` and notifies its owner. A GitHub rate-limit signal
+ * short-circuits the remaining work this tick (the next fire retries). No
+ * single skill's failure is ever allowed to throw out of the loop.
+ *
+ * When `sourceSync.autoPublish` is on (#1177), a detected drift also triggers
+ * an unattended re-publish via the injected `autoPublish` callback; the job
+ * maps the outcome to an owner notification + telemetry. `skill.source_drift_detected`
+ * is emitted for every drift regardless of the autoPublish switch.
+ *
+ * @module domains/skills/crud/sourceDriftJob
+ */
+import type pino from "pino";
+import type { SkillRepository } from "./repository";
+import type { SkillSource } from "../../../shared/types/index";
+import type { SourceSyncSection } from "../../settings/sections/sourceSync";
+import type { AnalyticsEmitter } from "../../../infra/analytics";
+import {
+ resolveRefHeadSha,
+ GitHubSourceNotFoundError,
+ GitHubRateLimitError,
+ type RefHeadProbeInput,
+ type RefHeadProbeResult,
+} from "./utils/githubPull";
+import {
+ classifyProbeResult,
+ pickSourceSyncToken,
+ SYSTEM_SYNC_ACTOR,
+ type AutoPublishOutcome,
+} from "./sourceDrift";
+
+type Candidate = { guid: string; source: SkillSource; ownerId: string };
+
+/** Narrow notifier surface — decoupled from the full NotificationService. */
+export interface SourceSyncNotifier {
+ notifySourceBroken(p: {
+ ownerId: string;
+ skillGuid: string;
+ repo: string;
+ ref: string;
+ }): Promise;
+ notifyAutoSynced(p: {
+ ownerId: string;
+ skillGuid: string;
+ fromVersion: string;
+ toVersion: string;
+ }): Promise;
+ notifyAutoSyncFailed(p: {
+ ownerId: string;
+ skillGuid: string;
+ reason: string;
+ }): Promise;
+}
+
+export interface SourceDriftJobDeps {
+ skillRepo: Pick<
+ SkillRepository,
+ "findGithubSourcedSkills" | "updateSourceDriftState"
+ >;
+ settingsService: { getSourceSync(): Promise };
+ /** Env fallback token when the settings token is empty. */
+ envTokenFallback?: string | undefined;
+ notifier: SourceSyncNotifier;
+ logger: pino.Logger;
+ /** Test seam for the probe; defaults to the real {@link resolveRefHeadSha}. */
+ probeRefHead?: (
+ repo: string,
+ ref: string,
+ opts?: RefHeadProbeInput,
+ ) => Promise;
+ /**
+ * Auto-publish a drifted skill (#1177). Wired to
+ * `SkillService.autoPublishFromSource` in bootstrap. Only invoked when
+ * `settings.sourceSync.autoPublish` is on. Absent ⇒ detect-only.
+ */
+ autoPublish?: (guid: string) => Promise;
+ /** Optional analytics for drift/auto-sync events. */
+ analyticsEmitter?: Pick;
+ /** Max concurrent upstream probes. Default 5. */
+ concurrency?: number;
+ /** Upper bound (ms) on per-group random jitter to spread load. Default 0. */
+ jitterMs?: number;
+}
+
+export interface SourceDriftJobResult {
+ readonly enabled: boolean;
+ readonly groups: number;
+ readonly checked: number;
+ readonly drifted: number;
+ readonly broken: number;
+ readonly skipped: number;
+ /** Versions auto-published this run (#1177). */
+ readonly autoPublished: number;
+ /** Auto-publish refusals (unversioned / validation) this run. */
+ readonly autoSyncFailed: number;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function safePersist(
+ deps: SourceDriftJobDeps,
+ guid: string,
+ patch: Parameters[1],
+): Promise {
+ try {
+ await deps.skillRepo.updateSourceDriftState(guid, patch);
+ } catch (err) {
+ deps.logger.warn(
+ { guid, err: err instanceof Error ? err.message : String(err) },
+ "source drift job: persist failed for one skill — continuing",
+ );
+ }
+}
+
+async function safeNotifyBroken(
+ deps: SourceDriftJobDeps,
+ c: Candidate,
+ repo: string,
+ ref: string,
+): Promise {
+ if (!c.ownerId) return; // no known owner to notify
+ try {
+ await deps.notifier.notifySourceBroken({
+ ownerId: c.ownerId,
+ skillGuid: c.guid,
+ repo,
+ ref,
+ });
+ } catch (err) {
+ deps.logger.warn(
+ { guid: c.guid, err: err instanceof Error ? err.message : String(err) },
+ "source drift job: broken-source notify failed — continuing",
+ );
+ }
+}
+
+/** Fire-and-forget analytics for the auto-sync events, under the system actor. */
+function emitActivity(
+ deps: SourceDriftJobDeps,
+ action: "skill.source_drift_detected" | "skill.auto_synced" | "skill.auto_sync_failed",
+ properties: Record,
+): void {
+ deps.analyticsEmitter?.trackPlatformActivity({
+ userId: SYSTEM_SYNC_ACTOR.userId,
+ userEmail: SYSTEM_SYNC_ACTOR.userEmail,
+ userDisplayName: SYSTEM_SYNC_ACTOR.userDisplayName,
+ action,
+ properties,
+ });
+}
+
+/** Run a notification, swallowing errors — a notify failure must not abort the tick. */
+async function safeNotify(
+ deps: SourceDriftJobDeps,
+ guid: string,
+ fn: () => Promise,
+): Promise {
+ try {
+ await fn();
+ } catch (err) {
+ deps.logger.warn(
+ { guid, err: err instanceof Error ? err.message : String(err) },
+ "source drift job: auto-sync notify failed — continuing",
+ );
+ }
+}
+
+/**
+ * Handle a skill the probe found `drifted`: emit the drift event, and — when
+ * autoPublish is enabled — invoke the publish callback and map its outcome to
+ * a notification + telemetry. Updates `counts` in place. Never throws.
+ */
+async function handleDrifted(
+ deps: SourceDriftJobDeps,
+ c: Candidate,
+ autoPublishOn: boolean,
+ counts: { autoPublished: number; autoSyncFailed: number },
+): Promise {
+ emitActivity(deps, "skill.source_drift_detected", {
+ skillId: c.guid,
+ repo: c.source.repo,
+ ref: c.source.ref,
+ upstreamHeadSha: c.source.upstreamHeadSha,
+ });
+ if (!autoPublishOn || !deps.autoPublish) return;
+
+ let outcome: AutoPublishOutcome;
+ try {
+ outcome = await deps.autoPublish(c.guid);
+ } catch (err) {
+ // autoPublishFromSource is designed not to throw, but stay defensive.
+ deps.logger.error(
+ { guid: c.guid, err: err instanceof Error ? err.message : String(err) },
+ "source drift job: auto-publish threw unexpectedly — continuing",
+ );
+ return;
+ }
+
+ switch (outcome.status) {
+ case "published":
+ counts.autoPublished++;
+ emitActivity(deps, "skill.auto_synced", {
+ skillId: c.guid,
+ fromVersion: outcome.fromVersion,
+ toVersion: outcome.toVersion,
+ });
+ if (c.ownerId) {
+ await safeNotify(deps, c.guid, () =>
+ deps.notifier.notifyAutoSynced({
+ ownerId: c.ownerId,
+ skillGuid: c.guid,
+ fromVersion: outcome.fromVersion,
+ toVersion: outcome.toVersion,
+ }),
+ );
+ }
+ break;
+ case "changed_unversioned":
+ case "validation_failed": {
+ counts.autoSyncFailed++;
+ const reason =
+ outcome.status === "changed_unversioned"
+ ? "upstream changed but SKILL.md version not bumped"
+ : outcome.reason;
+ emitActivity(deps, "skill.auto_sync_failed", { skillId: c.guid, reason });
+ if (c.ownerId) {
+ await safeNotify(deps, c.guid, () =>
+ deps.notifier.notifyAutoSyncFailed({ ownerId: c.ownerId, skillGuid: c.guid, reason }),
+ );
+ }
+ break;
+ }
+ case "error":
+ case "skipped":
+ // Transient error (retried next tick) or idempotent no-op — no notify.
+ break;
+ }
+}
+
+export async function runSourceDriftJob(
+ deps: SourceDriftJobDeps,
+): Promise {
+ const { logger } = deps;
+ const settings = await deps.settingsService.getSourceSync();
+ if (!settings.enabled) {
+ logger.info(
+ "source drift job: disabled (settings.sourceSync.enabled=false) — skipping",
+ );
+ return {
+ enabled: false,
+ groups: 0,
+ checked: 0,
+ drifted: 0,
+ broken: 0,
+ skipped: 0,
+ autoPublished: 0,
+ autoSyncFailed: 0,
+ };
+ }
+
+ const token = pickSourceSyncToken(settings.githubToken, deps.envTokenFallback);
+ const cutoff = new Date(Date.now() - settings.minCheckIntervalMinutes * 60_000);
+ const candidates = await deps.skillRepo.findGithubSourcedSkills({
+ notCheckedSince: cutoff,
+ });
+
+ // Coalesce by (repo, ref): probe each distinct upstream once, then fan the
+ // result out to every skill that points at it.
+ const groups = new Map();
+ for (const c of candidates) {
+ const key = `${c.source.repo} ${c.source.ref}`;
+ const existing = groups.get(key);
+ if (existing) existing.push(c);
+ else groups.set(key, [c]);
+ }
+
+ const probe = deps.probeRefHead ?? resolveRefHeadSha;
+ const concurrency = Math.max(1, deps.concurrency ?? 5);
+ const jitterMs = deps.jitterMs ?? 0;
+ const autoPublishOn = settings.autoPublish === true && !!deps.autoPublish;
+ const counts = {
+ checked: 0,
+ drifted: 0,
+ broken: 0,
+ skipped: 0,
+ autoPublished: 0,
+ autoSyncFailed: 0,
+ };
+ let rateLimited = false;
+
+ const groupList = [...groups.values()];
+ let cursor = 0; // synchronous claim → each worker gets a unique group index
+
+ async function worker(): Promise {
+ for (;;) {
+ const myIdx = cursor++;
+ if (myIdx >= groupList.length) return;
+ const group = groupList[myIdx]!;
+ if (rateLimited) {
+ counts.skipped += group.length;
+ continue;
+ }
+ const { repo, ref } = group[0]!.source;
+
+ // Belt-and-suspenders — the query already excludes pinned 40-hex refs.
+ if (/^[0-9a-f]{40}$/i.test(ref)) {
+ counts.skipped += group.length;
+ continue;
+ }
+
+ let result: RefHeadProbeResult;
+ try {
+ result = await probe(repo, ref, {
+ token: token || undefined,
+ etag: group[0]!.source.etag,
+ });
+ } catch (err) {
+ if (err instanceof GitHubRateLimitError) {
+ rateLimited = true;
+ counts.skipped += group.length;
+ logger.warn(
+ { repo, ref, retryAfterMs: err.retryAfterMs },
+ "source drift job: GitHub rate-limited — short-circuiting remaining groups this tick",
+ );
+ continue;
+ }
+ if (err instanceof GitHubSourceNotFoundError) {
+ const now = new Date();
+ for (const c of group) {
+ await safePersist(deps, c.guid, { driftState: "broken", lastCheckedAt: now });
+ await safeNotifyBroken(deps, c, repo, ref);
+ counts.broken++;
+ }
+ continue;
+ }
+ // Transient (network / 5xx) — skip this group; the next tick retries.
+ counts.skipped += group.length;
+ logger.warn(
+ { repo, ref, err: err instanceof Error ? err.message : String(err) },
+ "source drift job: probe failed (transient) — skipping group this tick",
+ );
+ continue;
+ }
+
+ const now = new Date();
+ for (const c of group) {
+ const { driftState, patch } = classifyProbeResult(c.source, result);
+ await safePersist(deps, c.guid, { ...patch, lastCheckedAt: now });
+ counts.checked++;
+ if (driftState === "drifted") {
+ counts.drifted++;
+ // Give handleDrifted the just-observed HEAD so the drift event +
+ // auto-publish idempotency guard see the fresh upstream sha.
+ const drifted: Candidate = {
+ ...c,
+ source: { ...c.source, upstreamHeadSha: patch.upstreamHeadSha },
+ };
+ await handleDrifted(deps, drifted, autoPublishOn, counts);
+ }
+ }
+
+ if (jitterMs > 0) await sleep(Math.floor(Math.random() * jitterMs));
+ }
+ }
+
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, groupList.length) }, () => worker()),
+ );
+
+ const summary: SourceDriftJobResult = { enabled: true, groups: groups.size, ...counts };
+ logger.info(summary, "source drift job complete");
+ return summary;
+}
diff --git a/ornn-api/src/domains/skills/crud/sourceSyncScheduler.test.ts b/ornn-api/src/domains/skills/crud/sourceSyncScheduler.test.ts
new file mode 100644
index 00000000..617e23d4
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceSyncScheduler.test.ts
@@ -0,0 +1,254 @@
+/**
+ * Source-sync scheduler unit tests. Mocks the Agenda surface (same approach
+ * as the mirror scheduler test) so assertions stay deterministic — the
+ * multi-pod row-lock guarantee is Agenda's own to test, not ours.
+ *
+ * @module domains/skills/crud/sourceSyncScheduler.test
+ */
+import { beforeEach, describe, expect, mock, test } from "bun:test";
+import pino from "pino";
+import type { SourceSyncSettingsReader } from "./sourceSyncScheduler";
+import type { SourceSyncSection } from "../../settings/sections/sourceSync";
+import type { SourceDriftJobResult } from "./sourceDriftJob";
+
+const logger = pino({ level: "silent" });
+
+let agendaCalls: {
+ define: Array<{ name: string }>;
+ every: Array<{ interval: string | number; name: string; options?: { timezone?: string } }>;
+ cancel: Array<{ name?: string }>;
+ now: string[];
+ started: boolean;
+ stopped: boolean;
+};
+const jobHandlers = new Map Promise>();
+let queryJobsResult: { jobs: Array> } = { jobs: [] };
+let queryJobsThrows: Error | null = null;
+
+mock.module("agenda", () => ({
+ Agenda: class FakeAgenda {
+ on() {}
+ define(name: string, fn: () => Promise) {
+ agendaCalls.define.push({ name });
+ jobHandlers.set(name, fn);
+ }
+ async every(
+ interval: string | number,
+ name: string,
+ _data: unknown,
+ options?: { timezone?: string },
+ ) {
+ agendaCalls.every.push({ interval, name, ...(options !== undefined ? { options } : {}) });
+ }
+ async cancel(opts: { name?: string }) {
+ agendaCalls.cancel.push(opts);
+ return 1;
+ }
+ async now(name: string) {
+ agendaCalls.now.push(name);
+ const fn = jobHandlers.get(name);
+ if (fn) await fn();
+ }
+ async start() {
+ agendaCalls.started = true;
+ }
+ async stop() {
+ agendaCalls.stopped = true;
+ }
+ async queryJobs(_opts: { name: string }) {
+ if (queryJobsThrows) throw queryJobsThrows;
+ return queryJobsResult;
+ }
+ },
+}));
+mock.module("@agendajs/mongo-backend", () => ({
+ MongoBackend: class FakeBackend {
+ constructor(_: unknown) {}
+ },
+}));
+
+const { createSourceSyncScheduler } = await import("./sourceSyncScheduler");
+
+function resetAgendaCalls() {
+ agendaCalls = { define: [], every: [], cancel: [], now: [], started: false, stopped: false };
+ jobHandlers.clear();
+ queryJobsResult = { jobs: [] };
+ queryJobsThrows = null;
+}
+
+function makeSettings(initial: string): SourceSyncSettingsReader & { set(s: string): void } {
+ let cur: SourceSyncSection = {
+ enabled: true,
+ githubToken: "",
+ pollSchedule: initial,
+ minCheckIntervalMinutes: 60,
+ autoPublish: false,
+ };
+ return {
+ getSourceSync: mock(async () => cur),
+ set(next: string) {
+ cur = { ...cur, pollSchedule: next };
+ },
+ } as unknown as SourceSyncSettingsReader & { set(s: string): void };
+}
+
+const okResult: SourceDriftJobResult = {
+ enabled: true,
+ groups: 0,
+ checked: 0,
+ drifted: 0,
+ broken: 0,
+ skipped: 0,
+ autoPublished: 0,
+ autoSyncFailed: 0,
+};
+const FAKE_DB = {} as Parameters[0]["db"];
+
+beforeEach(() => {
+ resetAgendaCalls();
+});
+
+describe("createSourceSyncScheduler", () => {
+ test("start registers both jobs + eager-syncs schedule from settings (SGT)", async () => {
+ const settings = makeSettings("*/15 * * * *");
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: settings,
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+
+ expect(agendaCalls.define.map((d) => d.name).sort()).toEqual([
+ "source-drift-check",
+ "source-sync-schedule",
+ ]);
+ const everyJob = agendaCalls.every.find((e) => e.name === "source-drift-check");
+ expect(everyJob).toBeDefined();
+ expect(everyJob!.interval).toBe("*/15 * * * *");
+ expect(everyJob!.options?.timezone).toBe("Asia/Singapore");
+ const everySync = agendaCalls.every.find((e) => e.name === "source-sync-schedule");
+ expect(everySync!.interval).toBe("1 minute");
+
+ await sched.stop();
+ expect(agendaCalls.stopped).toBe(true);
+ });
+
+ test("settings change → next tick re-registers with new cron", async () => {
+ const settings = makeSettings("*/15 * * * *");
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: settings,
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+ const before = agendaCalls.every.filter((e) => e.name === "source-drift-check").length;
+
+ settings.set("0 * * * *");
+ await sched.runSyncNow();
+
+ const after = agendaCalls.every.filter((e) => e.name === "source-drift-check");
+ expect(after.length).toBe(before + 1);
+ expect(after.at(-1)!.interval).toBe("0 * * * *");
+ });
+
+ test("unchanged schedule → no second every() for the job", async () => {
+ const settings = makeSettings("*/15 * * * *");
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: settings,
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+ const baseline = agendaCalls.every.filter((e) => e.name === "source-drift-check").length;
+ await sched.runSyncNow();
+ await sched.runSyncNow();
+ expect(agendaCalls.every.filter((e) => e.name === "source-drift-check").length).toBe(baseline);
+ });
+
+ test("empty schedule → cancels the recurring job; re-enabling re-registers", async () => {
+ const settings = makeSettings("*/15 * * * *");
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: settings,
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+
+ settings.set("");
+ await sched.runSyncNow();
+ expect(agendaCalls.cancel.filter((c) => c.name === "source-drift-check").length).toBe(1);
+
+ settings.set("0 3 * * *");
+ await sched.runSyncNow();
+ expect(
+ agendaCalls.every.filter((e) => e.name === "source-drift-check").at(-1)!.interval,
+ ).toBe("0 3 * * *");
+ });
+
+ test("drift-check handler delegates to runDriftJob", async () => {
+ let ran = 0;
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: makeSettings("*/15 * * * *"),
+ runDriftJob: async () => {
+ ran++;
+ return okResult;
+ },
+ });
+ await sched.start();
+ const fn = jobHandlers.get("source-drift-check");
+ expect(fn).toBeDefined();
+ await fn!();
+ expect(ran).toBe(1);
+ });
+
+ test("settings read failure on a tick is swallowed (no crash)", async () => {
+ const broken = {
+ getSourceSync: mock(async () => {
+ throw new Error("db down");
+ }),
+ } as unknown as SourceSyncSettingsReader;
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: broken,
+ runDriftJob: async () => okResult,
+ });
+ await sched.start(); // eager sync hits the broken read but must not throw
+ await sched.runSyncNow();
+ await sched.stop();
+ });
+
+ test("getScheduledRunStatus: succeeded derivation", async () => {
+ const lastRunAt = new Date("2026-07-01T02:00:00.000Z");
+ const lastFinishedAt = new Date("2026-07-01T02:00:03.500Z");
+ queryJobsResult = { jobs: [{ lastRunAt, lastFinishedAt }] };
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: makeSettings("*/15 * * * *"),
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+ const s = await sched.getScheduledRunStatus();
+ expect(s.status).toBe("succeeded");
+ expect(s.lastDurationMs).toBe(3500);
+ });
+
+ test("getScheduledRunStatus: queryJobs throw → never_run", async () => {
+ queryJobsThrows = new Error("mongo unreachable");
+ const sched = createSourceSyncScheduler({
+ db: FAKE_DB,
+ logger,
+ settingsService: makeSettings("*/15 * * * *"),
+ runDriftJob: async () => okResult,
+ });
+ await sched.start();
+ expect((await sched.getScheduledRunStatus()).status).toBe("never_run");
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/sourceSyncScheduler.ts b/ornn-api/src/domains/skills/crud/sourceSyncScheduler.ts
new file mode 100644
index 00000000..747e2839
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/sourceSyncScheduler.ts
@@ -0,0 +1,242 @@
+/**
+ * In-process source-sync (drift-check) scheduler (#1176).
+ *
+ * Clones the mirror scheduler's multi-pod-safe Agenda pattern
+ * (`domains/skills/mirror/scheduler.ts`): exactly one pod claims each
+ * scheduled fire via Agenda's per-fire row lock on the `agendaJobs`
+ * collection; the rest skip. Two recurring jobs:
+ *
+ * 1. `source-drift-check` — runs the drift batch job. Cadence (cron) is
+ * driven by `settings.sourceSync.pollSchedule`, interpreted in
+ * `Asia/Singapore`. Empty schedule = unregistered.
+ * 2. `source-sync-schedule` — every minute on every pod, re-reads the
+ * cron from settings and (re-)registers the drift job via
+ * `agenda.every()`, so an admin cadence change converges cluster-wide
+ * within ~65s without cross-pod messaging.
+ *
+ * Crash recovery: `defaultLockLifetime` (10 min) lets another pod re-claim
+ * a fire if the holder dies. The job itself is idempotent (it only records
+ * drift state), so a rare double-run is harmless.
+ *
+ * @module domains/skills/crud/sourceSyncScheduler
+ */
+import { Agenda } from "agenda";
+import { MongoBackend } from "@agendajs/mongo-backend";
+import type { Db } from "mongodb";
+import type pino from "pino";
+import type { SourceSyncSection } from "../../settings/sections/sourceSync";
+import type { SourceDriftJobResult } from "./sourceDriftJob";
+
+const JOB_DRIFT_CHECK = "source-drift-check";
+const JOB_SYNC_SCHEDULE = "source-sync-schedule";
+
+const DEFAULT_TIMEZONE = "Asia/Singapore";
+const DEFAULT_SYNC_INTERVAL = "1 minute";
+const DEFAULT_LOCK_LIFETIME_MS = 10 * 60 * 1000;
+const PROCESS_EVERY = "5 seconds";
+
+/** Narrow settings surface — just the source-sync section read. */
+export interface SourceSyncSettingsReader {
+ getSourceSync(): Promise;
+}
+
+export interface SourceSyncSchedulerDeps {
+ db: Db;
+ logger: pino.Logger;
+ settingsService: SourceSyncSettingsReader;
+ /** The actual work — constructed in bootstrap with the full job deps. */
+ runDriftJob: () => Promise;
+ lockLifetimeMs?: number;
+ syncInterval?: string | number;
+ timezone?: string;
+ processEvery?: string | number;
+}
+
+export interface ScheduledRunStatus {
+ status: "succeeded" | "failed" | "running" | "never_run";
+ lastRunAt: Date | null;
+ lastFinishedAt: Date | null;
+ lastDurationMs: number | null;
+ lastError: string | null;
+ nextRunAt: Date | null;
+}
+
+export interface SourceSyncScheduler {
+ start(): Promise;
+ stop(): Promise;
+ /** Test hook — force the sync tick immediately. */
+ runSyncNow(): Promise;
+ getScheduledRunStatus(): Promise;
+}
+
+export function createSourceSyncScheduler(
+ deps: SourceSyncSchedulerDeps,
+): SourceSyncScheduler {
+ const { db, settingsService, runDriftJob, logger } = deps;
+ const lockLifetime = deps.lockLifetimeMs ?? DEFAULT_LOCK_LIFETIME_MS;
+ const syncInterval = deps.syncInterval ?? DEFAULT_SYNC_INTERVAL;
+ const timezone = deps.timezone ?? DEFAULT_TIMEZONE;
+
+ const agenda = new Agenda({
+ backend: new MongoBackend({ mongo: db }),
+ processEvery: deps.processEvery ?? PROCESS_EVERY,
+ defaultLockLifetime: lockLifetime,
+ });
+
+ // Per-process memo of the last-registered cron — lets the sync tick
+ // early-return when nothing changed. Convergence across pods still
+ // happens via the shared `agendaJobs` doc.
+ let currentSchedule: string | null = null;
+
+ agenda.define(JOB_DRIFT_CHECK, async () => {
+ const t0 = Date.now();
+ try {
+ const result = await runDriftJob();
+ logger.info(
+ { ...result, durationMs: Date.now() - t0 },
+ "scheduled source drift check completed",
+ );
+ } catch (err) {
+ logger.error(
+ {
+ err: err instanceof Error ? err.message : String(err),
+ durationMs: Date.now() - t0,
+ },
+ "scheduled source drift check failed",
+ );
+ throw err;
+ }
+ });
+
+ agenda.define(JOB_SYNC_SCHEDULE, async () => {
+ let section: SourceSyncSection;
+ try {
+ section = await settingsService.getSourceSync();
+ } catch (err) {
+ logger.error(
+ { err: err instanceof Error ? err.message : String(err) },
+ "source-sync-schedule: failed to read settings — skipping tick",
+ );
+ return;
+ }
+ const next = section.pollSchedule;
+ if (next === currentSchedule) return;
+
+ if (next === "") {
+ await agenda.cancel({ name: JOB_DRIFT_CHECK });
+ currentSchedule = "";
+ logger.info(
+ "source-sync schedule: cancelled (settings.sourceSync.pollSchedule is empty)",
+ );
+ return;
+ }
+
+ try {
+ await agenda.every(next, JOB_DRIFT_CHECK, undefined, { timezone });
+ currentSchedule = next;
+ logger.info({ cron: next, timezone }, "source-sync schedule: registered");
+ } catch (err) {
+ logger.error(
+ { err: err instanceof Error ? err.message : String(err), cron: next },
+ "source-sync schedule: failed to register — will retry on next sync tick",
+ );
+ }
+ });
+
+ agenda.on("error", (err: unknown) => {
+ logger.error(
+ { err: err instanceof Error ? err.message : String(err) },
+ "agenda error event (source-sync)",
+ );
+ });
+
+ return {
+ async start() {
+ await agenda.start();
+ try {
+ await agenda.now(JOB_SYNC_SCHEDULE);
+ } catch (err) {
+ logger.warn(
+ { err: err instanceof Error ? err.message : String(err) },
+ "source-sync scheduler: initial sync enqueue failed — recurring sync will catch up",
+ );
+ }
+ await agenda.every(syncInterval, JOB_SYNC_SCHEDULE);
+ logger.info(
+ { syncInterval, timezone, lockLifetimeMs: lockLifetime },
+ "source-sync scheduler started",
+ );
+ },
+ async stop() {
+ try {
+ await agenda.stop(false); // false = don't close the Mongo client (we own it)
+ } catch (err) {
+ logger.warn(
+ { err: err instanceof Error ? err.message : String(err) },
+ "source-sync scheduler: agenda.stop failed — continuing",
+ );
+ }
+ },
+ async runSyncNow() {
+ await agenda.now(JOB_SYNC_SCHEDULE);
+ },
+ async getScheduledRunStatus(): Promise {
+ let result;
+ try {
+ result = await agenda.queryJobs({ name: JOB_DRIFT_CHECK });
+ } catch (err) {
+ logger.warn(
+ { err: err instanceof Error ? err.message : String(err) },
+ "getScheduledRunStatus (source-sync): queryJobs failed — returning never_run",
+ );
+ return emptyStatus();
+ }
+ const job = result.jobs[0];
+ if (!job) return emptyStatus();
+
+ const lastRunAt = job.lastRunAt ?? null;
+ const lastFinishedAt = job.lastFinishedAt ?? null;
+ const failedAt = job.failedAt ?? null;
+ const lockedAt = job.lockedAt ?? null;
+
+ let status: ScheduledRunStatus["status"];
+ if (lockedAt) {
+ status = "running";
+ } else if (
+ failedAt &&
+ (!lastFinishedAt || failedAt.getTime() >= lastFinishedAt.getTime())
+ ) {
+ status = "failed";
+ } else if (lastFinishedAt) {
+ status = "succeeded";
+ } else {
+ status = "never_run";
+ }
+
+ const lastDurationMs =
+ lastFinishedAt && lastRunAt
+ ? lastFinishedAt.getTime() - lastRunAt.getTime()
+ : null;
+
+ return {
+ status,
+ lastRunAt,
+ lastFinishedAt,
+ lastDurationMs,
+ lastError: status === "failed" ? (job.failReason ?? null) : null,
+ nextRunAt: job.nextRunAt ?? null,
+ };
+ },
+ };
+}
+
+function emptyStatus(): ScheduledRunStatus {
+ return {
+ status: "never_run",
+ lastRunAt: null,
+ lastFinishedAt: null,
+ lastDurationMs: null,
+ lastError: null,
+ nextRunAt: null,
+ };
+}
diff --git a/ornn-api/src/domains/skills/crud/types/api.ts b/ornn-api/src/domains/skills/crud/types/api.ts
index b4c13e60..84fb4f56 100644
--- a/ornn-api/src/domains/skills/crud/types/api.ts
+++ b/ornn-api/src/domains/skills/crud/types/api.ts
@@ -14,7 +14,6 @@ export interface SkillDetailResponse {
metadata: Record;
tags: string[];
skillHash: string;
- presignedPackageUrl: string;
isPrivate: boolean;
createdBy: string;
createdOn: string;
diff --git a/ornn-api/src/domains/skills/crud/utils/githubPull.test.ts b/ornn-api/src/domains/skills/crud/utils/githubPull.test.ts
index c45d9a33..92c11d74 100644
--- a/ornn-api/src/domains/skills/crud/utils/githubPull.test.ts
+++ b/ornn-api/src/domains/skills/crud/utils/githubPull.test.ts
@@ -5,8 +5,29 @@ import {
normalizePath,
normalizeRepoIdentifier,
parseGithubUrl,
+ authHeaders,
+ resolveRefHeadSha,
+ GitHubSourceNotFoundError,
+ GitHubRateLimitError,
} from "./githubPull";
+/** Records each request's URL + Authorization header, returns caller-chosen responses. */
+function recordingFetch(responder: (url: string) => Response): {
+ impl: typeof fetch;
+ calls: Array<{ url: string; headers: Record }>;
+} {
+ const calls: Array<{ url: string; headers: Record }> = [];
+ const impl = (async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise => {
+ const url = typeof input === "string" ? input : input.toString();
+ calls.push({ url, headers: (init?.headers ?? {}) as Record });
+ return responder(url);
+ }) as unknown as typeof fetch;
+ return { impl, calls };
+}
+
describe("parseGithubUrl", () => {
test("extracts repo + ref + path from a /tree/ URL", () => {
expect(
@@ -291,4 +312,181 @@ Hello world.
expect(result.source.ref).toBe("HEAD");
expect(result.resolvedCommitSha).toBe("headsha");
});
+
+ test("token authenticates api.github.com reads but never raw downloads (#1175)", async () => {
+ const authByHost: Record> = {};
+ const impl = (async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise => {
+ const url = typeof input === "string" ? input : input.toString();
+ const u = new URL(url);
+ const auth = (init?.headers as Record | undefined)?.Authorization;
+ (authByHost[u.host] ??= []).push(auth);
+ if (u.pathname.includes("/commits/")) {
+ return new Response(JSON.stringify({ sha: "s" }), { status: 200 });
+ }
+ if (u.pathname.includes("/contents")) {
+ return new Response(
+ JSON.stringify([
+ {
+ name: "SKILL.md",
+ path: "SKILL.md",
+ type: "file",
+ size: 12,
+ sha: "x",
+ download_url: "https://raw.example/SKILL.md",
+ },
+ ]),
+ { status: 200 },
+ );
+ }
+ if (url.startsWith("https://raw.example/")) {
+ return new Response("---\nname: x\n---\n");
+ }
+ return new Response("nf", { status: 404 });
+ }) as unknown as typeof fetch;
+
+ await fetchSkillFromGitHub(
+ { repo: "acme/x", ref: "main", token: "ghp_secret" },
+ impl,
+ );
+ // Every api.github.com call carried the bearer; the raw CDN host got none.
+ expect(authByHost["api.github.com"]?.every((a) => a === "Bearer ghp_secret")).toBe(true);
+ expect(authByHost["raw.example"]?.every((a) => a === undefined)).toBe(true);
+ });
+});
+
+describe("authHeaders", () => {
+ test("no token → no Authorization", () => {
+ const h = authHeaders();
+ expect(h.Authorization).toBeUndefined();
+ expect(h.Accept).toBe("application/vnd.github+json");
+ expect(h["User-Agent"]).toBeTruthy();
+ });
+ test("empty token → no Authorization", () => {
+ expect(authHeaders("").Authorization).toBeUndefined();
+ });
+ test("non-empty token → Bearer Authorization", () => {
+ expect(authHeaders("ghp_tok").Authorization).toBe("Bearer ghp_tok");
+ });
+});
+
+describe("resolveRefHeadSha", () => {
+ test("pinned 40-hex SHA short-circuits with ZERO http calls", async () => {
+ const { impl, calls } = recordingFetch(
+ () => new Response("must not be called", { status: 500 }),
+ );
+ const sha = "a1b2c3d4".repeat(5); // 40 hex chars
+ const res = await resolveRefHeadSha("acme/x", sha, {}, impl);
+ expect(res).toEqual({ sha, notModified: false });
+ expect(calls.length).toBe(0);
+ });
+
+ test("branch 200 → object.sha + etag; sends Authorization, no If-None-Match", async () => {
+ const { impl, calls } = recordingFetch(
+ () =>
+ new Response(JSON.stringify({ object: { sha: "newsha" } }), {
+ status: 200,
+ headers: { etag: 'W/"abc"' },
+ }),
+ );
+ const res = await resolveRefHeadSha("acme/x", "main", { token: "ghp_t" }, impl);
+ expect(res).toEqual({ sha: "newsha", etag: 'W/"abc"', notModified: false });
+ expect(calls[0]!.url).toContain("/git/ref/heads/main");
+ expect(calls[0]!.headers.Authorization).toBe("Bearer ghp_t");
+ expect(calls[0]!.headers["If-None-Match"]).toBeUndefined();
+ });
+
+ test("304 with stored etag → notModified, replays If-None-Match", async () => {
+ const { impl, calls } = recordingFetch(() => new Response(null, { status: 304 }));
+ const res = await resolveRefHeadSha(
+ "acme/x",
+ "main",
+ { token: "t", etag: 'W/"prev"' },
+ impl,
+ );
+ expect(res).toEqual({ notModified: true });
+ expect(calls[0]!.headers["If-None-Match"]).toBe('W/"prev"');
+ });
+
+ test("no token → request carries no Authorization", async () => {
+ const { impl, calls } = recordingFetch(
+ () => new Response(JSON.stringify({ object: { sha: "s" } }), { status: 200 }),
+ );
+ await resolveRefHeadSha("acme/x", "main", {}, impl);
+ expect(calls[0]!.headers.Authorization).toBeUndefined();
+ });
+
+ test("404 on heads AND tags → GitHubSourceNotFoundError", async () => {
+ const { impl, calls } = recordingFetch(() => new Response("nf", { status: 404 }));
+ await expect(resolveRefHeadSha("acme/x", "gone", {}, impl)).rejects.toBeInstanceOf(
+ GitHubSourceNotFoundError,
+ );
+ expect(calls.length).toBe(2);
+ expect(calls[0]!.url).toContain("/git/ref/heads/gone");
+ expect(calls[1]!.url).toContain("/git/ref/tags/gone");
+ });
+
+ test("404 heads then 200 tags → resolves via tag fallback", async () => {
+ const { impl } = recordingFetch((url) =>
+ url.includes("/git/ref/heads/")
+ ? new Response("nf", { status: 404 })
+ : new Response(JSON.stringify({ object: { sha: "tagsha" } }), { status: 200 }),
+ );
+ const res = await resolveRefHeadSha("acme/x", "v1.0", {}, impl);
+ expect(res.sha).toBe("tagsha");
+ });
+
+ test("slashed branch name keeps its path segments (not %2F)", async () => {
+ const { impl, calls } = recordingFetch(
+ () => new Response(JSON.stringify({ object: { sha: "s" } }), { status: 200 }),
+ );
+ await resolveRefHeadSha("acme/x", "feature/foo", {}, impl);
+ expect(calls[0]!.url).toContain("/git/ref/heads/feature/foo");
+ });
+
+ test("429 → GitHubRateLimitError with Retry-After", async () => {
+ const { impl } = recordingFetch(
+ () => new Response("slow down", { status: 429, headers: { "retry-after": "30" } }),
+ );
+ let err: unknown;
+ try {
+ await resolveRefHeadSha("acme/x", "main", {}, impl);
+ } catch (e) {
+ err = e;
+ }
+ expect(err).toBeInstanceOf(GitHubRateLimitError);
+ expect((err as GitHubRateLimitError).retryAfterMs).toBe(30_000);
+ });
+
+ test("403 with X-RateLimit-Remaining: 0 → GitHubRateLimitError", async () => {
+ const { impl } = recordingFetch(
+ () =>
+ new Response("forbidden", {
+ status: 403,
+ headers: { "x-ratelimit-remaining": "0" },
+ }),
+ );
+ await expect(resolveRefHeadSha("acme/x", "main", {}, impl)).rejects.toBeInstanceOf(
+ GitHubRateLimitError,
+ );
+ });
+
+ test("403 WITHOUT rate-limit signals → generic error (not rate-limit)", async () => {
+ const { impl } = recordingFetch(() => new Response("nope", { status: 403 }));
+ let err: unknown;
+ try {
+ await resolveRefHeadSha("acme/x", "main", {}, impl);
+ } catch (e) {
+ err = e;
+ }
+ expect(err).toBeInstanceOf(Error);
+ expect(err).not.toBeInstanceOf(GitHubRateLimitError);
+ });
+
+ test("500 → generic transient error", async () => {
+ const { impl } = recordingFetch(() => new Response("boom", { status: 500 }));
+ await expect(resolveRefHeadSha("acme/x", "main", {}, impl)).rejects.toThrow(/500/);
+ });
});
diff --git a/ornn-api/src/domains/skills/crud/utils/githubPull.ts b/ornn-api/src/domains/skills/crud/utils/githubPull.ts
index f3460596..17aa2934 100644
--- a/ornn-api/src/domains/skills/crud/utils/githubPull.ts
+++ b/ornn-api/src/domains/skills/crud/utils/githubPull.ts
@@ -6,11 +6,19 @@
* the contents API, fetch each file's raw bytes, and build a ZIP in the
* shape the existing upload pipeline expects (skill-root/SKILL.md, etc.).
*
- * MVP constraints:
- * - public repos only (no auth header)
+ * Constraints:
+ * - public repos only
* - single directory, recursive (enumerates via contents API)
* - max 200 files / 10 MiB total to keep the pull bounded
*
+ * Auth (#1175): every `api.github.com` request may carry an optional
+ * `Authorization: Bearer `. The token is a service-account
+ * credential used ONLY to lift the unauthenticated 60-req/hr-per-IP rate
+ * limit to the authenticated 5,000/hr (with free `304`s) — it grants no
+ * access beyond public content. Raw file downloads (`download_url`, served
+ * from `raw.githubusercontent.com`) stay unauthenticated: they're a
+ * different host with a separate budget and need no credential.
+ *
* @module domains/skills/crud/utils/githubPull
*/
@@ -19,6 +27,87 @@ import { createLogger } from "../../../../shared/logger";
import { hasUnsafeSegment } from "../../../../shared/githubNaming";
const logger = createLogger("githubSkillPull");
+const GITHUB_USER_AGENT = "ornn-api/skills-github-pull";
+
+/**
+ * Build headers for an `api.github.com` request. Adds `Authorization` only
+ * when a non-empty token is supplied — so the same code path serves both
+ * authenticated (rate-limit-lifted) and anonymous reads.
+ */
+export function authHeaders(token?: string): Record {
+ const headers: Record = {
+ Accept: "application/vnd.github+json",
+ "User-Agent": GITHUB_USER_AGENT,
+ };
+ if (typeof token === "string" && token.length > 0) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ return headers;
+}
+
+/**
+ * Thrown when a repo/ref genuinely doesn't exist (404 on both the branch
+ * and tag ref lookups). A typed error so the drift checker can map it to a
+ * `broken` state without coupling this low-level util to the HTTP-layer
+ * `AppError`.
+ */
+export class GitHubSourceNotFoundError extends Error {
+ constructor(
+ public readonly repo: string,
+ public readonly ref: string,
+ ) {
+ super(`GitHub ref '${ref}' not found in ${repo}`);
+ this.name = "GitHubSourceNotFoundError";
+ }
+}
+
+/**
+ * Thrown when GitHub signals a primary or secondary rate limit (`429`, or
+ * `403` with `X-RateLimit-Remaining: 0` / a `Retry-After` header). Carries
+ * the recommended wait so a batch caller (the drift scheduler, #1176) can
+ * short-circuit the rest of its tick and let the next fire retry.
+ */
+export class GitHubRateLimitError extends Error {
+ constructor(
+ public readonly status: number,
+ public readonly retryAfterMs: number | undefined,
+ ) {
+ super(`GitHub rate limit hit (status ${status})`);
+ this.name = "GitHubRateLimitError";
+ }
+}
+
+/**
+ * Classify a non-OK GitHub response: a rate-limit signal becomes a typed
+ * {@link GitHubRateLimitError} (with the wait derived from `Retry-After` or
+ * `X-RateLimit-Reset`); everything else becomes a generic error the caller
+ * treats as transient. `nowMs` is injected for deterministic tests.
+ */
+function rateLimitOrGeneric(
+ res: Response,
+ context: string,
+ nowMs: number,
+): Error {
+ const retryAfter = res.headers.get("retry-after");
+ const remaining = res.headers.get("x-ratelimit-remaining");
+ const reset = res.headers.get("x-ratelimit-reset");
+ const isRateLimit =
+ res.status === 429 ||
+ (res.status === 403 && (remaining === "0" || retryAfter !== null));
+ if (!isRateLimit) {
+ return new Error(`GitHub API returned ${res.status} for ${context}`);
+ }
+ let retryAfterMs: number | undefined;
+ if (retryAfter !== null) {
+ const secs = Number(retryAfter);
+ if (Number.isFinite(secs)) retryAfterMs = Math.max(0, secs * 1000);
+ } else if (reset !== null) {
+ const resetMs = Number(reset) * 1000;
+ if (Number.isFinite(resetMs)) retryAfterMs = Math.max(0, resetMs - nowMs);
+ }
+ return new GitHubRateLimitError(res.status, retryAfterMs);
+}
+
export interface GitHubPullInput {
/** `owner/name`. */
readonly repo: string;
@@ -32,6 +121,30 @@ export interface GitHubPullInput {
readonly maxFiles?: number | undefined;
/** Max total bytes. Safety cap. Default 10 MiB. */
readonly maxTotalBytes?: number | undefined;
+ /**
+ * Service-account token used to authenticate `api.github.com` reads and
+ * lift the rate limit. Absent ⇒ anonymous (rate-limited) reads. Never
+ * sent to raw file downloads.
+ */
+ readonly token?: string | undefined;
+}
+
+/** Options for the cheap HEAD-SHA drift probe. */
+export interface RefHeadProbeInput {
+ /** Auth token — see {@link authHeaders}. */
+ readonly token?: string | undefined;
+ /** Prior ETag for a conditional request; a matching `304` is free. */
+ readonly etag?: string | undefined;
+}
+
+/** Result of {@link resolveRefHeadSha}. */
+export interface RefHeadProbeResult {
+ /** Branch/tag HEAD commit SHA. Absent only when `notModified` is true. */
+ readonly sha?: string | undefined;
+ /** Fresh ETag to persist for the next conditional probe. */
+ readonly etag?: string | undefined;
+ /** True when the server answered `304 Not Modified` (nothing changed). */
+ readonly notModified: boolean;
}
export interface GitHubPullResult {
@@ -167,11 +280,11 @@ export async function fetchSkillFromGitHub(
const maxTotalBytes = input.maxTotalBytes ?? DEFAULT_MAX_BYTES;
// Resolve the ref to a concrete commit SHA for audit logging.
- const resolvedCommitSha = await resolveRefToSha(repo, refInput, fetchImpl);
+ const resolvedCommitSha = await resolveRefToSha(repo, refInput, fetchImpl, input.token);
// Walk the target directory recursively.
const allFiles: GitHubContentEntry[] = [];
- await walkContents(repo, resolvedCommitSha, path, fetchImpl, allFiles);
+ await walkContents(repo, resolvedCommitSha, path, fetchImpl, allFiles, input.token);
if (allFiles.length === 0) {
throw new Error(`No files found under '${path || "/"}' in ${repo}@${refInput}`);
@@ -244,18 +357,84 @@ export async function fetchSkillFromGitHub(
};
}
+/** Encode a slash-bearing ref (e.g. `feature/x`) segment-by-segment so the
+ * git-ref path stays intact instead of collapsing `/` into `%2F`. */
+function encodeRefPath(ref: string): string {
+ return ref.split("/").map(encodeURIComponent).join("/");
+}
+
+/**
+ * Cheap drift probe: resolve a branch/tag HEAD to its commit SHA using the
+ * lightweight `git/ref` endpoint (~200-byte body vs. the multi-KB commits
+ * API), with optional ETag conditional support.
+ *
+ * - A 40-hex `ref` is a pinned commit — it can never drift, so we return it
+ * immediately with NO network call.
+ * - Branches resolve via `git/ref/heads/`. On a `404` we fall back
+ * to `git/ref/tags/` before declaring the source missing.
+ * - A stored `etag` yields a `304 Not Modified` when nothing changed, which
+ * (for authenticated requests) does not count against the rate limit.
+ *
+ * Caveat: for an *annotated* tag, `object.sha` is the tag object SHA, not
+ * the underlying commit — so a tag-sourced skill's drift comparison is
+ * best-effort. Branches and pinned SHAs (the common cases) compare exactly;
+ * the full-pull path (`resolveRefToSha`) remains the source of truth.
+ */
+export async function resolveRefHeadSha(
+ repo: string,
+ ref: string,
+ opts: RefHeadProbeInput = {},
+ fetchImpl: typeof fetch = globalThis.fetch,
+): Promise {
+ const normalizedRepo = normalizeRepoIdentifier(repo);
+ const trimmedRef = ref.trim();
+ if (/^[0-9a-f]{40}$/i.test(trimmedRef)) {
+ // Pinned commit SHA — immutable, no HTTP needed.
+ return { sha: trimmedRef, notModified: false };
+ }
+
+ const headers = authHeaders(opts.token);
+ if (typeof opts.etag === "string" && opts.etag.length > 0) {
+ headers["If-None-Match"] = opts.etag;
+ }
+
+ const probe = async (
+ refType: "heads" | "tags",
+ ): Promise => {
+ const url = `https://api.github.com/repos/${normalizedRepo}/git/ref/${refType}/${encodeRefPath(trimmedRef)}`;
+ const res = await fetchImpl(url, { headers });
+ if (res.status === 304) return { notModified: true };
+ if (res.status === 404) return "not-found";
+ if (!res.ok) {
+ // A 403/429 rate-limit signal becomes a typed error so the batch
+ // caller can back off; anything else is treated as transient.
+ throw rateLimitOrGeneric(res, `${normalizedRepo}@${trimmedRef}`, Date.now());
+ }
+ const body = (await res.json()) as { object?: { sha?: string } };
+ const sha = body.object?.sha;
+ if (!sha) {
+ throw new Error(
+ `GitHub git/ref API returned no object SHA for ${normalizedRepo}@${trimmedRef}`,
+ );
+ }
+ return { sha, etag: res.headers.get("etag") ?? undefined, notModified: false };
+ };
+
+ const asBranch = await probe("heads");
+ if (asBranch !== "not-found") return asBranch;
+ const asTag = await probe("tags");
+ if (asTag !== "not-found") return asTag;
+ throw new GitHubSourceNotFoundError(normalizedRepo, trimmedRef);
+}
+
async function resolveRefToSha(
repo: string,
ref: string,
fetchImpl: typeof fetch,
+ token?: string,
): Promise {
const url = `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`;
- const res = await fetchImpl(url, {
- headers: {
- Accept: "application/vnd.github+json",
- "User-Agent": "ornn-api/skills-github-pull",
- },
- });
+ const res = await fetchImpl(url, { headers: authHeaders(token) });
if (res.status === 404) {
throw new Error(`Ref '${ref}' not found in ${repo}`);
}
@@ -277,15 +456,11 @@ async function walkContents(
path: string,
fetchImpl: typeof fetch,
out: GitHubContentEntry[],
+ token?: string,
): Promise {
const encodedPath = path ? `/${encodeURI(path)}` : "";
const url = `https://api.github.com/repos/${repo}/contents${encodedPath}?ref=${encodeURIComponent(ref)}`;
- const res = await fetchImpl(url, {
- headers: {
- Accept: "application/vnd.github+json",
- "User-Agent": "ornn-api/skills-github-pull",
- },
- });
+ const res = await fetchImpl(url, { headers: authHeaders(token) });
if (res.status === 404) {
// The target path doesn't exist on this ref. Callers decide whether
// that's acceptable — for the top-level call it isn't; for an empty
@@ -303,7 +478,7 @@ async function walkContents(
if (entry.type === "file") {
out.push(entry);
} else if (entry.type === "dir") {
- await walkContents(repo, ref, entry.path, fetchImpl, out);
+ await walkContents(repo, ref, entry.path, fetchImpl, out, token);
}
// symlinks, submodules → skip
}
diff --git a/ornn-api/src/domains/skills/format/routes.ts b/ornn-api/src/domains/skills/format/routes.ts
index 5dee22b3..a182951f 100644
--- a/ornn-api/src/domains/skills/format/routes.ts
+++ b/ornn-api/src/domains/skills/format/routes.ts
@@ -45,7 +45,7 @@ export const SKILL_FORMAT_RULES = `# Ornn Skill Package Format Rules
### Required Frontmatter Fields
- **name** (string, required): kebab-case only, must match the skill package folder name. No spaces or capitals.
-- **description** (string, required): must be under 1024 characters. Must not contain XML tags (\`<\` or \`>\`).
+- **description** (string, required): must be under 1536 characters. Must not contain XML tags (\`<\` or \`>\`).
- **metadata** (object, required): a nested object containing:
- **category** (string, required): one of \`plain\`, \`tool-based\`, \`runtime-based\`, \`mixed\`.
- \`plain\`: no programmatic dependency (no tools, no scripts).
diff --git a/ornn-api/src/infra/analytics/index.ts b/ornn-api/src/infra/analytics/index.ts
index 46e5b067..d88b6b82 100644
--- a/ornn-api/src/infra/analytics/index.ts
+++ b/ornn-api/src/infra/analytics/index.ts
@@ -65,6 +65,10 @@ export type PlatformActivityAction =
| "skill.source_linked"
| "skill.source_unlinked"
| "skill.agentseal_rescanned"
+ // Automatic source-sync events (#1177).
+ | "skill.source_drift_detected"
+ | "skill.auto_synced"
+ | "skill.auto_sync_failed"
| "settings.exported"
| "settings.imported";
diff --git a/ornn-api/src/infra/config.ts b/ornn-api/src/infra/config.ts
index 25909677..7733a710 100644
--- a/ornn-api/src/infra/config.ts
+++ b/ornn-api/src/infra/config.ts
@@ -87,6 +87,15 @@ export interface SkillConfig {
*/
readonly ornnPublicOrigin: string;
+ /**
+ * Service-account GitHub token for authenticated source-repo reads
+ * (#1175). Env fallback used when the `sourceSync` settings section has no
+ * token. Public-read only — it lifts the 60/hr anonymous rate ceiling, it
+ * grants no access beyond public content. Empty ⇒ reads run anonymously
+ * (rate-limited). Never logged.
+ */
+ readonly sourceSyncGithubToken: string;
+
/** Master passphrase for AES-256-GCM at-rest secret encryption. Required, ≥32 chars; boot fails with ConfigError if missing/short. See ENCRYPTION_KEY in envSchema for full rationale. */
readonly encryptionKey: string;
}
@@ -165,6 +174,11 @@ const envSchema = z.object({
(v) => (v === "" ? undefined : v),
z.string().url().default("https://ornn.chrono-ai.fun"),
),
+
+ // Source-sync GitHub token (#1175). Optional — empty means anonymous,
+ // rate-limited source reads. The `sourceSync` settings section overrides
+ // this at runtime when an admin sets a value there.
+ ORNN_SOURCE_SYNC_GITHUB_TOKEN: z.string().default(""),
});
/**
@@ -228,5 +242,7 @@ export function loadConfig(): SkillConfig {
agentsealEnabled: env.AGENTSEAL_ENABLED !== "false",
ornnPublicOrigin: env.ORNN_PUBLIC_ORIGIN.replace(/\/+$/, ""),
+
+ sourceSyncGithubToken: env.ORNN_SOURCE_SYNC_GITHUB_TOKEN.trim(),
};
}
diff --git a/ornn-api/src/openapi/schemas.ts b/ornn-api/src/openapi/schemas.ts
index 378efb94..aee27f9d 100644
--- a/ornn-api/src/openapi/schemas.ts
+++ b/ornn-api/src/openapi/schemas.ts
@@ -66,7 +66,6 @@ export const skillDetailResponseSchema = z.object({
metadata: z.record(z.string(), z.unknown()).describe("Structured skill metadata including category, outputType, runtimes, tools, and tags. See skill format spec for full schema"),
tags: z.array(z.string()).describe("List of tag names for categorization and search filtering"),
skillHash: z.string().describe("SHA-256 hash of the skill package contents. Changes when the skill is updated"),
- presignedPackageUrl: z.string().describe("Temporary pre-signed URL to download the skill package ZIP file. Expires after a short period"),
isPrivate: z.boolean().describe("If true, only the owner can view and use this skill. If false, the skill is publicly listed in the registry"),
createdBy: z.string().describe("Email address of the user who created the skill"),
createdOn: z.string().describe("ISO 8601 timestamp of when the skill was created"),
diff --git a/ornn-api/src/openapi/specBuilder.ts b/ornn-api/src/openapi/specBuilder.ts
index 4fa1e7e1..b9f5f825 100644
--- a/ornn-api/src/openapi/specBuilder.ts
+++ b/ornn-api/src/openapi/specBuilder.ts
@@ -126,7 +126,7 @@ function skillReadPath(_prefix: string): PathItem {
return {
get: {
summary: "Get skill by GUID or name",
- description: "Retrieve full details of a single skill by its UUID or unique name. Returns metadata, tags, package download URL, visibility status, and timestamps. The presignedPackageUrl can be used to download the raw ZIP package. For accessing individual file contents without downloading the ZIP, use the /json endpoint instead.",
+ description: "Retrieve full details of a single skill by its UUID or unique name. Returns metadata, tags, visibility status, and timestamps. To download the raw ZIP package use GET /skills/{idOrName}/versions/{version}/download; for individual file contents without downloading the ZIP, use the /json endpoint instead.",
operationId: "getSkill",
tags: ["Skills"],
security: bearerAuth(),
@@ -150,6 +150,29 @@ function skillJsonPath(_prefix: string): PathItem {
};
}
+function skillDownloadPath(_prefix: string): PathItem {
+ return {
+ get: {
+ summary: "Download a skill version's package ZIP",
+ description: "Stream the raw ZIP package for a specific skill version. Bytes are proxied from object storage through ornn-api — clients never talk to the storage backend directly. `version` may be a literal (e.g. '1.2') or a dist-tag (e.g. 'latest'). Returns application/zip on success; a private skill the caller cannot read returns 404 (existence is not leaked).",
+ operationId: "downloadSkillPackage",
+ tags: ["Skills"],
+ security: bearerAuth(),
+ parameters: [
+ pathParam("idOrName", "Skill UUID or unique skill name"),
+ pathParam("version", "Version literal (e.g. '1.2') or dist-tag (e.g. 'latest')"),
+ ],
+ responses: {
+ 200: {
+ description: "The raw skill package ZIP bytes",
+ content: { "application/zip": { schema: { type: "string", format: "binary" } } },
+ },
+ ...errorResponses(401, 404),
+ },
+ },
+ };
+}
+
function skillUpdatePath(_prefix: string): PathItem {
return {
put: {
@@ -453,6 +476,7 @@ export function buildSpec(): OpenApiSpec {
[`${prefix}/skills`]: skillUploadPath(prefix),
[`${prefix}/skills/{idOrName}`]: skillReadPath(prefix),
[`${prefix}/skills/{idOrName}/json`]: skillJsonPath(prefix),
+ [`${prefix}/skills/{idOrName}/versions/{version}/download`]: skillDownloadPath(prefix),
[`${prefix}/skills/{id}`]: {
...skillUpdatePath(prefix),
...skillDeletePath(prefix),
diff --git a/ornn-api/src/shared/schemas/skillFrontmatter.test.ts b/ornn-api/src/shared/schemas/skillFrontmatter.test.ts
index e9b30db0..4f3f5e69 100644
--- a/ornn-api/src/shared/schemas/skillFrontmatter.test.ts
+++ b/ornn-api/src/shared/schemas/skillFrontmatter.test.ts
@@ -225,4 +225,15 @@ describe("validateSkillFrontmatter — depends-on grammar (#968)", () => {
const msg = r.errors.find((e) => e.field === "metadata.depends-on.0")?.message ?? "";
expect(msg).toContain("depends-on entries must be non-empty");
});
+
+ test("description at the 1536 cap passes; 1537 fails", () => {
+ // Cap aligned with Claude Code's skill-listing truncation (#1180 follow-up).
+ const ok = validateSkillFrontmatter(base({ description: "x".repeat(1536) }));
+ expect(ok.success).toBe(true);
+
+ const tooLong = validateSkillFrontmatter(base({ description: "x".repeat(1537) }));
+ expect(tooLong.success).toBe(false);
+ if (tooLong.success) return;
+ expect(tooLong.errors.some((e) => e.field === "description")).toBe(true);
+ });
});
diff --git a/ornn-api/src/shared/schemas/skillFrontmatter.ts b/ornn-api/src/shared/schemas/skillFrontmatter.ts
index 5a906bb2..7ec0c74c 100644
--- a/ornn-api/src/shared/schemas/skillFrontmatter.ts
+++ b/ornn-api/src/shared/schemas/skillFrontmatter.ts
@@ -218,11 +218,17 @@ export const SKILL_VERSION_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
*/
export const SKILL_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export const SKILL_NAME_MAX = 64;
+// Aligned with Claude Code's skill-listing budget: it truncates the combined
+// `description` (+ optional `when_to_use`) at 1536 chars when deciding whether
+// to auto-invoke a skill (the `skillListingMaxDescChars` default). Capping here
+// at the same number lets an author write a description as rich as the runtime
+// will actually use for routing, while still rejecting unbounded input.
+export const SKILL_DESCRIPTION_MAX = 1536;
// Full frontmatter schema (base, before the top-level refinement).
const baseSkillFrontmatterSchema = z.object({
name: z.string().min(1).max(SKILL_NAME_MAX).regex(SKILL_NAME_REGEX, "Name must be kebab-case"),
- description: z.string().min(1).max(1024),
+ description: z.string().min(1).max(SKILL_DESCRIPTION_MAX),
// YAML parses `version: 0.1` (unquoted) as a float and `1.0` as an
// integer `1`, which both lose the intended two-digit shape. We require
// the author to quote it (`version: "0.1"`) so the round-trip is
diff --git a/ornn-api/src/shared/types/index.ts b/ornn-api/src/shared/types/index.ts
index 2943fba5..409d6245 100644
--- a/ornn-api/src/shared/types/index.ts
+++ b/ornn-api/src/shared/types/index.ts
@@ -230,6 +230,21 @@ export interface SkillDocument {
distTags?: Record | undefined;
}
+/**
+ * Cached drift verdict from the most recent source-drift check (#1175).
+ * - `in_sync` — upstream HEAD equals `lastSyncedCommit`.
+ * - `drifted` — upstream moved; a re-pull would publish a new version.
+ * - `changed_unversioned` — upstream changed but `SKILL.md` version did not
+ * advance (reserved for the auto-publish phase #1177).
+ * - `broken` — the source repo/ref could not be resolved (404 / made
+ * private / deleted).
+ */
+export type SkillSourceDriftState =
+ | "in_sync"
+ | "drifted"
+ | "changed_unversioned"
+ | "broken";
+
/**
* Origin metadata for a skill pulled from an external source. The `type`
* discriminator lets future additions (GitLab, Bitbucket, ...) live
@@ -256,6 +271,22 @@ export type SkillSource =
* detection. Absent in the same "linked but not yet synced" state.
*/
lastSyncedCommit?: string | undefined;
+ /**
+ * Upstream HEAD commit SHA observed by the most recent drift check
+ * (#1175). When present and different from `lastSyncedCommit`, the
+ * upstream has moved. Written by `checkSourceDrift`; never mutates
+ * the package itself.
+ */
+ upstreamHeadSha?: string | undefined;
+ /**
+ * ETag returned by the last `git/ref` probe, replayed via
+ * `If-None-Match` so an unchanged upstream answers with a free `304`.
+ */
+ etag?: string | undefined;
+ /** Wall-clock time of the most recent drift check. */
+ lastCheckedAt?: Date | undefined;
+ /** Cached drift verdict from the last check. See {@link SkillSourceDriftState}. */
+ driftState?: SkillSourceDriftState | undefined;
};
/**
@@ -360,7 +391,8 @@ export interface SkillDetailResponse {
metadata: Record;
tags: string[];
skillHash: string;
- presignedPackageUrl: string;
+ // Package bytes are fetched via `GET /skills/:idOrName/versions/:version/
+ // download` (#1196) — the response no longer carries a presigned URL.
isPrivate: boolean;
createdBy: string;
// Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657).
@@ -405,6 +437,12 @@ export interface SkillDetailResponse {
lastSyncedAt?: string | undefined;
/** Absent in the same "linked but never synced" state. */
lastSyncedCommit?: string | undefined;
+ /** Upstream HEAD from the last drift check (#1175). */
+ upstreamHeadSha?: string | undefined;
+ /** Wall-clock time of the last drift check (ISO string). */
+ lastCheckedAt?: string | undefined;
+ /** Cached drift verdict. See `SkillSourceDriftState`. */
+ driftState?: SkillSourceDriftState | undefined;
}
| undefined;
/** NyxID service tie (null when untied). See `SkillDocument.nyxidServiceId`. */
diff --git a/ornn-web/src/components/notifications/NotificationDetailModal.tsx b/ornn-web/src/components/notifications/NotificationDetailModal.tsx
index fd32c21a..74fb1aee 100644
--- a/ornn-web/src/components/notifications/NotificationDetailModal.tsx
+++ b/ornn-web/src/components/notifications/NotificationDetailModal.tsx
@@ -41,6 +41,9 @@ const CATEGORY_LABEL: Record = {
"quota.credits_granted": "Quota",
"launchPromo.codeDelivered": "Promo",
"skillset.member_unreadable": "Skillset",
+ "skill.source_broken": "Source",
+ "skill.auto_synced": "Auto-sync",
+ "skill.auto_sync_failed": "Auto-sync",
};
export interface NotificationDetailModalProps {
diff --git a/ornn-web/src/components/skill/AdvancedOptionsModal.test.tsx b/ornn-web/src/components/skill/AdvancedOptionsModal.test.tsx
index 50cbe45d..64d541b0 100644
--- a/ornn-web/src/components/skill/AdvancedOptionsModal.test.tsx
+++ b/ornn-web/src/components/skill/AdvancedOptionsModal.test.tsx
@@ -105,7 +105,6 @@ function skill(overrides: Partial): SkillDetail {
isPrivate: false,
tags: [],
updatedOn: "2026-05-01T00:00:00.000Z",
- presignedPackageUrl: "",
metadata: {},
version: "1.0.0",
sharedWithUsers: [],
diff --git a/ornn-web/src/components/skill/AdvancedOptionsModal.tsx b/ornn-web/src/components/skill/AdvancedOptionsModal.tsx
index 78b4b9b1..c03cce1a 100644
--- a/ornn-web/src/components/skill/AdvancedOptionsModal.tsx
+++ b/ornn-web/src/components/skill/AdvancedOptionsModal.tsx
@@ -26,6 +26,7 @@ import {
import { useToastStore } from "@/stores/toastStore";
import type { RefreshPreviewResponse } from "@/services/skillApi";
import type { SkillDetail } from "@/types/domain";
+import { SourceDriftBadge } from "./SourceDriftBadge";
import { translateError } from "@/utils/translateError";
type AdvancedSettingId = "nyxid-service-binding" | "github-link";
@@ -571,6 +572,8 @@ function GithubLinkPanel({ skill, onClose }: { skill: SkillDetail; onClose: () =
})
: t("githubLink.neverSynced", "Linked but never synced.")}
+ {/* Auto-sync drift status (#1178) — same passive pill as the chip. */}
+
)}
diff --git a/ornn-web/src/components/skill/GitHubOriginChip.test.tsx b/ornn-web/src/components/skill/GitHubOriginChip.test.tsx
new file mode 100644
index 00000000..e2f35abf
--- /dev/null
+++ b/ornn-web/src/components/skill/GitHubOriginChip.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * GitHubOriginChip tests (#1178) — the auto-sync badge shows next to the
+ * "Synced from GitHub" label, and the manual "Refresh from GitHub" override
+ * button remains available alongside it.
+ *
+ * @module components/skill/GitHubOriginChip.test
+ */
+import { describe, expect, it, afterEach, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { GitHubOriginChip } from "./GitHubOriginChip";
+import type { SkillSource } from "@/types/domain";
+
+afterEach(cleanup);
+
+function gh(driftState?: SkillSource["driftState"]): SkillSource {
+ return {
+ type: "github",
+ repo: "o/r",
+ ref: "main",
+ path: "",
+ lastSyncedCommit: "abcdef1234567",
+ ...(driftState ? { driftState } : {}),
+ };
+}
+
+describe("GitHubOriginChip", () => {
+ it("renders the drift badge AND keeps the manual refresh button", () => {
+ render(
+ {}}
+ />,
+ );
+ // Passive badge from driftState…
+ expect(screen.getByText(/version not bumped/i)).toBeInTheDocument();
+ // …and the manual override is still there.
+ expect(screen.getByText("Refresh from GitHub")).toBeInTheDocument();
+ });
+
+ it("broken source shows the danger badge", () => {
+ render(
+ {}} />,
+ );
+ expect(screen.getByText("Source unavailable")).toBeInTheDocument();
+ });
+
+ it("no badge before the first drift check, but the chip still renders", () => {
+ render(
+ {}} />,
+ );
+ expect(screen.getByText("Synced from GitHub")).toBeInTheDocument();
+ expect(screen.queryByText("Source unavailable")).not.toBeInTheDocument();
+ expect(screen.queryByText(/Auto-synced/)).not.toBeInTheDocument();
+ });
+
+ it("fires onRefresh when the manual button is clicked", () => {
+ const onRefresh = vi.fn();
+ render(
+ ,
+ );
+ screen.getByText("Refresh from GitHub").click();
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ornn-web/src/components/skill/GitHubOriginChip.tsx b/ornn-web/src/components/skill/GitHubOriginChip.tsx
index ebefc0c6..520136d8 100644
--- a/ornn-web/src/components/skill/GitHubOriginChip.tsx
+++ b/ornn-web/src/components/skill/GitHubOriginChip.tsx
@@ -9,6 +9,7 @@
import { useTranslation } from "react-i18next";
import type { SkillSource } from "@/types/domain";
+import { SourceDriftBadge } from "./SourceDriftBadge";
interface GitHubOriginChipProps {
source: SkillSource | undefined;
@@ -79,6 +80,9 @@ export function GitHubOriginChip({
{t("githubOrigin.label", "Synced from GitHub")}
+ {/* Passive auto-sync status (#1178) — read-only; the refresh button below
+ remains the manual override. */}
+
= {}): SkillDetail {
isPrivate: true,
tags: [],
updatedOn: "2026-05-01T00:00:00.000Z",
- presignedPackageUrl: "",
metadata: {},
version: "1.0",
sharedWithUsers: [],
diff --git a/ornn-web/src/components/skill/SkillInstallCard.test.tsx b/ornn-web/src/components/skill/SkillInstallCard.test.tsx
index 8059e7b8..d38b7d93 100644
--- a/ornn-web/src/components/skill/SkillInstallCard.test.tsx
+++ b/ornn-web/src/components/skill/SkillInstallCard.test.tsx
@@ -42,7 +42,6 @@ function makeSkill(overrides: Partial = {}): SkillDetail {
isPrivate: false,
tags: ["pdf"],
updatedOn: "2026-06-01T00:00:00.000Z",
- presignedPackageUrl: "https://example.com/pkg.zip",
metadata: {},
version: "1.0.0",
sharedWithUsers: [],
diff --git a/ornn-web/src/components/skill/SourceDriftBadge.test.tsx b/ornn-web/src/components/skill/SourceDriftBadge.test.tsx
new file mode 100644
index 00000000..b0054fa3
--- /dev/null
+++ b/ornn-web/src/components/skill/SourceDriftBadge.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * SourceDriftBadge tests (#1178) — each driftState renders the right copy +
+ * DESIGN.md state-token tone, and nothing renders for the no-drift / non-github
+ * cases so legacy skills look unchanged. react-i18next is globally mocked and
+ * resolves keys from en.json (with {{when}} interpolation).
+ *
+ * @module components/skill/SourceDriftBadge.test
+ */
+import { describe, expect, it, afterEach } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { SourceDriftBadge } from "./SourceDriftBadge";
+import type { SkillSource } from "@/types/domain";
+
+afterEach(cleanup);
+
+function gh(
+ driftState?: SkillSource["driftState"],
+ extra: Partial> = {},
+): SkillSource {
+ return {
+ type: "github",
+ repo: "o/r",
+ ref: "main",
+ path: "",
+ ...(driftState ? { driftState } : {}),
+ ...extra,
+ };
+}
+
+describe("SourceDriftBadge", () => {
+ it("renders nothing before the first drift check (no driftState)", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders nothing for an undefined source", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("in_sync → 'Auto-synced' with success tone", () => {
+ render();
+ const el = screen.getByText(/Auto-synced/);
+ expect(el.className).toContain("text-success");
+ });
+
+ it("drifted → 'Update in progress' with info tone", () => {
+ render();
+ const el = screen.getByText("Update in progress");
+ expect(el.className).toContain("text-info");
+ });
+
+ it("changed_unversioned → warning tone + explanatory copy", () => {
+ render();
+ const el = screen.getByText(/version not bumped/i);
+ expect(el.className).toContain("text-warning");
+ expect(el.getAttribute("title")).toMatch(/bump the version/i);
+ });
+
+ it("broken → danger tone", () => {
+ render();
+ const el = screen.getByText("Source unavailable");
+ expect(el.className).toContain("text-danger");
+ });
+});
diff --git a/ornn-web/src/components/skill/SourceDriftBadge.tsx b/ornn-web/src/components/skill/SourceDriftBadge.tsx
new file mode 100644
index 00000000..2f10ea3d
--- /dev/null
+++ b/ornn-web/src/components/skill/SourceDriftBadge.tsx
@@ -0,0 +1,93 @@
+/**
+ * Passive auto-sync status pill (#1178), driven by `source.driftState`.
+ *
+ * Read-only — it reflects what the scheduled drift check / auto-publish
+ * (#1176/#1177) recorded on the skill's GitHub source. It is NOT an action;
+ * the manual "Refresh from GitHub" button remains the override.
+ *
+ * Renders nothing until the first drift check has run (no `driftState`), or
+ * for non-GitHub sources — so legacy skills look unchanged.
+ *
+ * @module components/skill/SourceDriftBadge
+ */
+
+import { useTranslation } from "react-i18next";
+import type { SkillSource } from "@/types/domain";
+
+/** Compact relative-time ("5m ago", "2h ago", "3d ago", "just now"). */
+function relativeTime(iso: string | undefined): string | null {
+ if (!iso) return null;
+ const then = new Date(iso).getTime();
+ if (Number.isNaN(then)) return null;
+ const deltaSec = Math.round((then - Date.now()) / 1000);
+ const abs = Math.abs(deltaSec);
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+ if (abs < 60) return rtf.format(Math.round(deltaSec), "second");
+ if (abs < 3600) return rtf.format(Math.round(deltaSec / 60), "minute");
+ if (abs < 86400) return rtf.format(Math.round(deltaSec / 3600), "hour");
+ return rtf.format(Math.round(deltaSec / 86400), "day");
+}
+
+export function SourceDriftBadge({
+ source,
+ className,
+}: {
+ source: SkillSource | undefined;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ if (!source || source.type !== "github" || !source.driftState) return null;
+
+ // Tone classes are DESIGN.md semantic state tokens only (no invented palette).
+ // State color is never the sole signal — each variant pairs copy + border.
+ let tone: string;
+ let label: string;
+ let title: string | undefined;
+
+ switch (source.driftState) {
+ case "in_sync": {
+ const when = relativeTime(source.lastSyncedAt);
+ tone = "text-success bg-success-soft border-success/40";
+ label = when
+ ? t("sourceDrift.inSyncAt", "Auto-synced {{when}}", { when })
+ : t("sourceDrift.inSync", "Auto-synced");
+ title = t("sourceDrift.inSyncTitle", "Automatically kept in sync with the GitHub source.");
+ break;
+ }
+ case "drifted":
+ tone = "text-info bg-info-soft border-info/40";
+ label = t("sourceDrift.drifted", "Update in progress");
+ title = t(
+ "sourceDrift.driftedTitle",
+ "Upstream changed — a new version is being published automatically.",
+ );
+ break;
+ case "changed_unversioned":
+ tone = "text-warning bg-warning-soft border-warning/40";
+ label = t("sourceDrift.changedUnversioned", "Upstream changed — version not bumped");
+ title = t(
+ "sourceDrift.changedUnversionedTitle",
+ "The GitHub source changed but its SKILL.md version was not increased, so no new version was published. Bump the version upstream to resume auto-sync.",
+ );
+ break;
+ case "broken":
+ tone = "text-danger bg-danger-soft border-danger/30";
+ label = t("sourceDrift.broken", "Source unavailable");
+ title = t(
+ "sourceDrift.brokenTitle",
+ "The GitHub source could not be reached (deleted, made private, or the branch/tag was removed). Re-link the skill to resume auto-sync.",
+ );
+ break;
+ default:
+ return null;
+ }
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/ornn-web/src/components/skill/TransferOwnershipModal.test.tsx b/ornn-web/src/components/skill/TransferOwnershipModal.test.tsx
index 23b59233..21b93928 100644
--- a/ornn-web/src/components/skill/TransferOwnershipModal.test.tsx
+++ b/ornn-web/src/components/skill/TransferOwnershipModal.test.tsx
@@ -68,7 +68,6 @@ function skill(overrides: Partial = {}): SkillDetail {
isPrivate: true,
tags: [],
updatedOn: "2026-05-01T00:00:00.000Z",
- presignedPackageUrl: "",
metadata: {},
version: "1.0",
sharedWithUsers: [],
diff --git a/ornn-web/src/components/skillset/SkillsetMemberViewer.test.tsx b/ornn-web/src/components/skillset/SkillsetMemberViewer.test.tsx
index 085ea23a..23f15055 100644
--- a/ornn-web/src/components/skillset/SkillsetMemberViewer.test.tsx
+++ b/ornn-web/src/components/skillset/SkillsetMemberViewer.test.tsx
@@ -32,7 +32,7 @@ const MEMBERS = ["alpha@1.0", "beta@2.1"];
beforeEach(() => {
useSkill.mockReturnValue({
- data: { presignedPackageUrl: "https://signed/pkg.zip" },
+ data: { guid: "alpha-guid", version: "1.0" },
isLoading: false,
error: null,
});
diff --git a/ornn-web/src/components/skillset/SkillsetMemberViewer.tsx b/ornn-web/src/components/skillset/SkillsetMemberViewer.tsx
index d23e6630..ca80d966 100644
--- a/ornn-web/src/components/skillset/SkillsetMemberViewer.tsx
+++ b/ornn-web/src/components/skillset/SkillsetMemberViewer.tsx
@@ -8,8 +8,9 @@
*
* Data path (all read-only — NO skill mutation, NO closure write):
* member ref `name@version`
- * → `useSkill(name, version)` → SkillDetail (carries presigned URL)
- * → `useSkillPackage(presignedUrl)` → FileNode tree + text contents map
+ * → `useSkill(name, version)` → SkillDetail (guid + version)
+ * → `useSkillPackage(guid, version)` → FileNode tree + text contents map
+ * (proxied ornn-api download, #1196)
* → `` → file tree + viewer
*
* ACL is enforced upstream: a member the caller can't see (private / removed)
@@ -52,7 +53,7 @@ export function SkillsetMemberViewer({ members, previewRef }: SkillsetMemberView
fileContents,
isLoading: pkgLoading,
error: pkgError,
- } = useSkillPackage(skill?.presignedPackageUrl);
+ } = useSkillPackage(skill?.guid, skill?.version);
const loading = skillLoading || (!!skill && pkgLoading);
diff --git a/ornn-web/src/hooks/usePlaygroundSession.ts b/ornn-web/src/hooks/usePlaygroundSession.ts
index f28864af..8cf34067 100644
--- a/ornn-web/src/hooks/usePlaygroundSession.ts
+++ b/ornn-web/src/hooks/usePlaygroundSession.ts
@@ -49,7 +49,7 @@ export function usePlaygroundSession(skillName: string | null) {
files: packageFiles,
fileContents: packageContents,
isLoading: packageLoading,
- } = useSkillPackage(skill?.presignedPackageUrl);
+ } = useSkillPackage(skill?.guid, skill?.version);
// ── Env vars ────────────────────────────────────────────────────────
const envVarKeys = useMemo(
diff --git a/ornn-web/src/hooks/useSkillDetail.ts b/ornn-web/src/hooks/useSkillDetail.ts
index 1eae601e..b2fcc604 100644
--- a/ornn-web/src/hooks/useSkillDetail.ts
+++ b/ornn-web/src/hooks/useSkillDetail.ts
@@ -42,6 +42,7 @@ import {
useRefreshSkillFromSource,
} from "@/hooks/useSkills";
import { useSkillPackage } from "@/hooks/useSkillPackage";
+import { useSourceDriftProbe } from "@/hooks/useSourceDriftProbe";
import {
useStartAudit,
useAuditSummaryByVersion,
@@ -94,6 +95,10 @@ export function useSkillDetail(idOrName: string | undefined) {
const refreshMutation = useRefreshSkillFromSource(idOrName ?? "");
const startAuditMutation = useStartAudit();
+ // Lazy on-view drift freshening (#1178) — re-reads the detail once when the
+ // github source's last drift check is stale. See useSourceDriftProbe.
+ useSourceDriftProbe(skill?.source, refetch);
+
// 7-day pulls totals — feeds the hero "↓ N pulls · 7d" status pill.
const last7d = useMemo(rangeLast7d, []);
const { data: pulls7d = [] } = useSkillPulls(skill?.name || skill?.guid, {
@@ -113,7 +118,7 @@ export function useSkillDetail(idOrName: string | undefined) {
rawZip,
isLoading: packageLoading,
error: packageError,
- } = useSkillPackage(skill?.presignedPackageUrl);
+ } = useSkillPackage(skill?.guid, skill?.version);
// Three access tiers (#1127) from the shared hook — `canWrite` is what
// lets a write-grantee (not just the owner) see the content-edit UI.
diff --git a/ornn-web/src/hooks/useSkillPackage.test.ts b/ornn-web/src/hooks/useSkillPackage.test.ts
new file mode 100644
index 00000000..c71c68ce
--- /dev/null
+++ b/ornn-web/src/hooks/useSkillPackage.test.ts
@@ -0,0 +1,60 @@
+/**
+ * useSkillPackage — guards the proxied download call (#1196).
+ *
+ * The hook fetches a skill version's ZIP through ornn-api's authenticated
+ * download route. The path MUST carry the `/api/v1` prefix like every other
+ * apiClient call — a missing prefix silently 404s at the NyxID proxy (the bug
+ * that shipped in the first cut, invisible because component tests mock this
+ * hook wholesale).
+ */
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const apiGetBinary = vi.fn();
+vi.mock("@/services/apiClient", () => ({
+ apiGetBinary: (...a: unknown[]) => apiGetBinary(...a),
+ ApiClientError: class ApiClientError extends Error {
+ statusCode = 0;
+ },
+}));
+
+const loadAsync = vi.fn();
+vi.mock("jszip", () => ({
+ default: { loadAsync: (...a: unknown[]) => loadAsync(...a) },
+}));
+
+import { useSkillPackage } from "./useSkillPackage";
+
+describe("useSkillPackage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ apiGetBinary.mockResolvedValue(new ArrayBuffer(8));
+ // Minimal JSZip stub: an empty archive (forEach yields nothing).
+ loadAsync.mockResolvedValue({ forEach: () => {} });
+ });
+
+ it("downloads via the /api/v1 proxied route using guid + version", async () => {
+ renderHook(() => useSkillPackage("guid-123", "1.2"));
+ await waitFor(() =>
+ expect(apiGetBinary).toHaveBeenCalledWith(
+ "/api/v1/skills/guid-123/versions/1.2/download",
+ ),
+ );
+ });
+
+ it("encodes guid/version and never omits the /api/v1 prefix", async () => {
+ renderHook(() => useSkillPackage("a/b", "latest"));
+ await waitFor(() =>
+ expect(apiGetBinary).toHaveBeenCalledWith(
+ "/api/v1/skills/a%2Fb/versions/latest/download",
+ ),
+ );
+ });
+
+ it("does not fetch until both guid and version are present", () => {
+ renderHook(() => useSkillPackage(undefined, "1.2"));
+ renderHook(() => useSkillPackage("guid-123", undefined));
+ expect(apiGetBinary).not.toHaveBeenCalled();
+ });
+});
diff --git a/ornn-web/src/hooks/useSkillPackage.ts b/ornn-web/src/hooks/useSkillPackage.ts
index 3130c701..944153ac 100644
--- a/ornn-web/src/hooks/useSkillPackage.ts
+++ b/ornn-web/src/hooks/useSkillPackage.ts
@@ -6,6 +6,7 @@ import {
type FileTreeEntry,
} from "@/utils/fileTreeBuilder";
import { encodeErrorPayload } from "@/utils/translateError";
+import { apiGetBinary, ApiClientError } from "@/services/apiClient";
/** Extensions treated as viewable text files */
const TEXT_EXTENSIONS = new Set([
@@ -56,11 +57,13 @@ interface UseSkillPackageResult {
}
/**
- * Fetches a skill ZIP from a presigned URL, extracts it with JSZip,
- * and returns a FileNode tree + text file contents map.
+ * Fetches a skill version's ZIP from the ornn-api proxied download endpoint
+ * (#1196 — no direct chrono-bucket / presigned-URL fetch), extracts it with
+ * JSZip, and returns a FileNode tree + text file contents map.
*/
export function useSkillPackage(
- presignedUrl: string | undefined,
+ guid: string | undefined,
+ version: string | undefined,
): UseSkillPackageResult {
const [files, setFiles] = useState([]);
const [fileContents, setFileContents] = useState