feat: implement account settings and API tokens management

- Added SettingsComponent for user profile management, including display name and password change functionality.
- Introduced TokensComponent for managing API tokens, including creation and revocation.
- Created LayoutComponent for consistent application layout with navigation and user information.
- Established main application structure in index.html and main.ts.
- Integrated Tailwind CSS for styling and responsive design.
- Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
2025-11-27 22:15:38 +00:00
parent a6c6ea1393
commit ab88ac896f
71 changed files with 9446 additions and 0 deletions

167
ts/models/apitoken.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* ApiToken model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class ApiToken
extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
implements IApiToken
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId: string = '';
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index({ unique: true })
public tokenHash: string = '';
@plugins.smartdata.svDb()
public tokenPrefix: string = '';
@plugins.smartdata.svDb()
public protocols: TRegistryProtocol[] = [];
@plugins.smartdata.svDb()
public scopes: ITokenScope[] = [];
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public expiresAt?: Date;
@plugins.smartdata.svDb()
public lastUsedAt?: Date;
@plugins.smartdata.svDb()
public lastUsedIp?: string;
@plugins.smartdata.svDb()
public usageCount: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public isRevoked: boolean = false;
@plugins.smartdata.svDb()
public revokedAt?: Date;
@plugins.smartdata.svDb()
public revokedReason?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public createdIp?: string;
/**
* Find token by hash
*/
public static async findByHash(tokenHash: string): Promise<ApiToken | null> {
return await ApiToken.getInstance({
tokenHash,
isRevoked: false,
});
}
/**
* Find token by prefix (for listing)
*/
public static async findByPrefix(tokenPrefix: string): Promise<ApiToken | null> {
return await ApiToken.getInstance({
tokenPrefix,
});
}
/**
* Get all tokens for a user
*/
public static async getUserTokens(userId: string): Promise<ApiToken[]> {
return await ApiToken.getInstances({
userId,
isRevoked: false,
});
}
/**
* Check if token is valid (not expired, not revoked)
*/
public isValid(): boolean {
if (this.isRevoked) return false;
if (this.expiresAt && this.expiresAt < new Date()) return false;
return true;
}
/**
* Record token usage
*/
public async recordUsage(ip?: string): Promise<void> {
this.lastUsedAt = new Date();
this.lastUsedIp = ip;
this.usageCount += 1;
await this.save();
}
/**
* Revoke token
*/
public async revoke(reason?: string): Promise<void> {
this.isRevoked = true;
this.revokedAt = new Date();
this.revokedReason = reason;
await this.save();
}
/**
* Check if token has permission for protocol
*/
public hasProtocol(protocol: TRegistryProtocol): boolean {
return this.protocols.includes(protocol) || this.protocols.includes('*' as TRegistryProtocol);
}
/**
* Check if token has permission for action on resource
*/
public hasScope(
protocol: TRegistryProtocol,
organizationId?: string,
repositoryId?: string,
action?: string
): boolean {
for (const scope of this.scopes) {
// Check protocol
if (scope.protocol !== '*' && scope.protocol !== protocol) continue;
// Check organization
if (scope.organizationId && scope.organizationId !== organizationId) continue;
// Check repository
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
// Check action
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
return true;
}
return false;
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await ApiToken.getNewId();
}
}
}

