feat(core): Add pluggable auth providers, storage hooks, multi-upstream cache awareness, and PyPI/RubyGems protocol implementations
This commit is contained in:
@@ -1,17 +1,54 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IStorageConfig, IStorageBackend } from './interfaces.core.js';
|
||||
import type { IStorageConfig, IStorageBackend, TRegistryProtocol } from './interfaces.core.js';
|
||||
import type {
|
||||
IStorageHooks,
|
||||
IStorageHookContext,
|
||||
IStorageActor,
|
||||
IStorageMetadata,
|
||||
} from './interfaces.storage.js';
|
||||
|
||||
/**
|
||||
* Storage abstraction layer for registry
|
||||
* Provides a unified interface over SmartBucket
|
||||
* Storage abstraction layer for registry.
|
||||
* Provides a unified interface over SmartBucket with optional hooks
|
||||
* for quota tracking, audit logging, cache invalidation, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage
|
||||
* const storage = new RegistryStorage(config);
|
||||
*
|
||||
* // With hooks for quota tracking
|
||||
* const storage = new RegistryStorage(config, {
|
||||
* beforePut: async (ctx) => {
|
||||
* const quota = await getQuota(ctx.actor?.orgId);
|
||||
* const usage = await getUsage(ctx.actor?.orgId);
|
||||
* if (usage + (ctx.metadata?.size || 0) > quota) {
|
||||
* return { allowed: false, reason: 'Quota exceeded' };
|
||||
* }
|
||||
* return { allowed: true };
|
||||
* },
|
||||
* afterPut: async (ctx) => {
|
||||
* await updateUsage(ctx.actor?.orgId, ctx.metadata?.size || 0);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class RegistryStorage implements IStorageBackend {
|
||||
private smartBucket: plugins.smartbucket.SmartBucket;
|
||||
private bucket: plugins.smartbucket.Bucket;
|
||||
private bucketName: string;
|
||||
private hooks?: IStorageHooks;
|
||||
|
||||
constructor(private config: IStorageConfig) {
|
||||
constructor(private config: IStorageConfig, hooks?: IStorageHooks) {
|
||||
this.bucketName = config.bucketName;
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set storage hooks (can be called after construction)
|
||||
*/
|
||||
public setHooks(hooks: IStorageHooks): void {
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +71,24 @@ export class RegistryStorage implements IStorageBackend {
|
||||
*/
|
||||
public async getObject(key: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await this.bucket.fastGet({ path: key });
|
||||
const data = await this.bucket.fastGet({ path: key });
|
||||
|
||||
// Call afterGet hook (non-blocking)
|
||||
if (this.hooks?.afterGet && data) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
this.hooks.afterGet({
|
||||
operation: 'get',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
}).catch(() => {}); // Don't fail on hook errors
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
@@ -48,19 +102,159 @@ export class RegistryStorage implements IStorageBackend {
|
||||
data: Buffer,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<void> {
|
||||
// Call beforePut hook if available
|
||||
if (this.hooks?.beforePut) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
const hookContext: IStorageHookContext = {
|
||||
operation: 'put',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: {
|
||||
...context.metadata,
|
||||
size: data.length,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const result = await this.hooks.beforePut(hookContext);
|
||||
if (!result.allowed) {
|
||||
throw new Error(result.reason || 'Storage operation denied by hook');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: SmartBucket doesn't support metadata yet
|
||||
await this.bucket.fastPut({
|
||||
path: key,
|
||||
contents: data,
|
||||
overwrite: true, // Always overwrite existing objects
|
||||
});
|
||||
|
||||
// Call afterPut hook (non-blocking)
|
||||
if (this.hooks?.afterPut) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
this.hooks.afterPut({
|
||||
operation: 'put',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: {
|
||||
...context.metadata,
|
||||
size: data.length,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
}).catch(() => {}); // Don't fail on hook errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object
|
||||
*/
|
||||
public async deleteObject(key: string): Promise<void> {
|
||||
// Call beforeDelete hook if available
|
||||
if (this.hooks?.beforeDelete) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
const hookContext: IStorageHookContext = {
|
||||
operation: 'delete',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const result = await this.hooks.beforeDelete(hookContext);
|
||||
if (!result.allowed) {
|
||||
throw new Error(result.reason || 'Delete operation denied by hook');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.bucket.fastRemove({ path: key });
|
||||
|
||||
// Call afterDelete hook (non-blocking)
|
||||
if (this.hooks?.afterDelete) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
this.hooks.afterDelete({
|
||||
operation: 'delete',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
}).catch(() => {}); // Don't fail on hook errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CONTEXT FOR HOOKS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Current operation context for hooks.
|
||||
* Set this before performing storage operations to enable hooks.
|
||||
*/
|
||||
private currentContext?: {
|
||||
protocol: TRegistryProtocol;
|
||||
actor?: IStorageActor;
|
||||
metadata?: IStorageMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the current operation context for hooks.
|
||||
* Call this before performing storage operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* storage.setContext({
|
||||
* protocol: 'npm',
|
||||
* actor: { userId: 'user123', ip: '192.168.1.1' },
|
||||
* metadata: { packageName: 'lodash', version: '4.17.21' }
|
||||
* });
|
||||
* await storage.putNpmTarball('lodash', '4.17.21', tarball);
|
||||
* storage.clearContext();
|
||||
* ```
|
||||
*/
|
||||
public setContext(context: {
|
||||
protocol: TRegistryProtocol;
|
||||
actor?: IStorageActor;
|
||||
metadata?: IStorageMetadata;
|
||||
}): void {
|
||||
this.currentContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current operation context.
|
||||
*/
|
||||
public clearContext(): void {
|
||||
this.currentContext = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with a temporary context.
|
||||
* Context is automatically cleared after execution.
|
||||
*/
|
||||
public async withContext<T>(
|
||||
context: {
|
||||
protocol: TRegistryProtocol;
|
||||
actor?: IStorageActor;
|
||||
metadata?: IStorageMetadata;
|
||||
},
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
this.setContext(context);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user