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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@
"type": "string"
}
},
"eventHandlers": {
"type": "array",
"description": "List of event handler modules that observe GitProxy lifecycle events (push/pull started, completed, error, permission denied). Handlers run asynchronously and are isolated from the request path. Each value is either a file path or a module name.",
"items": {
"type": "string"
}
},
"authorisedList": {
"description": "List of repositories that are authorised to be pushed to through the proxy.",
"type": "array",
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
"import": "./dist/src/plugin.js",
"require": "./dist/src/plugin.js"
},
"./eventHandlers": {
"types": "./dist/src/eventHandlers/index.d.ts",
"import": "./dist/src/eventHandlers/index.js",
"require": "./dist/src/eventHandlers/index.js"
},
"./proxy": {
"types": "./dist/src/proxy/index.d.ts",
"import": "./dist/src/proxy/index.js",
Expand Down
1 change: 1 addition & 0 deletions proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
"contactEmail": "",
"csrfProtection": true,
"plugins": [],
"eventHandlers": [],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config schema shows the eventHandlers array, but there are no examples of how to configure event handlers in proxy.config.json. Consider to document example

"apiAuthentication": [
{
"type": "jwt",
Expand Down
7 changes: 7 additions & 0 deletions src/config/generated/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface GitProxyConfig {
* Provide custom URLs for the git proxy interfaces in case it cannot determine its own URL
*/
domains?: Domains;
/**
* List of event handler modules that observe GitProxy lifecycle events (push/pull started,
* completed, error, permission denied). Handlers run asynchronously and are isolated from
* the request path. Each value is either a file path or a module name.
*/
eventHandlers?: string[];
/**
* List of plugins to integrate on GitProxy's push or pull actions. Each value is either a
* file path or a module name.
Expand Down Expand Up @@ -769,6 +775,7 @@ const typeMap: any = {
{ json: 'cookieSecret', js: 'cookieSecret', typ: u(undefined, '') },
{ json: 'csrfProtection', js: 'csrfProtection', typ: u(undefined, true) },
{ json: 'domains', js: 'domains', typ: u(undefined, r('Domains')) },
{ json: 'eventHandlers', js: 'eventHandlers', typ: u(undefined, a('')) },
{ json: 'plugins', js: 'plugins', typ: u(undefined, a('')) },
{ json: 'privateOrganizations', js: 'privateOrganizations', typ: u(undefined, a('any')) },
{ json: 'proxyUrl', js: 'proxyUrl', typ: u(undefined, '') },
Expand Down
6 changes: 6 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ export const getPlugins = () => {
return config.plugins || [];
};

// Get loadable event handler modules (observe push/pull lifecycle events)
export const getEventHandlers = (): string[] => {
const config = loadFullConfiguration();
return config.eventHandlers || [];
};

export const getTLSKeyPemPath = (): string | undefined => {
const config = loadFullConfiguration();
return config.tls?.key && config.tls.key !== '' ? config.tls.key : config.sslKeyPemPath;
Expand Down
60 changes: 60 additions & 0 deletions src/eventHandlers/EventHandlerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { IProxyEventRegistry } from './types';

/**
* A plugin which subscribes one or more event handlers to GitProxy lifecycle
* events. Loaded at startup from the `eventHandlers` config key.
*
* Subclasses (or instances constructed with a `register` function) declare
* their subscriptions inside `register(registry)`. The registry is the only
* way handlers are wired in; event handler plugins do not participate in the
* action chain and cannot block or modify operations.
*/
export class EventHandlerPlugin {
isGitProxyPlugin: boolean;
isGitProxyEventHandlerPlugin: boolean;
register: (registry: IProxyEventRegistry) => void;

constructor(register: (registry: IProxyEventRegistry) => void) {
this.isGitProxyPlugin = true;
this.isGitProxyEventHandlerPlugin = true;
this.register = register;
}
}

/**
* Checks whether a value looks like an EventHandlerPlugin (loaded from an
* external module). Mirrors `isCompatiblePlugin` in src/plugin.ts but checks
* for `register` rather than `exec`.
*/
export const isEventHandlerPlugin = (obj: unknown): obj is EventHandlerPlugin => {
let cursor: unknown = obj;
while (cursor != null) {
if (
typeof cursor === 'object' &&
Object.prototype.hasOwnProperty.call(cursor, 'isGitProxyEventHandlerPlugin') &&
(cursor as { isGitProxyEventHandlerPlugin?: unknown }).isGitProxyEventHandlerPlugin ===
true &&
typeof (cursor as { register?: unknown }).register === 'function'
) {
return true;
}
cursor = Object.getPrototypeOf(cursor);
}
return false;
};
60 changes: 60 additions & 0 deletions src/eventHandlers/builtin/consoleLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { EventHandlerPlugin } from '../EventHandlerPlugin';
import { ActionPhase, EventDetails, IProxyEventRegistry, ProxyOperation } from '../types';

const formatLine = (details: EventDetails): string => {
const payload: Record<string, unknown> = {
event: 'gitproxy.action',
actionId: details.actionId,
operation: details.operation,
phase: details.phase,
timestamp: new Date(details.timestamp).toISOString(),
repo: details.repository.url,
};
if (details.user?.username) payload.username = details.user.username;
if (details.user?.email) payload.userEmail = details.user.email;
if (details.error) payload.error = details.error.message;
return JSON.stringify(payload);
};

const log = (details: EventDetails): void => {
console.info(formatLine(details));
};

const phases: ActionPhase[] = ['started', 'completed', 'error', 'permissionDenied'];
const operations: ProxyOperation[] = ['push', 'pull'];

/**
* Built-in event handler that emits a structured JSON line for every
* lifecycle event. Always loaded so deployments get an automatic audit trail
* without needing to write or configure a handler. To silence, filter at the
* log aggregator on `"event":"gitproxy.action"`.
*/
export const consoleLoggerEventHandler = new EventHandlerPlugin(function registerConsoleLogger(
registry: IProxyEventRegistry,
) {
for (const op of operations) {
const builder = op === 'push' ? registry.onPush() : registry.onPull();
for (const phase of phases) {
if (phase === 'started') builder.onStarted(log);
else if (phase === 'completed') builder.onCompleted(log);
else if (phase === 'error') builder.onError(log);
else if (phase === 'permissionDenied') builder.onPermissionDenied(log);
}
}
});
141 changes: 141 additions & 0 deletions src/eventHandlers/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Action } from '../proxy/actions';
import { ProxyEventRegistry } from './registry';
import {
ActionEventCallback,
ActionPhase,
EventDetails,
ProxyOperation,
RepositoryContext,
UserContext,
} from './types';

const buildRepositoryContext = (action: Action): RepositoryContext => ({
url: action.url,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same problem like line 38, don't return references, return values instead:

name: action.repoName? String(action.repoName) : undefined,
url: String(action.url),

project: action.project,
name: action.repoName,
});

const buildUserContext = (action: Action): UserContext | undefined => {
if (!action.user && !action.userEmail) {
return undefined;
}
return { username: action.user, email: action.userEmail };
Copy link
Copy Markdown

@re-vlad re-vlad May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventDetails.user and repository must be immutable copies (using String()) of Action fields — not references. Otherwise, async handlers may see stale or modified data if Action is mutated later in the chain. This rather critical I believe, but easy to fix:

return { 
  username = action.user ? String(action.user) : undefined;
  email = action.userEmail ? String(action.userEmail) : undefined;
}```. 

};

/**
* Translates an Action's type string to the ProxyOperation surfaced to
* handlers, or null when the action should not produce an event (e.g.
* "default" actions used for protocol pings).
*/
const operationFor = (action: Action): ProxyOperation | null => {
if (action.type === 'push') return 'push';
if (action.type === 'pull') return 'pull';
return null;
};

export class EventDispatcher {
private readonly registry: ProxyEventRegistry;
private readonly inflight: Set<Promise<void>> = new Set();

constructor(registry: ProxyEventRegistry) {
this.registry = registry;
}

/**
* Invokes registered handlers for a phase. Returns synchronously — handlers
* run on the event loop without delaying the request path. Errors thrown by
* a handler are caught and logged; they never propagate.
*/
public dispatch(action: Action, phase: ActionPhase, error?: Error): void {
const operation = operationFor(action);
if (!operation) return;

const handlers = this.registry.get(operation, phase);
if (handlers.length === 0) return;

const details: EventDetails = {
actionId: action.id,
operation,
phase,
timestamp: Date.now(),
repository: buildRepositoryContext(action),
user: buildUserContext(action),
error,
};

for (const handler of handlers) {
this.invoke(handler, details);
}
}

private invoke(handler: ActionEventCallback, details: EventDetails): void {
const handlerName = handler.name || 'anonymous';
let promise: Promise<void>;
try {
promise = Promise.resolve(handler(details));
} catch (err) {
this.logHandlerError(handlerName, details, err);
return;
}

const tracked = promise.catch((err) => this.logHandlerError(handlerName, details, err));
this.inflight.add(tracked);
tracked.finally(() => this.inflight.delete(tracked));
}

private logHandlerError(handlerName: string, details: EventDetails, err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
console.error(
`Event handler "${handlerName}" failed for ${details.operation}.${details.phase}: ${message}`,
);
}

/**
* Awaits in-flight handler promises, bounded by `timeoutMs`. Used by
* Proxy.stop() to give handlers a chance to finish before the process
* exits. Handlers still running past the timeout are abandoned with a warning.
*/
async drain(timeoutMs: number): Promise<void> {
if (this.inflight.size === 0) return;

const inflightCount = this.inflight.size;
const all = Promise.allSettled([...this.inflight]).then(() => undefined);
const timeout = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), timeoutMs),
);
const result = await Promise.race([all.then(() => 'done' as const), timeout]);
if (result === 'timeout') {
console.warn(
`Event dispatcher drain timed out after ${timeoutMs}ms with ${this.inflight.size}/${inflightCount} handler(s) still running; abandoning.`,
);
}
}
}

let activeDispatcher: EventDispatcher | null = null;

export const setEventDispatcher = (dispatcher: EventDispatcher | null): void => {
activeDispatcher = dispatcher;
};

export const getEventDispatcher = (): EventDispatcher | null => activeDispatcher;

export const resetEventDispatcher = (): void => {
activeDispatcher = null;
};
18 changes: 18 additions & 0 deletions src/eventHandlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export * from './types';
export { EventHandlerPlugin, isEventHandlerPlugin } from './EventHandlerPlugin';
Loading
Loading