171
ts/models/auditlog.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* AuditLog model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class AuditLog
extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog>
implements IAuditLog
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public actorId?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public actorType: 'user' | 'api_token' | 'system' | 'anonymous' = 'anonymous';
@plugins.smartdata.svDb()
public actorTokenId?: string;
@plugins.smartdata.svDb()
public actorIp?: string;
@plugins.smartdata.svDb()
public actorUserAgent?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public action: TAuditAction = 'USER_CREATED';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public resourceType: TAuditResourceType = 'user';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public resourceId?: string;
@plugins.smartdata.svDb()
public resourceName?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public repositoryId?: string;
@plugins.smartdata.svDb()
public metadata: Record<string, unknown> = {};
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public success: boolean = true;
@plugins.smartdata.svDb()
public errorCode?: string;
@plugins.smartdata.svDb()
public errorMessage?: string;
@plugins.smartdata.svDb()
public durationMs?: number;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public timestamp: Date = new Date();
/**
* Create an audit log entry
*/
public static async log(data: {
actorId?: string;
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
actorTokenId?: string;
actorIp?: string;
actorUserAgent?: string;
action: TAuditAction;
resourceType: TAuditResourceType;
resourceId?: string;
resourceName?: string;
organizationId?: string;
repositoryId?: string;
metadata?: Record<string, unknown>;
success?: boolean;
errorCode?: string;
errorMessage?: string;
durationMs?: number;
}): Promise<AuditLog> {
const log = new AuditLog();
log.id = await AuditLog.getNewId();
log.actorId = data.actorId;
log.actorType = data.actorType || (data.actorId ? 'user' : 'anonymous');
log.actorTokenId = data.actorTokenId;
log.actorIp = data.actorIp;
log.actorUserAgent = data.actorUserAgent;
log.action = data.action;
log.resourceType = data.resourceType;
log.resourceId = data.resourceId;
log.resourceName = data.resourceName;
log.organizationId = data.organizationId;
log.repositoryId = data.repositoryId;
log.metadata = data.metadata || {};
log.success = data.success ?? true;
log.errorCode = data.errorCode;
log.errorMessage = data.errorMessage;
log.durationMs = data.durationMs;
log.timestamp = new Date();
await log.save();
return log;
}
/**
* Query audit logs with filters
*/
public static async query(filters: {
actorId?: string;
organizationId?: string;
repositoryId?: string;
resourceType?: TAuditResourceType;
action?: TAuditAction[];
success?: boolean;
startDate?: Date;
endDate?: Date;
offset?: number;
limit?: number;
}): Promise<{ logs: AuditLog[]; total: number }> {
const query: Record<string, unknown> = {};
if (filters.actorId) query.actorId = filters.actorId;
if (filters.organizationId) query.organizationId = filters.organizationId;
if (filters.repositoryId) query.repositoryId = filters.repositoryId;
if (filters.resourceType) query.resourceType = filters.resourceType;
if (filters.action) query.action = { $in: filters.action };
if (filters.success !== undefined) query.success = filters.success;
if (filters.startDate || filters.endDate) {
query.timestamp = {};
if (filters.startDate) (query.timestamp as Record<string, unknown>).$gte = filters.startDate;
if (filters.endDate) (query.timestamp as Record<string, unknown>).$lte = filters.endDate;
}
// Get total count
const allLogs = await AuditLog.getInstances(query);
const total = allLogs.length;
// Apply pagination
const offset = filters.offset || 0;
const limit = filters.limit || 100;
const logs = allLogs.slice(offset, offset + limit);
return { logs, total };
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await AuditLog.getNewId();
}
}
}

57
ts/models/db.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Database connection singleton
*/
import * as plugins from '../plugins.ts';
let dbInstance: plugins.smartdata.SmartdataDb | null = null;
/**
* Initialize database connection
*/
export async function initDb(config: {
mongoDbUrl: string;
mongoDbName?: string;
}): Promise<plugins.smartdata.SmartdataDb> {
if (dbInstance) {
return dbInstance;
}
dbInstance = new plugins.smartdata.SmartdataDb({
mongoDbUrl: config.mongoDbUrl,
mongoDbName: config.mongoDbName || 'stackregistry',
});
await dbInstance.init();
console.log('Database connected successfully');
return dbInstance;
}
/**
* Get database instance (must call initDb first)
*/
export function getDb(): plugins.smartdata.SmartdataDb {
if (!dbInstance) {
throw new Error('Database not initialized. Call initDb() first.');
}
return dbInstance;
}
/**
* Close database connection
*/
export async function closeDb(): Promise<void> {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
console.log('Database connection closed');
}
}
/**
* Check if database is connected
*/
export function isDbConnected(): boolean {
return dbInstance !== null;
}

16
ts/models/index.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Model exports
*/
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
export { User } from './user.ts';
export { Organization } from './organization.ts';
export { OrganizationMember } from './organization.member.ts';
export { Team } from './team.ts';
export { TeamMember } from './team.member.ts';
export { Repository } from './repository.ts';
export { RepositoryPermission } from './repository.permission.ts';
export { Package } from './package.ts';
export { ApiToken } from './apitoken.ts';
export { Session } from './session.ts';
export { AuditLog } from './auditlog.ts';

View File

@@ -0,0 +1,109 @@
/**
* OrganizationMember model - links users to organizations with roles
*/
import * as plugins from '../plugins.ts';
import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class OrganizationMember
extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember>
implements IOrganizationMember
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public role: TOrganizationRole = 'member';
@plugins.smartdata.svDb()
public invitedBy?: string;
@plugins.smartdata.svDb()
public joinedAt: Date = new Date();
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
/**
* Add a member to an organization
*/
public static async addMember(data: {
organizationId: string;
userId: string;
role: TOrganizationRole;
invitedBy?: string;
}): Promise<OrganizationMember> {
// Check if member already exists
const existing = await OrganizationMember.getInstance({
organizationId: data.organizationId,
userId: data.userId,
});
if (existing) {
throw new Error('User is already a member of this organization');
}
const member = new OrganizationMember();
member.id = await OrganizationMember.getNewId();
member.organizationId = data.organizationId;
member.userId = data.userId;
member.role = data.role;
member.invitedBy = data.invitedBy;
member.joinedAt = new Date();
member.createdAt = new Date();
await member.save();
return member;
}
/**
* Find membership for user in organization
*/
public static async findMembership(
organizationId: string,
userId: string
): Promise<OrganizationMember | null> {
return await OrganizationMember.getInstance({
organizationId,
userId,
});
}
/**
* Get all members of an organization
*/
public static async getOrgMembers(organizationId: string): Promise<OrganizationMember[]> {
return await OrganizationMember.getInstances({
organizationId,
});
}
/**
* Get all organizations a user belongs to
*/
public static async getUserOrganizations(userId: string): Promise<OrganizationMember[]> {
return await OrganizationMember.getInstances({
userId,
});
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await OrganizationMember.getNewId();
}
}
}

138
ts/models/organization.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Organization model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type {
IOrganization,
IOrganizationSettings,
TOrganizationPlan,
} from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
const DEFAULT_SETTINGS: IOrganizationSettings = {
requireMfa: false,
allowPublicRepositories: true,
defaultRepositoryVisibility: 'private',
allowedProtocols: ['oci', 'npm', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
};
@plugins.smartdata.Collection(() => getDb())
export class Organization
extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
implements IOrganization
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public name: string = ''; // URL-safe slug
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public displayName: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public avatarUrl?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public plan: TOrganizationPlan = 'free';
@plugins.smartdata.svDb()
public settings: IOrganizationSettings = DEFAULT_SETTINGS;
@plugins.smartdata.svDb()
public billingEmail?: string;
@plugins.smartdata.svDb()
public isVerified: boolean = false;
@plugins.smartdata.svDb()
public verifiedDomains: string[] = [];
@plugins.smartdata.svDb()
public storageQuotaBytes: number = 5 * 1024 * 1024 * 1024; // 5GB default
@plugins.smartdata.svDb()
public usedStorageBytes: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdById: string = '';
/**
* Create a new organization
*/
public static async createOrganization(data: {
name: string;
displayName: string;
description?: string;
createdById: string;
}): Promise<Organization> {
// Validate name (URL-safe)
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name)) {
throw new Error(
'Organization name must be lowercase alphanumeric with optional hyphens'
);
}
const org = new Organization();
org.id = await Organization.getNewId();
org.name = data.name.toLowerCase();
org.displayName = data.displayName;
org.description = data.description;
org.createdById = data.createdById;
org.settings = { ...DEFAULT_SETTINGS };
org.createdAt = new Date();
org.updatedAt = new Date();
await org.save();
return org;
}
/**
* Find organization by name (slug)
*/
public static async findByName(name: string): Promise<Organization | null> {
return await Organization.getInstance({ name: name.toLowerCase() });
}
/**
* Check if storage quota is exceeded
*/
public hasStorageAvailable(additionalBytes: number): boolean {
if (this.storageQuotaBytes < 0) return true; // Unlimited
return this.usedStorageBytes + additionalBytes <= this.storageQuotaBytes;
}
/**
* Update storage usage
*/
public async updateStorageUsage(deltaBytes: number): Promise<void> {
this.usedStorageBytes = Math.max(0, this.usedStorageBytes + deltaBytes);
await this.save();
}
/**
* Lifecycle hook: Update timestamps before save
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await Organization.getNewId();
}
}
}

195
ts/models/package.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Package model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type {
IPackage,
IPackageVersion,
IProtocolMetadata,
} from '../interfaces/package.interfaces.ts';
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package> implements IPackage {
@plugins.smartdata.unI()
public id: string = ''; // {protocol}:{org}:{name}
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public repositoryId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public protocol: TRegistryProtocol = 'npm';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index()
public name: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public description?: string;
@plugins.smartdata.svDb()
public versions: Record<string, IPackageVersion> = {};
@plugins.smartdata.svDb()
public distTags: Record<string, string> = {}; // e.g., { latest: "1.0.0" }
@plugins.smartdata.svDb()
public metadata: IProtocolMetadata = { type: 'npm' };
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public isPrivate: boolean = true;
@plugins.smartdata.svDb()
public storageBytes: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public downloadCount: number = 0;
@plugins.smartdata.svDb()
public starCount: number = 0;
@plugins.smartdata.svDb()
public cacheExpiresAt?: Date;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdById: string = '';
/**
* Generate package ID
*/
public static generateId(protocol: TRegistryProtocol, orgName: string, name: string): string {
return `${protocol}:${orgName}:${name}`;
}
/**
* Find package by ID
*/
public static async findById(id: string): Promise<Package | null> {
return await Package.getInstance({ id });
}
/**
* Find package by protocol, org, and name
*/
public static async findByName(
protocol: TRegistryProtocol,
orgName: string,
name: string
): Promise<Package | null> {
const id = Package.generateId(protocol, orgName, name);
return await Package.findById(id);
}
/**
* Get packages in an organization
*/
public static async getOrgPackages(organizationId: string): Promise<Package[]> {
return await Package.getInstances({ organizationId });
}
/**
* Search packages
*/
public static async search(
query: string,
options?: {
protocol?: TRegistryProtocol;
organizationId?: string;
isPrivate?: boolean;
limit?: number;
offset?: number;
}
): Promise<Package[]> {
const filter: Record<string, unknown> = {};
if (options?.protocol) filter.protocol = options.protocol;
if (options?.organizationId) filter.organizationId = options.organizationId;
if (options?.isPrivate !== undefined) filter.isPrivate = options.isPrivate;
// Simple text search - in production, would use MongoDB text index
const allPackages = await Package.getInstances(filter);
// Filter by query
const lowerQuery = query.toLowerCase();
const filtered = allPackages.filter(
(pkg) =>
pkg.name.toLowerCase().includes(lowerQuery) ||
pkg.description?.toLowerCase().includes(lowerQuery)
);
// Apply pagination
const offset = options?.offset || 0;
const limit = options?.limit || 50;
return filtered.slice(offset, offset + limit);
}
/**
* Add a new version
*/
public addVersion(version: IPackageVersion): void {
this.versions[version.version] = version;
this.storageBytes += version.size;
this.updatedAt = new Date();
}
/**
* Get specific version
*/
public getVersion(version: string): IPackageVersion | undefined {
return this.versions[version];
}
/**
* Get latest version
*/
public getLatestVersion(): IPackageVersion | undefined {
const latest = this.distTags['latest'];
if (latest) {
return this.versions[latest];
}
// Fallback to most recent
const versionList = Object.keys(this.versions);
if (versionList.length === 0) return undefined;
return this.versions[versionList[versionList.length - 1]];
}
/**
* Increment download count
*/
public async incrementDownloads(version?: string): Promise<void> {
this.downloadCount += 1;
if (version && this.versions[version]) {
this.versions[version].downloads += 1;
}
await this.save();
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await Package.getNewId();
}
}
}

View File

@@ -0,0 +1,162 @@
/**
* RepositoryPermission model - grants access to repositories
*/
import * as plugins from '../plugins.ts';
import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class RepositoryPermission
extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission>
implements IRepositoryPermission
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public repositoryId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public teamId?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId?: string;
@plugins.smartdata.svDb()
public role: TRepositoryRole = 'reader';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public grantedById: string = '';
/**
* Grant permission to a user
*/
public static async grantToUser(data: {
repositoryId: string;
userId: string;
role: TRepositoryRole;
grantedById: string;
}): Promise<RepositoryPermission> {
// Check for existing permission
const existing = await RepositoryPermission.getInstance({
repositoryId: data.repositoryId,
userId: data.userId,
});
if (existing) {
// Update existing permission
existing.role = data.role;
await existing.save();
return existing;
}
const perm = new RepositoryPermission();
perm.id = await RepositoryPermission.getNewId();
perm.repositoryId = data.repositoryId;
perm.userId = data.userId;
perm.role = data.role;
perm.grantedById = data.grantedById;
perm.createdAt = new Date();
await perm.save();
return perm;
}
/**
* Grant permission to a team
*/
public static async grantToTeam(data: {
repositoryId: string;
teamId: string;
role: TRepositoryRole;
grantedById: string;
}): Promise<RepositoryPermission> {
// Check for existing permission
const existing = await RepositoryPermission.getInstance({
repositoryId: data.repositoryId,
teamId: data.teamId,
});
if (existing) {
// Update existing permission
existing.role = data.role;
await existing.save();
return existing;
}
const perm = new RepositoryPermission();
perm.id = await RepositoryPermission.getNewId();
perm.repositoryId = data.repositoryId;
perm.teamId = data.teamId;
perm.role = data.role;
perm.grantedById = data.grantedById;
perm.createdAt = new Date();
await perm.save();
return perm;
}
/**
* Get user's direct permission on repository
*/
public static async getUserPermission(
repositoryId: string,
userId: string
): Promise<RepositoryPermission | null> {
return await RepositoryPermission.getInstance({
repositoryId,
userId,
});
}
/**
* Get team's permission on repository
*/
public static async getTeamPermission(
repositoryId: string,
teamId: string
): Promise<RepositoryPermission | null> {
return await RepositoryPermission.getInstance({
repositoryId,
teamId,
});
}
/**
* Get all permissions for a repository
*/
public static async getRepoPermissions(repositoryId: string): Promise<RepositoryPermission[]> {
return await RepositoryPermission.getInstances({
repositoryId,
});
}
/**
* Get all permissions for user's teams on a repository
*/
public static async getTeamPermissionsForRepo(
repositoryId: string,
teamIds: string[]
): Promise<RepositoryPermission[]> {
if (teamIds.length === 0) return [];
return await RepositoryPermission.getInstances({
repositoryId,
teamId: { $in: teamIds } as unknown as string,
});
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await RepositoryPermission.getNewId();
}
}
}

158
ts/models/repository.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Repository model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class Repository
extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
implements IRepository
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public protocol: TRegistryProtocol = 'npm';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public visibility: TRepositoryVisibility = 'private';
@plugins.smartdata.svDb()
public storageNamespace: string = '';
@plugins.smartdata.svDb()
public downloadCount: number = 0;
@plugins.smartdata.svDb()
public starCount: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdById: string = '';
/**
* Create a new repository
*/
public static async createRepository(data: {
organizationId: string;
name: string;
description?: string;
protocol: TRegistryProtocol;
visibility?: TRepositoryVisibility;
createdById: string;
}): Promise<Repository> {
// Validate name
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name.toLowerCase())) {
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
}
// Check for duplicate name in org + protocol
const existing = await Repository.getInstance({
organizationId: data.organizationId,
name: data.name.toLowerCase(),
protocol: data.protocol,
});
if (existing) {
throw new Error('Repository with this name and protocol already exists');
}
const repo = new Repository();
repo.id = await Repository.getNewId();
repo.organizationId = data.organizationId;
repo.name = data.name.toLowerCase();
repo.description = data.description;
repo.protocol = data.protocol;
repo.visibility = data.visibility || 'private';
repo.storageNamespace = `${data.protocol}/${data.organizationId}/${data.name.toLowerCase()}`;
repo.createdById = data.createdById;
repo.createdAt = new Date();
repo.updatedAt = new Date();
await repo.save();
return repo;
}
/**
* Find repository by org, name, and protocol
*/
public static async findByName(
organizationId: string,
name: string,
protocol: TRegistryProtocol
): Promise<Repository | null> {
return await Repository.getInstance({
organizationId,
name: name.toLowerCase(),
protocol,
});
}
/**
* Get all repositories in an organization
*/
public static async getOrgRepositories(organizationId: string): Promise<Repository[]> {
return await Repository.getInstances({
organizationId,
});
}
/**
* Get all public repositories
*/
public static async getPublicRepositories(protocol?: TRegistryProtocol): Promise<Repository[]> {
const query: Record<string, unknown> = { visibility: 'public' };
if (protocol) {
query.protocol = protocol;
}
return await Repository.getInstances(query);
}
/**
* Increment download count
*/
public async incrementDownloads(): Promise<void> {
this.downloadCount += 1;
await this.save();
}
/**
* Get full path (org/repo)
*/
public getFullPath(orgName: string): string {
return `${orgName}/${this.name}`;
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await Repository.getNewId();
}
}
}

135
ts/models/session.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Session model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { ISession } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class Session
extends plugins.smartdata.SmartDataDbDoc<Session, Session>
implements ISession
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId: string = '';
@plugins.smartdata.svDb()
public userAgent: string = '';
@plugins.smartdata.svDb()
public ipAddress: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public isValid: boolean = true;
@plugins.smartdata.svDb()
public invalidatedAt?: Date;
@plugins.smartdata.svDb()
public invalidatedReason?: string;
@plugins.smartdata.svDb()
public lastActivityAt: Date = new Date();
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
/**
* Create a new session
*/
public static async createSession(data: {
userId: string;
userAgent: string;
ipAddress: string;
}): Promise<Session> {
const session = new Session();
session.id = await Session.getNewId();
session.userId = data.userId;
session.userAgent = data.userAgent;
session.ipAddress = data.ipAddress;
session.isValid = true;
session.lastActivityAt = new Date();
session.createdAt = new Date();
await session.save();
return session;
}
/**
* Find valid session by ID
*/
public static async findValidSession(sessionId: string): Promise<Session | null> {
const session = await Session.getInstance({
id: sessionId,
isValid: true,
});
if (!session) return null;
// Check if session is expired (7 days)
const maxAge = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - session.createdAt.getTime() > maxAge) {
await session.invalidate('expired');
return null;
}
return session;
}
/**
* Get all valid sessions for a user
*/
public static async getUserSessions(userId: string): Promise<Session[]> {
return await Session.getInstances({
userId,
isValid: true,
});
}
/**
* Invalidate all sessions for a user
*/
public static async invalidateAllUserSessions(
userId: string,
reason: string = 'logout_all'
): Promise<number> {
const sessions = await Session.getUserSessions(userId);
for (const session of sessions) {
await session.invalidate(reason);
}
return sessions.length;
}
/**
* Invalidate this session
*/
public async invalidate(reason: string): Promise<void> {
this.isValid = false;
this.invalidatedAt = new Date();
this.invalidatedReason = reason;
await this.save();
}
/**
* Update last activity
*/
public async touchActivity(): Promise<void> {
this.lastActivityAt = new Date();
await this.save();
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await Session.getNewId();
}
}
}

97
ts/models/team.member.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* TeamMember model - links users to teams with roles
*/
import * as plugins from '../plugins.ts';
import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class TeamMember
extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember>
implements ITeamMember
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public teamId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public role: TTeamRole = 'member';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
/**
* Add a member to a team
*/
public static async addMember(data: {
teamId: string;
userId: string;
role: TTeamRole;
}): Promise<TeamMember> {
// Check if member already exists
const existing = await TeamMember.getInstance({
teamId: data.teamId,
userId: data.userId,
});
if (existing) {
throw new Error('User is already a member of this team');
}
const member = new TeamMember();
member.id = await TeamMember.getNewId();
member.teamId = data.teamId;
member.userId = data.userId;
member.role = data.role;
member.createdAt = new Date();
await member.save();
return member;
}
/**
* Find membership for user in team
*/
public static async findMembership(teamId: string, userId: string): Promise<TeamMember | null> {
return await TeamMember.getInstance({
teamId,
userId,
});
}
/**
* Get all members of a team
*/
public static async getTeamMembers(teamId: string): Promise<TeamMember[]> {
return await TeamMember.getInstances({
teamId,
});
}
/**
* Get all teams a user belongs to
*/
public static async getUserTeams(userId: string): Promise<TeamMember[]> {
return await TeamMember.getInstances({
userId,
});
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await TeamMember.getNewId();
}
}
}

100
ts/models/team.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Team model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { ITeam } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implements ITeam {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public isDefaultTeam: boolean = false;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
/**
* Create a new team
*/
public static async createTeam(data: {
organizationId: string;
name: string;
description?: string;
isDefaultTeam?: boolean;
}): Promise<Team> {
// Validate name
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name.toLowerCase())) {
throw new Error('Team name must be lowercase alphanumeric with optional hyphens');
}
// Check for duplicate name in org
const existing = await Team.getInstance({
organizationId: data.organizationId,
name: data.name.toLowerCase(),
});
if (existing) {
throw new Error('Team with this name already exists in the organization');
}
const team = new Team();
team.id = await Team.getNewId();
team.organizationId = data.organizationId;
team.name = data.name.toLowerCase();
team.description = data.description;
team.isDefaultTeam = data.isDefaultTeam || false;
team.createdAt = new Date();
team.updatedAt = new Date();
await team.save();
return team;
}
/**
* Find team by name in organization
*/
public static async findByName(organizationId: string, name: string): Promise<Team | null> {
return await Team.getInstance({
organizationId,
name: name.toLowerCase(),
});
}
/**
* Get all teams in an organization
*/
public static async getOrgTeams(organizationId: string): Promise<Team[]> {
return await Team.getInstances({
organizationId,
});
}
/**
* Lifecycle hook
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await Team.getNewId();
}
}
}

115
ts/models/user.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* User model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { IUser, TUserStatus } from '../interfaces/auth.interfaces.ts';
import { getDb } from './db.ts';
@plugins.smartdata.Collection(() => getDb())
export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implements IUser {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public email: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public username: string = '';
@plugins.smartdata.svDb()
public passwordHash: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public displayName: string = '';
@plugins.smartdata.svDb()
public avatarUrl?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public status: TUserStatus = 'pending_verification';
@plugins.smartdata.svDb()
public emailVerified: boolean = false;
@plugins.smartdata.svDb()
public mfaEnabled: boolean = false;
@plugins.smartdata.svDb()
public mfaSecret?: string;
@plugins.smartdata.svDb()
public lastLoginAt?: Date;
@plugins.smartdata.svDb()
public lastLoginIp?: string;
@plugins.smartdata.svDb()
public failedLoginAttempts: number = 0;
@plugins.smartdata.svDb()
public lockedUntil?: Date;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public isPlatformAdmin: boolean = false;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
/**
* Create a new user instance
*/
public static async createUser(data: {
email: string;
username: string;
passwordHash: string;
displayName?: string;
}): Promise<User> {
const user = new User();
user.id = await User.getNewId();
user.email = data.email.toLowerCase();
user.username = data.username.toLowerCase();
user.passwordHash = data.passwordHash;
user.displayName = data.displayName || data.username;
user.status = 'pending_verification';
user.createdAt = new Date();
user.updatedAt = new Date();
await user.save();
return user;
}
/**
* Find user by email
*/
public static async findByEmail(email: string): Promise<User | null> {
return await User.getInstance({ email: email.toLowerCase() });
}
/**
* Find user by username
*/
public static async findByUsername(username: string): Promise<User | null> {
return await User.getInstance({ username: username.toLowerCase() });
}
/**
* Lifecycle hook: Update timestamps before save
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await User.getNewId();
}
}
}