Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc74ff995 | |||
| d71ae08645 | |||
| fe3cb75095 | |||
| f76778ce45 | |||
| 15ca1a67f4 | |||
| b05c53f967 |
212
.gitea/workflows/release.yml
Normal file
212
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,212 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --ignore-scripts
|
||||
|
||||
- name: Install UI dependencies
|
||||
run: cd ui && pnpm install
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Verify deno.json version matches tag
|
||||
run: |
|
||||
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "deno.json version: $DENO_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Angular UI
|
||||
run: cd ui && pnpm run build
|
||||
|
||||
- name: Bundle UI into TypeScript
|
||||
run: deno run --allow-all scripts/bundle-ui.ts
|
||||
|
||||
- name: Compile binaries for all platforms
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
cd ../..
|
||||
|
||||
- name: Extract changelog for this version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
if [ ! -f CHANGELOG.md ] && [ ! -f changelog.md ]; then
|
||||
echo "No changelog found, using default release notes"
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
## Stack.Gallery Registry $VERSION
|
||||
|
||||
Pre-compiled binaries for multiple platforms.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
|
||||
Or download the binary for your platform and make it executable.
|
||||
|
||||
### Supported Platforms
|
||||
- Linux x86_64 (x64)
|
||||
- Linux ARM64 (aarch64)
|
||||
- macOS x86_64 (Intel)
|
||||
- macOS ARM64 (Apple Silicon)
|
||||
|
||||
### Checksums
|
||||
SHA256 checksums are provided in SHA256SUMS.txt
|
||||
EOF
|
||||
else
|
||||
CHANGELOG_FILE=$([ -f CHANGELOG.md ] && echo "CHANGELOG.md" || echo "changelog.md")
|
||||
awk "/## \[$VERSION\]/,/## \[/" "$CHANGELOG_FILE" | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||
## Stack.Gallery Registry $VERSION
|
||||
|
||||
See changelog.md for full details.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Release notes:"
|
||||
cat /tmp/release_notes.md
|
||||
|
||||
- name: Delete existing release if it exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Checking for existing release $VERSION..."
|
||||
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/tags/$VERSION" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
|
||||
curl -X DELETE -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$EXISTING_RELEASE_ID"
|
||||
echo "Existing release deleted"
|
||||
sleep 2
|
||||
else
|
||||
echo "No existing release found, proceeding with creation"
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Creating release for $VERSION..."
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$VERSION\",
|
||||
\"name\": \"Stack.Gallery Registry $VERSION\",
|
||||
\"body\": $(jq -Rs . /tmp/release_notes.md),
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
for binary in dist/binaries/*; do
|
||||
filename=$(basename "$binary")
|
||||
echo "Uploading $filename..."
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$binary" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$RELEASE_ID/assets?name=$filename"
|
||||
done
|
||||
|
||||
echo "All assets uploaded successfully"
|
||||
|
||||
- name: Clean up old releases
|
||||
run: |
|
||||
echo "Cleaning up old releases (keeping only last 3)..."
|
||||
|
||||
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases" | \
|
||||
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||
|
||||
if [ -n "$RELEASES" ]; then
|
||||
echo "Found releases to delete:"
|
||||
for release_id in $RELEASES; do
|
||||
echo " Deleting release ID: $release_id"
|
||||
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$release_id"
|
||||
done
|
||||
echo "Old releases deleted successfully"
|
||||
else
|
||||
echo "No old releases to delete (less than 4 releases total)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
- name: Release Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " Release ${{ steps.version.outputs.version }} Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Binaries published:"
|
||||
ls -lh dist/binaries/
|
||||
echo ""
|
||||
echo "Release URL:"
|
||||
echo "https://code.foss.global/stack.gallery/registry/releases/tag/${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation command:"
|
||||
echo "curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,9 +4,13 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
ui/dist/
|
||||
.angular/
|
||||
out-tsc/
|
||||
|
||||
# tsdeno temporary files
|
||||
package.json.bak
|
||||
|
||||
# Generated files
|
||||
ts/embedded-ui.generated.ts
|
||||
|
||||
@@ -45,11 +49,15 @@ coverage/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Playwright MCP
|
||||
.playwright-mcp/
|
||||
|
||||
# Debug
|
||||
.nogit/
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
stories/
|
||||
|
||||
# Package manager locks (keep pnpm-lock.yaml)
|
||||
|
||||
22
changelog.md
22
changelog.md
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-20 - 1.4.2 - fix(registry)
|
||||
align registry integrations with updated auth, storage, repository, and audit models
|
||||
|
||||
- update smartregistry auth and storage provider implementations to match the current request, token, and storage hook APIs
|
||||
- fix audit events for auth provider, platform settings, and external authentication flows to use dedicated event types
|
||||
- adapt repository, organization, user, and package handlers to renamed model fields and revised repository visibility/protocol data
|
||||
- add missing repository and team model fields plus helper methods needed by the updated API and permission flows
|
||||
- correct AES-GCM crypto buffer handling and package version checksum mapping
|
||||
|
||||
## 2026-03-20 - 1.4.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-03-20 - 1.4.0 - feat(release,build,tests)
|
||||
add automated multi-platform release pipeline and align runtime, model, and test updates
|
||||
|
||||
- add a Gitea release workflow that builds the UI, bundles embedded assets, cross-compiles binaries for Linux and macOS, generates checksums, and publishes release assets from version tags
|
||||
- switch compilation to tsdeno with compile targets defined in npmextra.json and simplify project scripts for check, lint, format, and compile tasks
|
||||
- improve CLI startup error handling in mod.ts and guard execution with import.meta.main
|
||||
- update test configuration to load MongoDB and S3 settings from qenv-based environment files and adjust tests for renamed model and token APIs
|
||||
- rename package search usage to searchPackages, update audit event names, and align package version fields and model name overrides with newer dependency behavior
|
||||
|
||||
## 2025-12-03 - 1.3.0 - feat(auth)
|
||||
Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
|
||||
|
||||
|
||||
38
deno.json
38
deno.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.2",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -15,30 +15,28 @@
|
||||
"build": "cd ui && pnpm run build",
|
||||
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
|
||||
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
|
||||
"compile": "deno compile --allow-all --output dist/stack-gallery-registry mod.ts",
|
||||
"compile:linux-x64": "deno compile --allow-all --target x86_64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-x64 mod.ts",
|
||||
"compile:linux-arm64": "deno compile --allow-all --target aarch64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-arm64 mod.ts",
|
||||
"compile:macos-x64": "deno compile --allow-all --target x86_64-apple-darwin --output dist/stack-gallery-registry-macos-x64 mod.ts",
|
||||
"compile:macos-arm64": "deno compile --allow-all --target aarch64-apple-darwin --output dist/stack-gallery-registry-macos-arm64 mod.ts",
|
||||
"release": "deno task bundle-ui && deno task compile:linux-x64 && deno task compile:linux-arm64 && deno task compile:macos-x64 && deno task compile:macos-arm64"
|
||||
"compile": "tsdeno compile",
|
||||
"check": "deno check mod.ts",
|
||||
"fmt": "deno fmt",
|
||||
"lint": "deno lint"
|
||||
},
|
||||
"imports": {
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.13",
|
||||
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.3.0",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.1.0",
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.6.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.0",
|
||||
"@push.rocks/smartbucket": "npm:@push.rocks/smartbucket@^4.5.1",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.1",
|
||||
"@push.rocks/smartenv": "npm:@push.rocks/smartenv@^6.0.0",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.3",
|
||||
"@push.rocks/smartstring": "npm:@push.rocks/smartstring@^4.1.0",
|
||||
"@push.rocks/smartcrypto": "npm:@push.rocks/smartcrypto@^2.0.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.0",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.0",
|
||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.0",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.0",
|
||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.0",
|
||||
"@push.rocks/smartarchive": "npm:@push.rocks/smartarchive@^5.0.0",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.3.0",
|
||||
"@push.rocks/smartcrypto": "npm:@push.rocks/smartcrypto@^2.0.4",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.5",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.20",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.3",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||
"@std/http": "jsr:@std/http@^1.0.0"
|
||||
|
||||
15
mod.ts
15
mod.ts
@@ -7,5 +7,16 @@
|
||||
|
||||
import { runCli } from './ts/cli.ts';
|
||||
|
||||
// Run CLI
|
||||
await runCli();
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await runCli();
|
||||
} catch (error) {
|
||||
const debugMode = Deno.args.includes('--debug');
|
||||
if (debugMode) {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
68
npmextra.json
Normal file
68
npmextra.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"projectType": "deno",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "stack.gallery",
|
||||
"gitrepo": "registry",
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"npmPackagename": "@stack.gallery/registry",
|
||||
"license": "MIT"
|
||||
},
|
||||
"services": [
|
||||
"mongodb",
|
||||
"minio"
|
||||
]
|
||||
},
|
||||
"@git.zone/tsdeno": {
|
||||
"compileTargets": [
|
||||
{
|
||||
"name": "stack-gallery-registry-linux-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-unknown-linux-gnu",
|
||||
"permissions": [
|
||||
"--allow-all"
|
||||
],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "stack-gallery-registry-linux-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-unknown-linux-gnu",
|
||||
"permissions": [
|
||||
"--allow-all"
|
||||
],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "stack-gallery-registry-macos-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-apple-darwin",
|
||||
"permissions": [
|
||||
"--allow-all"
|
||||
],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "stack-gallery-registry-macos-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-apple-darwin",
|
||||
"permissions": [
|
||||
"--allow-all"
|
||||
],
|
||||
"noCheck": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"type": "module",
|
||||
@@ -8,8 +8,10 @@
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"watch": "concurrently --kill-others --names \"BACKEND,UI,BUNDLER\" --prefix-colors \"cyan,magenta,yellow\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\" \"deno task bundle-ui:watch\"",
|
||||
"build": "cd ui && pnpm run build",
|
||||
"test": "deno test --allow-all"
|
||||
"build": "deno task check",
|
||||
"test": "deno task test",
|
||||
"lint": "deno task lint",
|
||||
"format": "deno task fmt"
|
||||
},
|
||||
"keywords": [
|
||||
"registry",
|
||||
@@ -25,10 +27,8 @@
|
||||
"author": "Stack.Gallery",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsdeno": "^1.2.0",
|
||||
"concurrently": "^9.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdata": "link:../../push.rocks/smartdata"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||
}
|
||||
|
||||
2305
pnpm-lock.yaml
generated
2305
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
overrides:
|
||||
'@push.rocks/smartdata': link:../../push.rocks/smartdata
|
||||
438
readme.md
438
readme.md
@@ -1,6 +1,6 @@
|
||||
# @stack.gallery/registry 📦
|
||||
|
||||
**Enterprise-grade multi-protocol package registry** built with Deno and TypeScript. Host your own private NPM, Docker/OCI, Maven, Cargo, PyPI, Composer, and RubyGems registry with a unified, beautiful web interface.
|
||||
A self-hosted, multi-protocol package registry built with Deno and TypeScript. Run your own private **NPM**, **Docker/OCI**, **Maven**, **Cargo**, **PyPI**, **Composer**, and **RubyGems** registry — all behind a single binary with a modern web UI.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -8,225 +8,357 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔐 **Multi-Protocol Support** - NPM, OCI/Docker, Maven, Cargo, PyPI, Composer, RubyGems
|
||||
- 🏢 **Organizations & Teams** - Fine-grained access control with role-based permissions
|
||||
- 🎫 **API Tokens** - Scoped tokens for CI/CD and programmatic access
|
||||
- 🔍 **Upstream Caching** - Proxy and cache packages from public registries
|
||||
- 📊 **Audit Logging** - Complete audit trail for compliance and security
|
||||
- 🎨 **Modern Web UI** - Angular 19 dashboard for package management
|
||||
- ⚡ **Deno Runtime** - Fast, secure, TypeScript-first backend
|
||||
- 🗄️ **MongoDB + S3** - Scalable storage with smartdata ORM
|
||||
- 🔌 **7 Protocol Support** — NPM, OCI/Docker, Maven, Cargo, PyPI, Composer, RubyGems via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry)
|
||||
- 🏢 **Organizations & Teams** — Hierarchical access control: orgs → teams → repositories
|
||||
- 🔐 **Flexible Authentication** — Local JWT auth, OAuth/OIDC, and LDAP with JIT user provisioning
|
||||
- 🎫 **Scoped API Tokens** — Per-protocol, per-scope tokens (`srg_` prefix) for CI/CD pipelines
|
||||
- 🛡️ **RBAC Permissions** — Reader → Developer → Maintainer → Admin per repository
|
||||
- 🔍 **Upstream Caching** — Transparently proxy and cache packages from public registries
|
||||
- 📊 **Audit Logging** — Full audit trail on every action for compliance
|
||||
- 🎨 **Modern Web UI** — Angular 19 dashboard with Tailwind CSS, embedded in the binary
|
||||
- ⚡ **Single Binary** — Cross-compiled with `deno compile` for Linux and macOS (x64 + ARM64)
|
||||
- 🗄️ **MongoDB + S3** — Metadata in MongoDB, artifacts in any S3-compatible store
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Deno** >= 1.40
|
||||
- **MongoDB** >= 4.4
|
||||
- **S3-compatible storage** (MinIO, AWS S3, etc.)
|
||||
- **Node.js** >= 18 (for UI development)
|
||||
- **S3-compatible storage** (MinIO, AWS S3, Cloudflare R2, etc.)
|
||||
|
||||
### Installation
|
||||
### Install from Binary
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
# One-liner install (latest version)
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
|
||||
# Install specific version
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.3.0
|
||||
|
||||
# Install + set up systemd service
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --setup-service
|
||||
```
|
||||
|
||||
The installer:
|
||||
- Detects your platform (Linux/macOS, x64/ARM64)
|
||||
- Downloads the pre-compiled binary from Gitea releases
|
||||
- Installs to `/opt/stack-gallery-registry/` with a symlink in `/usr/local/bin/`
|
||||
- Optionally creates and enables a systemd service
|
||||
|
||||
### Run from Source
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://code.foss.global/stack.gallery/registry.git
|
||||
cd registry
|
||||
|
||||
# Install UI dependencies
|
||||
cd ui && pnpm install && cd ..
|
||||
# Development mode (hot reload, reads .nogit/env.json)
|
||||
deno task dev
|
||||
|
||||
# Build the UI
|
||||
pnpm run build
|
||||
# Production mode
|
||||
deno task start
|
||||
```
|
||||
|
||||
### Configuration
|
||||
The registry is available at `http://localhost:3000`.
|
||||
|
||||
Create a `.nogit/env.json` file for local development:
|
||||
## ⚙️ Configuration
|
||||
|
||||
Configuration is loaded from **environment variables** (production) or from **`.nogit/env.json`** when using the `--ephemeral` flag (development).
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MONGODB_URL` | `mongodb://localhost:27017` | MongoDB connection string |
|
||||
| `MONGODB_DB` | `stackgallery` | Database name |
|
||||
| `S3_ENDPOINT` | `http://localhost:9000` | S3-compatible endpoint |
|
||||
| `S3_ACCESS_KEY` | `minioadmin` | S3 access key |
|
||||
| `S3_SECRET_KEY` | `minioadmin` | S3 secret key |
|
||||
| `S3_BUCKET` | `registry` | S3 bucket name |
|
||||
| `S3_REGION` | — | S3 region |
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `AUTH_ENCRYPTION_KEY` | *(ephemeral)* | 64-char hex for AES-256-GCM encryption of OAuth/LDAP secrets |
|
||||
| `STORAGE_PATH` | `packages` | Base path in S3 for artifacts |
|
||||
| `ENABLE_UPSTREAM_CACHE` | `true` | Cache packages from upstream registries |
|
||||
| `UPSTREAM_CACHE_EXPIRY` | `24` | Cache TTL in hours |
|
||||
|
||||
**Example `.nogit/env.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"MONGODB_URL": "mongodb://localhost:27017",
|
||||
"MONGODB_URL": "mongodb://admin:pass@localhost:27017/stackregistry?authSource=admin",
|
||||
"MONGODB_NAME": "stackregistry",
|
||||
"S3_HOST": "localhost",
|
||||
"S3_PORT": "9000",
|
||||
"S3_ACCESSKEY": "minioadmin",
|
||||
"S3_SECRETKEY": "minioadmin",
|
||||
"S3_BUCKET": "registry",
|
||||
"S3_USESSL": false,
|
||||
"JWT_SECRET": "your-secure-secret-key",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"ADMIN_PASSWORD": "your-admin-password"
|
||||
"S3_USESSL": false
|
||||
}
|
||||
```
|
||||
|
||||
Or use environment variables:
|
||||
## 🔌 Protocol Endpoints
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `MONGODB_URL` | MongoDB connection string | `mongodb://localhost:27017` |
|
||||
| `MONGODB_DB` | Database name | `stackgallery` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | `http://localhost:9000` |
|
||||
| `S3_ACCESS_KEY` | S3 access key | `minioadmin` |
|
||||
| `S3_SECRET_KEY` | S3 secret key | `minioadmin` |
|
||||
| `S3_BUCKET` | S3 bucket name | `registry` |
|
||||
| `JWT_SECRET` | JWT signing secret | `change-me-in-production` |
|
||||
| `ADMIN_EMAIL` | Default admin email | `admin@stack.gallery` |
|
||||
| `ADMIN_PASSWORD` | Default admin password | `admin` |
|
||||
| `PORT` | HTTP server port | `3000` |
|
||||
Each protocol is handled natively via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry). Point your package manager at the registry:
|
||||
|
||||
### Running
|
||||
| Protocol | Paths | Client Config Example |
|
||||
|----------|-------|-----------------------|
|
||||
| **NPM** | `/-/*`, `/@scope/*` | `npm config set registry http://registry:3000` |
|
||||
| **OCI/Docker** | `/v2/*` | `docker login registry:3000` |
|
||||
| **Maven** | `/maven2/*` | Add repository URL in `pom.xml` |
|
||||
| **Cargo** | `/api/v1/crates/*` | Configure in `.cargo/config.toml` |
|
||||
| **PyPI** | `/simple/*`, `/pypi/*` | `pip install --index-url http://registry:3000/simple/` |
|
||||
| **Composer** | `/packages.json`, `/p/*` | Add repository in `composer.json` |
|
||||
| **RubyGems** | `/api/v1/gems/*`, `/gems/*` | `gem sources -a http://registry:3000` |
|
||||
|
||||
```bash
|
||||
# Development mode (with hot reload)
|
||||
pnpm run watch
|
||||
Authentication works with **Bearer tokens** (API tokens prefixed `srg_`) and **Basic auth** (email:password or username:token).
|
||||
|
||||
# Production mode
|
||||
deno run --allow-all mod.ts server
|
||||
## 🔐 Authentication & Security
|
||||
|
||||
# Or with Deno tasks
|
||||
deno task start
|
||||
### Local Auth
|
||||
- JWT-based with **15-minute access tokens** and **7-day refresh tokens** (HS256)
|
||||
- Session tracking — each login creates a session, tokens embed session IDs
|
||||
- Password hashing with PBKDF2 (10,000 rounds SHA-256 + random salt)
|
||||
|
||||
### External Auth (OAuth/OIDC & LDAP)
|
||||
- **OAuth/OIDC** — Connect to any OIDC-compliant provider (Keycloak, Okta, Auth0, Azure AD, etc.)
|
||||
- **LDAP** — Bind + search authentication against Active Directory or OpenLDAP
|
||||
- **JIT Provisioning** — Users are auto-created on first external login
|
||||
- **Auto-linking** — External identities are linked to existing users by email match
|
||||
- **Encrypted secrets** — Provider client secrets and bind passwords are stored AES-256-GCM encrypted
|
||||
|
||||
### RBAC Permissions
|
||||
|
||||
Access is resolved through a hierarchy:
|
||||
|
||||
```
|
||||
Platform Admin (full access)
|
||||
└─ Organization Owner/Admin
|
||||
└─ Team Maintainer (read + write + delete on team repos)
|
||||
└─ Team Member (read + write on team repos)
|
||||
└─ Direct Repo Permission (reader / developer / maintainer / admin)
|
||||
└─ Public Repository (read for everyone)
|
||||
```
|
||||
|
||||
The registry will be available at `http://localhost:3000`
|
||||
### Scoped API Tokens
|
||||
|
||||
Tokens are prefixed with `srg_` and can be scoped to:
|
||||
- Specific **protocols** (e.g., npm + oci only)
|
||||
- Specific **actions** (read / write / delete)
|
||||
- Specific **organizations**
|
||||
- Custom **expiration** dates
|
||||
|
||||
## 📡 REST API
|
||||
|
||||
All management endpoints live under `/api/v1/`. Authenticated via `Authorization: Bearer <jwt_or_api_token>`.
|
||||
|
||||
### Auth
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/v1/auth/login` | Login (email + password) |
|
||||
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
||||
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
||||
| `GET` | `/api/v1/auth/me` | Current user info |
|
||||
| `GET` | `/api/v1/auth/providers` | List active external auth providers |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/authorize` | Initiate OAuth flow |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/callback` | OAuth callback |
|
||||
| `POST` | `/api/v1/auth/ldap/:id/login` | LDAP login |
|
||||
|
||||
### Users
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/users` | List users |
|
||||
| `POST` | `/api/v1/users` | Create user |
|
||||
| `GET` | `/api/v1/users/:id` | Get user |
|
||||
| `PUT` | `/api/v1/users/:id` | Update user |
|
||||
| `DELETE` | `/api/v1/users/:id` | Delete user |
|
||||
|
||||
### Organizations
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations` | List organizations |
|
||||
| `POST` | `/api/v1/organizations` | Create organization |
|
||||
| `GET` | `/api/v1/organizations/:id` | Get organization |
|
||||
| `PUT` | `/api/v1/organizations/:id` | Update organization |
|
||||
| `DELETE` | `/api/v1/organizations/:id` | Delete organization |
|
||||
| `GET` | `/api/v1/organizations/:id/members` | List members |
|
||||
| `POST` | `/api/v1/organizations/:id/members` | Add member |
|
||||
| `PUT` | `/api/v1/organizations/:id/members/:userId` | Update member role |
|
||||
| `DELETE` | `/api/v1/organizations/:id/members/:userId` | Remove member |
|
||||
|
||||
### Repositories
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/:orgId/repositories` | List org repos |
|
||||
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repo |
|
||||
| `GET` | `/api/v1/repositories/:id` | Get repo |
|
||||
| `PUT` | `/api/v1/repositories/:id` | Update repo |
|
||||
| `DELETE` | `/api/v1/repositories/:id` | Delete repo |
|
||||
|
||||
### Packages
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/packages` | Search packages |
|
||||
| `GET` | `/api/v1/packages/:id` | Get package details |
|
||||
| `GET` | `/api/v1/packages/:id/versions` | List versions |
|
||||
| `DELETE` | `/api/v1/packages/:id` | Delete package |
|
||||
| `DELETE` | `/api/v1/packages/:id/versions/:version` | Delete version |
|
||||
|
||||
### Tokens
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/tokens` | List your tokens |
|
||||
| `POST` | `/api/v1/tokens` | Create token |
|
||||
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
||||
|
||||
### Audit
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/audit` | Query audit logs |
|
||||
|
||||
### Admin (Platform Admins Only)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/admin/auth/providers` | List all auth providers |
|
||||
| `POST` | `/api/v1/admin/auth/providers` | Create auth provider |
|
||||
| `GET` | `/api/v1/admin/auth/providers/:id` | Get provider details |
|
||||
| `PUT` | `/api/v1/admin/auth/providers/:id` | Update provider |
|
||||
| `DELETE` | `/api/v1/admin/auth/providers/:id` | Disable provider |
|
||||
| `POST` | `/api/v1/admin/auth/providers/:id/test` | Test provider connection |
|
||||
| `GET` | `/api/v1/admin/auth/settings` | Get platform settings |
|
||||
| `PUT` | `/api/v1/admin/auth/settings` | Update platform settings |
|
||||
|
||||
### Health Check
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
registry/
|
||||
├── mod.ts # Entry point
|
||||
├── mod.ts # Deno entry point
|
||||
├── deno.json # Deno config, tasks, imports
|
||||
├── npmextra.json # tsdeno compile targets & gitzone config
|
||||
├── install.sh # Binary installer script
|
||||
├── .gitea/workflows/ # CI release pipeline
|
||||
├── scripts/
|
||||
│ └── bundle-ui.ts # Embeds Angular build as base64 TypeScript
|
||||
├── ts/
|
||||
│ ├── registry.ts # Main StackGalleryRegistry class
|
||||
│ ├── cli.ts # CLI command handler
|
||||
│ ├── plugins.ts # Centralized dependencies
|
||||
│ ├── registry.ts # StackGalleryRegistry — main orchestrator
|
||||
│ ├── cli.ts # CLI commands (smartcli)
|
||||
│ ├── plugins.ts # Centralized dependency imports
|
||||
│ ├── api/
|
||||
│ │ ├── router.ts # REST API router with JWT auth
|
||||
│ │ └── handlers/ # API endpoint handlers
|
||||
│ ├── models/ # MongoDB models (smartdata)
|
||||
│ │ ├── user.ts
|
||||
│ │ ├── organization.ts
|
||||
│ │ ├── repository.ts
|
||||
│ │ ├── package.ts
|
||||
│ │ ├── session.ts
|
||||
│ │ └── ...
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── auth.service.ts
|
||||
│ │ ├── permission.service.ts
|
||||
│ │ ├── token.service.ts
|
||||
│ │ └── audit.service.ts
|
||||
│ ├── providers/ # Registry protocol integrations
|
||||
│ │ ├── auth.provider.ts
|
||||
│ │ └── storage.provider.ts
|
||||
│ └── interfaces/ # TypeScript types
|
||||
└── ui/ # Angular 19 web interface
|
||||
│ │ ├── router.ts # REST API router with JWT/token auth
|
||||
│ │ └── handlers/ # auth, user, org, repo, package, token, audit, oauth, admin
|
||||
│ ├── models/ # MongoDB models via @push.rocks/smartdata
|
||||
│ │ ├── user.ts, organization.ts, team.ts
|
||||
│ │ ├── repository.ts, package.ts
|
||||
│ │ ├── apitoken.ts, session.ts, auditlog.ts
|
||||
│ │ ├── auth.provider.ts, external.identity.ts, platform.settings.ts
|
||||
│ │ └── *.member.ts, *.permission.ts
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── auth.service.ts # JWT login/refresh/logout
|
||||
│ │ ├── external.auth.service.ts # OAuth/OIDC & LDAP flows
|
||||
│ │ ├── crypto.service.ts # AES-256-GCM encryption
|
||||
│ │ ├── token.service.ts # API token CRUD
|
||||
│ │ ├── permission.service.ts # RBAC resolution
|
||||
│ │ └── audit.service.ts # Audit logging
|
||||
│ ├── providers/ # smartregistry integration
|
||||
│ │ ├── auth.provider.ts # IAuthProvider implementation
|
||||
│ │ └── storage.provider.ts # IStorageHooks for quota/audit
|
||||
│ └── interfaces/ # TypeScript interfaces & types
|
||||
└── ui/ # Angular 19 + Tailwind CSS frontend
|
||||
└── src/app/
|
||||
├── features/ # Login, dashboard, orgs, repos, packages, tokens, admin
|
||||
├── core/ # Services, guards, interceptors
|
||||
└── shared/ # Layout, UI components
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/v1/auth/login` | Login with email/password |
|
||||
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
||||
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
||||
| `GET` | `/api/v1/auth/me` | Get current user |
|
||||
|
||||
### Organizations
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations` | List organizations |
|
||||
| `POST` | `/api/v1/organizations` | Create organization |
|
||||
| `GET` | `/api/v1/organizations/:id` | Get organization details |
|
||||
| `PUT` | `/api/v1/organizations/:id` | Update organization |
|
||||
| `DELETE` | `/api/v1/organizations/:id` | Delete organization |
|
||||
|
||||
### Repositories
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/:orgId/repositories` | List repositories |
|
||||
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repository |
|
||||
| `GET` | `/api/v1/repositories/:id` | Get repository details |
|
||||
|
||||
### Packages
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/packages` | List packages |
|
||||
| `GET` | `/api/v1/packages/:id` | Get package details |
|
||||
| `GET` | `/api/v1/packages/:id/versions` | List package versions |
|
||||
|
||||
### API Tokens
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/tokens` | List user's tokens |
|
||||
| `POST` | `/api/v1/tokens` | Create new token |
|
||||
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
||||
|
||||
## 🔌 Protocol Endpoints
|
||||
|
||||
The registry handles protocol-specific endpoints automatically via `@push.rocks/smartregistry`:
|
||||
|
||||
- **NPM**: `/-/*`, `/@scope/*`
|
||||
- **OCI/Docker**: `/v2/*`
|
||||
- **Maven**: `/maven2/*`
|
||||
- **PyPI**: `/simple/*`, `/pypi/*`
|
||||
- **Cargo**: `/api/v1/crates/*`
|
||||
- **Composer**: `/packages.json`, `/p/*`
|
||||
- **RubyGems**: `/api/v1/gems/*`, `/gems/*`
|
||||
|
||||
## 🔧 Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Runtime | Deno |
|
||||
| Language | TypeScript |
|
||||
| Database | MongoDB via `@push.rocks/smartdata` |
|
||||
| Storage | S3 via `@push.rocks/smartbucket` |
|
||||
| Registry | `@push.rocks/smartregistry` |
|
||||
| Frontend | Angular 19 |
|
||||
| Auth | JWT with session management |
|
||||
| **Runtime** | Deno 2.x |
|
||||
| **Language** | TypeScript (strict mode) |
|
||||
| **Database** | MongoDB via [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) |
|
||||
| **Storage** | S3 via [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) |
|
||||
| **Registry Core** | [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry) |
|
||||
| **Frontend** | Angular 19 (Signals, Zoneless) + Tailwind CSS |
|
||||
| **Auth** | JWT (HS256) + OAuth/OIDC + LDAP |
|
||||
| **Build** | [`@git.zone/tsdeno`](https://code.foss.global/git.zone/tsdeno) cross-compilation |
|
||||
| **CI/CD** | Gitea Actions → binary releases |
|
||||
|
||||
## 🛡️ Security Features
|
||||
## 🛠️ Development
|
||||
|
||||
- **JWT Authentication** - Short-lived access tokens with refresh flow
|
||||
- **Session Management** - Track and invalidate active sessions
|
||||
- **Scoped API Tokens** - Fine-grained permissions per token
|
||||
- **RBAC** - Organization-level role-based access control
|
||||
- **Audit Logging** - Comprehensive action logging
|
||||
- **Password Hashing** - PBKDF2-style hashing with salts
|
||||
|
||||
## 📜 CLI Commands
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
deno run --allow-all mod.ts server [--ephemeral]
|
||||
# Start dev server with hot reload (reads .nogit/env.json)
|
||||
deno task dev
|
||||
|
||||
# Show help
|
||||
deno run --allow-all mod.ts help
|
||||
# Watch mode: backend + UI + bundler concurrently
|
||||
pnpm run watch
|
||||
|
||||
# Build Angular UI
|
||||
deno task build
|
||||
|
||||
# Bundle UI into embedded TypeScript
|
||||
deno task bundle-ui
|
||||
|
||||
# Cross-compile binaries for all platforms
|
||||
deno task compile
|
||||
|
||||
# Type check / format / lint
|
||||
deno task check
|
||||
deno task fmt
|
||||
deno task lint
|
||||
|
||||
# Run tests
|
||||
deno task test # All tests
|
||||
deno task test:unit # Unit tests only
|
||||
deno task test:integration # Integration tests (requires running server)
|
||||
deno task test:e2e # E2E tests (requires running server + services)
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--ephemeral` / `-e` - Load config from `.nogit/env.json` instead of environment variables
|
||||
### Build & Release
|
||||
|
||||
Releases are automated via Gitea Actions (`.gitea/workflows/release.yml`):
|
||||
|
||||
1. Push a `v*` tag
|
||||
2. CI builds the Angular UI and bundles it into TypeScript
|
||||
3. `tsdeno compile` produces binaries for 4 platforms (linux-x64, linux-arm64, macos-x64, macos-arm64)
|
||||
4. Binaries + SHA256 checksums are uploaded as Gitea release assets
|
||||
|
||||
Compile targets are configured in `npmextra.json` under `@git.zone/tsdeno`.
|
||||
|
||||
### Storage Layout
|
||||
|
||||
Artifacts are stored in S3 at:
|
||||
|
||||
```
|
||||
{storagePath}/{protocol}/{orgName}/{packageName}/{version}/{filename}
|
||||
```
|
||||
|
||||
For example: `packages/npm/myorg/mypackage/1.0.0/mypackage-1.0.0.tgz`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
/**
|
||||
* Test configuration for Stack.Gallery Registry tests
|
||||
* Uses @push.rocks/qenv to read from .nogit/env.json
|
||||
*/
|
||||
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
|
||||
const testQenv = new Qenv('./', '.nogit/', false);
|
||||
|
||||
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL')
|
||||
|| 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
|
||||
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME')
|
||||
|| 'test-registry';
|
||||
|
||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT') || 'localhost';
|
||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT') || '9100';
|
||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY') || 'testadmin';
|
||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY') || 'testpassword';
|
||||
const s3Bucket = await testQenv.getEnvVarOnDemand('S3_BUCKET') || 'test-registry';
|
||||
const s3UseSsl = await testQenv.getEnvVarOnDemand('S3_USESSL');
|
||||
|
||||
const s3Protocol = s3UseSsl === 'true' ? 'https' : 'http';
|
||||
const s3EndpointUrl = `${s3Protocol}://${s3Endpoint}:${s3Port}`;
|
||||
|
||||
export const testConfig = {
|
||||
mongodb: {
|
||||
url: 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin',
|
||||
name: 'test-registry',
|
||||
url: mongoUrl,
|
||||
name: mongoName,
|
||||
},
|
||||
s3: {
|
||||
endpoint: 'http://localhost:9100',
|
||||
accessKey: 'testadmin',
|
||||
secretKey: 'testpassword',
|
||||
bucket: 'test-registry',
|
||||
endpoint: s3EndpointUrl,
|
||||
accessKey: s3AccessKey,
|
||||
secretKey: s3SecretKey,
|
||||
bucket: s3Bucket,
|
||||
region: 'us-east-1',
|
||||
},
|
||||
jwt: {
|
||||
@@ -35,26 +55,8 @@ export const testConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get test config with environment variable overrides
|
||||
* Get test config (kept for backward compatibility)
|
||||
*/
|
||||
export function getTestConfig() {
|
||||
return {
|
||||
...testConfig,
|
||||
mongodb: {
|
||||
...testConfig.mongodb,
|
||||
url: Deno.env.get('TEST_MONGODB_URL') || testConfig.mongodb.url,
|
||||
name: Deno.env.get('TEST_MONGODB_NAME') || testConfig.mongodb.name,
|
||||
},
|
||||
s3: {
|
||||
...testConfig.s3,
|
||||
endpoint: Deno.env.get('TEST_S3_ENDPOINT') || testConfig.s3.endpoint,
|
||||
accessKey: Deno.env.get('TEST_S3_ACCESS_KEY') || testConfig.s3.accessKey,
|
||||
secretKey: Deno.env.get('TEST_S3_SECRET_KEY') || testConfig.s3.secretKey,
|
||||
bucket: Deno.env.get('TEST_S3_BUCKET') || testConfig.s3.bucket,
|
||||
},
|
||||
registry: {
|
||||
...testConfig.registry,
|
||||
url: Deno.env.get('TEST_REGISTRY_URL') || testConfig.registry.url,
|
||||
},
|
||||
};
|
||||
return testConfig;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,9 @@ describe('Package Model', () => {
|
||||
return {
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: testUserId,
|
||||
publishedById: testUserId,
|
||||
size: 1024,
|
||||
checksum: `sha256-${crypto.randomUUID()}`,
|
||||
checksumAlgorithm: 'sha256',
|
||||
digest: `sha256:${crypto.randomUUID()}`,
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
};
|
||||
@@ -124,7 +123,7 @@ describe('Package Model', () => {
|
||||
await createPackage('find-this');
|
||||
await createPackage('other');
|
||||
|
||||
const results = await Package.search('search');
|
||||
const results = await Package.searchPackages('search');
|
||||
assertEquals(results.length, 1);
|
||||
assertEquals(results[0].name, 'search-me');
|
||||
});
|
||||
@@ -134,14 +133,14 @@ describe('Package Model', () => {
|
||||
pkg.description = 'A unique description for testing';
|
||||
await pkg.save();
|
||||
|
||||
const results = await Package.search('unique description');
|
||||
const results = await Package.searchPackages('unique description');
|
||||
assertEquals(results.length, 1);
|
||||
});
|
||||
|
||||
it('should filter by protocol', async () => {
|
||||
await createPackage('npm-pkg');
|
||||
|
||||
const results = await Package.search('npm', { protocol: 'oci' });
|
||||
const results = await Package.searchPackages('npm', { protocol: 'oci' });
|
||||
assertEquals(results.length, 0);
|
||||
});
|
||||
|
||||
@@ -150,10 +149,10 @@ describe('Package Model', () => {
|
||||
await createPackage('page2');
|
||||
await createPackage('page3');
|
||||
|
||||
const firstPage = await Package.search('page', { limit: 2, offset: 0 });
|
||||
const firstPage = await Package.searchPackages('page', { limit: 2, offset: 0 });
|
||||
assertEquals(firstPage.length, 2);
|
||||
|
||||
const secondPage = await Package.search('page', { limit: 2, offset: 2 });
|
||||
const secondPage = await Package.searchPackages('page', { limit: 2, offset: 2 });
|
||||
assertEquals(secondPage.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,8 +104,8 @@ describe('TokenService', () => {
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.userId, testUserId);
|
||||
assertEquals(validation.protocols.includes('npm'), true);
|
||||
assertEquals(validation.token!.userId, testUserId);
|
||||
assertEquals(validation.token!.protocols.includes('npm'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.3.0',
|
||||
version: '1.4.2',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class AdminAuthApi {
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
createdById: ctx.actor!.userId!,
|
||||
});
|
||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||
// Encrypt bind password
|
||||
@@ -124,7 +124,7 @@ export class AdminAuthApi {
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
createdById: ctx.actor!.userId!,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
@@ -138,11 +138,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_CREATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_created',
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
@@ -270,11 +269,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_updated',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
@@ -321,11 +319,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_DELETED', 'system', {
|
||||
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_disabled',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
@@ -360,11 +357,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
|
||||
resourceId: id,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
action: 'auth_provider_tested',
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
@@ -433,12 +429,9 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'platform_settings_updated',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -39,7 +39,13 @@ export class OrganizationApi {
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
const memberships = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
const orgs: Organization[] = [];
|
||||
for (const m of memberships) {
|
||||
const org = await Organization.findById(m.organizationId);
|
||||
if (org) orgs.push(org);
|
||||
}
|
||||
organizations = orgs;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,8 +161,8 @@ export class OrganizationApi {
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = ctx.actor.userId;
|
||||
membership.role = 'owner';
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
membership.invitedBy = ctx.actor.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
@@ -310,7 +316,7 @@ export class OrganizationApi {
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
addedAt: m.addedAt,
|
||||
addedAt: m.joinedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
@@ -384,8 +390,8 @@ export class OrganizationApi {
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
membership.invitedBy = ctx.actor.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
@@ -398,7 +404,7 @@ export class OrganizationApi {
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
addedAt: membership.addedAt,
|
||||
addedAt: membership.joinedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class PackageApi {
|
||||
// For anonymous users, only search public packages
|
||||
const isPrivate = ctx.actor?.userId ? undefined : false;
|
||||
|
||||
const packages = await Package.search(query, {
|
||||
const packages = await Package.searchPackages(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
@@ -174,7 +174,7 @@ export class PackageApi {
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
checksum: data.metadata?.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Repository, Organization } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
private permissionService: PermissionService;
|
||||
@@ -26,7 +26,6 @@ export class RepositoryApi {
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get accessible repositories
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
@@ -38,9 +37,9 @@ export class RepositoryApi {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
createdAt: repo.createdAt,
|
||||
@@ -84,11 +83,10 @@ export class RepositoryApi {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes,
|
||||
createdAt: repo.createdAt,
|
||||
@@ -118,17 +116,22 @@ export class RepositoryApi {
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, protocols, isPublic, settings } = body;
|
||||
const { name, description, protocol, visibility } = body as {
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Repository name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,30 +141,15 @@ export class RepositoryApi {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check if name is taken in this org
|
||||
const existing = await Repository.findByName(orgId, name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = orgId;
|
||||
repo.name = name;
|
||||
repo.displayName = displayName || name;
|
||||
repo.description = description;
|
||||
repo.protocols = protocols || ['npm'];
|
||||
repo.isPublic = isPublic ?? false;
|
||||
repo.settings = settings || {
|
||||
allowOverwrite: false,
|
||||
immutableTags: false,
|
||||
retentionDays: 0,
|
||||
};
|
||||
repo.createdAt = new Date();
|
||||
repo.createdById = ctx.actor.userId;
|
||||
|
||||
await repo.save();
|
||||
// Create repository using the model's factory method
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: orgId,
|
||||
name,
|
||||
description,
|
||||
protocol: protocol || 'npm',
|
||||
visibility: visibility || 'private',
|
||||
createdById: ctx.actor.userId,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
@@ -177,9 +165,9 @@ export class RepositoryApi {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
@@ -217,13 +205,13 @@ export class RepositoryApi {
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, protocols, isPublic, settings } = body;
|
||||
const { description, visibility } = body as {
|
||||
description?: string;
|
||||
visibility?: TRepositoryVisibility;
|
||||
};
|
||||
|
||||
if (displayName !== undefined) repo.displayName = displayName;
|
||||
if (description !== undefined) repo.description = description;
|
||||
if (protocols !== undefined) repo.protocols = protocols;
|
||||
if (isPublic !== undefined) repo.isPublic = isPublic;
|
||||
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
|
||||
if (visibility !== undefined) repo.visibility = visibility;
|
||||
|
||||
await repo.save();
|
||||
|
||||
@@ -232,11 +220,10 @@ export class RepositoryApi {
|
||||
body: {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -137,8 +137,8 @@ export class UserApi {
|
||||
user.username = username;
|
||||
user.passwordHash = passwordHash;
|
||||
user.displayName = displayName || username;
|
||||
user.isSystemAdmin = isSystemAdmin || false;
|
||||
user.isActive = true;
|
||||
user.isPlatformAdmin = isSystemAdmin || false;
|
||||
user.status = 'active';
|
||||
user.createdAt = new Date();
|
||||
|
||||
await user.save();
|
||||
@@ -189,8 +189,8 @@ export class UserApi {
|
||||
|
||||
// Only admins can change these
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.isActive = isActive;
|
||||
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
|
||||
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
|
||||
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
@@ -245,7 +245,7 @@ export class UserApi {
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.isActive = false;
|
||||
user.status = 'suspended';
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
|
||||
@@ -51,6 +51,13 @@ export type TAuditAction =
|
||||
| 'PACKAGE_PULLED'
|
||||
| 'PACKAGE_DELETED'
|
||||
| 'PACKAGE_DEPRECATED'
|
||||
// Auth Provider Management
|
||||
| 'AUTH_PROVIDER_CREATED'
|
||||
| 'AUTH_PROVIDER_UPDATED'
|
||||
| 'AUTH_PROVIDER_DELETED'
|
||||
| 'AUTH_PROVIDER_TESTED'
|
||||
// Platform Settings
|
||||
| 'PLATFORM_SETTINGS_UPDATED'
|
||||
// Security Events
|
||||
| 'SECURITY_SCAN_COMPLETED'
|
||||
| 'SECURITY_VULNERABILITY_FOUND'
|
||||
@@ -65,6 +72,8 @@ export type TAuditResourceType =
|
||||
| 'package'
|
||||
| 'api_token'
|
||||
| 'session'
|
||||
| 'auth_provider'
|
||||
| 'platform_settings'
|
||||
| 'system';
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
public createdById?: string; // Who created the token (for audit)
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
public override name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
|
||||
@@ -36,7 +36,7 @@ export class AuthProvider
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public name: string = ''; // URL-safe slug identifier
|
||||
public override name: string = ''; // URL-safe slug identifier
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
|
||||
@@ -25,7 +25,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public name: string = ''; // URL-safe slug
|
||||
public override name: string = ''; // URL-safe slug
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
|
||||
@@ -31,7 +31,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@plugins.smartdata.index()
|
||||
public name: string = '';
|
||||
public override name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
@@ -110,7 +110,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
/**
|
||||
* Search packages
|
||||
*/
|
||||
public static async search(
|
||||
public static async searchPackages(
|
||||
query: string,
|
||||
options?: {
|
||||
protocol?: TRegistryProtocol;
|
||||
|
||||
@@ -99,6 +99,16 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
return perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find permission for a user on a repository (alias for getUserPermission)
|
||||
*/
|
||||
public static async findPermission(
|
||||
repositoryId: string,
|
||||
userId: string
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getUserPermission(repositoryId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's direct permission on repository
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public name: string = '';
|
||||
public override name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
@@ -39,6 +39,12 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
@plugins.smartdata.svDb()
|
||||
public starCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public packageCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public storageBytes: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
@@ -128,6 +134,20 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
return await Repository.getInstances(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this repository is public
|
||||
*/
|
||||
public get isPublic(): boolean {
|
||||
return this.visibility === 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find repository by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Repository | null> {
|
||||
return await Repository.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implement
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public name: string = '';
|
||||
public override name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
@@ -25,6 +25,9 @@ export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implement
|
||||
@plugins.smartdata.svDb()
|
||||
public isDefaultTeam: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public repositoryIds: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@@ -46,206 +46,114 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request and return the actor
|
||||
* Called by smartregistry for every incoming request
|
||||
* Authenticate with username/password credentials
|
||||
* Returns userId on success, null on failure
|
||||
*/
|
||||
public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise<plugins.smartregistry.IRequestActor> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: request.ip,
|
||||
actorUserAgent: request.userAgent,
|
||||
});
|
||||
|
||||
// Extract auth credentials
|
||||
const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization'];
|
||||
|
||||
// Try Bearer token (API token)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return await this.authenticateWithApiToken(token, request, auditContext);
|
||||
}
|
||||
|
||||
// Try Basic auth (for npm/other CLI tools)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const credentials = authHeader.substring(6);
|
||||
return await this.authenticateWithBasicAuth(credentials, request, auditContext);
|
||||
}
|
||||
|
||||
// Anonymous access
|
||||
return this.createAnonymousActor(request);
|
||||
public async authenticate(
|
||||
credentials: plugins.smartregistry.ICredentials
|
||||
): Promise<string | null> {
|
||||
const result = await this.authService.login(credentials.username, credentials.password);
|
||||
if (!result.success || !result.user) return null;
|
||||
return result.user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor has permission for the requested action
|
||||
* Validate a token and return auth token info
|
||||
*/
|
||||
public async validateToken(
|
||||
token: string,
|
||||
protocol?: plugins.smartregistry.TRegistryProtocol
|
||||
): Promise<plugins.smartregistry.IAuthToken | null> {
|
||||
// Try API token (srg_ prefix)
|
||||
if (token.startsWith('srg_')) {
|
||||
const result = await this.tokenService.validateToken(token);
|
||||
if (!result.valid || !result.token || !result.user) return null;
|
||||
|
||||
return {
|
||||
type: (protocol || result.token.protocols[0] || 'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
userId: result.user.id,
|
||||
scopes: result.token.scopes.map((s) =>
|
||||
`${s.protocol}:${s.actions.join(',')}`
|
||||
),
|
||||
readonly: !result.token.scopes.some((s) =>
|
||||
s.actions.includes('write') || s.actions.includes('*')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Try JWT access token
|
||||
const validated = await this.authService.validateAccessToken(token);
|
||||
if (!validated) return null;
|
||||
|
||||
return {
|
||||
type: (protocol || 'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
userId: validated.user.id,
|
||||
scopes: ['*'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new token for a user and protocol
|
||||
*/
|
||||
public async createToken(
|
||||
userId: string,
|
||||
protocol: plugins.smartregistry.TRegistryProtocol,
|
||||
options?: plugins.smartregistry.ITokenOptions
|
||||
): Promise<string> {
|
||||
const result = await this.tokenService.createToken({
|
||||
userId,
|
||||
name: `${protocol}-token`,
|
||||
protocols: [protocol as TRegistryProtocol],
|
||||
scopes: [
|
||||
{
|
||||
protocol: protocol as TRegistryProtocol,
|
||||
actions: options?.readonly ? ['read'] : ['read', 'write', 'delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
return result.rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token
|
||||
*/
|
||||
public async revokeToken(token: string): Promise<void> {
|
||||
if (token.startsWith('srg_')) {
|
||||
// Hash and find the token
|
||||
const result = await this.tokenService.validateToken(token);
|
||||
if (result.valid && result.token) {
|
||||
await this.tokenService.revokeToken(result.token.id, 'provider_revoked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token holder is authorized for a resource and action
|
||||
*/
|
||||
public async authorize(
|
||||
actor: plugins.smartregistry.IRequestActor,
|
||||
request: plugins.smartregistry.IAuthorizationRequest
|
||||
): Promise<plugins.smartregistry.IAuthorizationResult> {
|
||||
const stackActor = actor as IStackGalleryActor;
|
||||
token: plugins.smartregistry.IAuthToken | null,
|
||||
resource: string,
|
||||
action: string
|
||||
): Promise<boolean> {
|
||||
// Anonymous access: only public reads
|
||||
if (!token) return false;
|
||||
|
||||
// Anonymous users can only read public packages
|
||||
if (stackActor.type === 'anonymous') {
|
||||
if (request.action === 'read' && request.isPublic) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Authentication required',
|
||||
statusCode: 401,
|
||||
};
|
||||
}
|
||||
// Parse resource string (format: "protocol:type:name" or "org/repo")
|
||||
const userId = token.userId;
|
||||
if (!userId) return false;
|
||||
|
||||
// Check protocol access
|
||||
if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) &&
|
||||
!stackActor.protocols.includes('*' as TRegistryProtocol)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Token does not have access to ${request.protocol} protocol`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
// Map action
|
||||
const mappedAction = this.mapAction(action);
|
||||
|
||||
// Map action to TAction
|
||||
const action = this.mapAction(request.action);
|
||||
// For simple authorization without specific resource context,
|
||||
// check if user is active
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return false;
|
||||
|
||||
// Resolve permissions
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: stackActor.userId!,
|
||||
organizationId: request.organizationId,
|
||||
repositoryId: request.repositoryId,
|
||||
protocol: request.protocol as TRegistryProtocol,
|
||||
});
|
||||
// System admins bypass all checks
|
||||
if (user.isSystemAdmin) return true;
|
||||
|
||||
// Check permission
|
||||
let allowed = false;
|
||||
switch (action) {
|
||||
case 'read':
|
||||
allowed = permissions.canRead || (request.isPublic ?? false);
|
||||
break;
|
||||
case 'write':
|
||||
allowed = permissions.canWrite;
|
||||
break;
|
||||
case 'delete':
|
||||
allowed = permissions.canDelete;
|
||||
break;
|
||||
case 'admin':
|
||||
allowed = permissions.canAdmin;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API token
|
||||
*/
|
||||
private async authenticateWithApiToken(
|
||||
rawToken: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
const result = await this.tokenService.validateToken(rawToken, request.ip);
|
||||
|
||||
if (!result.valid || !result.token || !result.user) {
|
||||
await auditContext.logFailure(
|
||||
'TOKEN_USED',
|
||||
'api_token',
|
||||
result.errorCode || 'UNKNOWN',
|
||||
result.errorMessage || 'Token validation failed'
|
||||
);
|
||||
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
await auditContext.log('TOKEN_USED', 'api_token', {
|
||||
resourceId: result.token.id,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using Basic auth (username:password or username:token)
|
||||
*/
|
||||
private async authenticateWithBasicAuth(
|
||||
credentials: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
try {
|
||||
const decoded = atob(credentials);
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
// If password looks like an API token, try token auth
|
||||
if (password?.startsWith('srg_')) {
|
||||
return await this.authenticateWithApiToken(password, request, auditContext);
|
||||
}
|
||||
|
||||
// Otherwise try username/password (email/password)
|
||||
const result = await this.authService.login(username, password, {
|
||||
userAgent: request.userAgent,
|
||||
ipAddress: request.ip,
|
||||
});
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous actor
|
||||
*/
|
||||
private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor {
|
||||
return {
|
||||
type: 'anonymous',
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: [],
|
||||
permissions: {
|
||||
canRead: false,
|
||||
canWrite: false,
|
||||
canDelete: false,
|
||||
},
|
||||
};
|
||||
return mappedAction === 'read'; // Default: authenticated users can read
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { Package } from '../models/package.ts';
|
||||
import { Repository } from '../models/repository.ts';
|
||||
import { Organization } from '../models/organization.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
export interface IStorageConfig {
|
||||
export interface IStorageProviderConfig {
|
||||
bucket: plugins.smartbucket.SmartBucket;
|
||||
bucketName: string;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
@@ -20,222 +20,192 @@ export interface IStorageConfig {
|
||||
* and stores artifacts in S3 via smartbucket
|
||||
*/
|
||||
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
|
||||
private config: IStorageConfig;
|
||||
private config: IStorageProviderConfig;
|
||||
|
||||
constructor(config: IStorageConfig) {
|
||||
constructor(config: IStorageProviderConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is stored
|
||||
* Use this to validate, transform, or prepare for storage
|
||||
*/
|
||||
public async beforeStore(context: plugins.smartregistry.IStorageContext): Promise<plugins.smartregistry.IStorageContext> {
|
||||
public async beforePut(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
): Promise<plugins.smartregistry.IBeforePutResult> {
|
||||
// Validate organization exists and has quota
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (!org) {
|
||||
throw new Error(`Organization not found: ${context.organizationId}`);
|
||||
}
|
||||
const orgId = context.actor?.orgId;
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
return { allowed: false, reason: `Organization not found: ${orgId}` };
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
const newSize = context.size || 0;
|
||||
if (org.settings.quotas.maxStorageBytes > 0) {
|
||||
if (org.usedStorageBytes + newSize > org.settings.quotas.maxStorageBytes) {
|
||||
throw new Error('Organization storage quota exceeded');
|
||||
// Check storage quota
|
||||
const newSize = context.metadata?.size || 0;
|
||||
if (!org.hasStorageAvailable(newSize)) {
|
||||
return { allowed: false, reason: 'Organization storage quota exceeded' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate repository exists
|
||||
const repo = await Repository.findById(context.repositoryId);
|
||||
if (!repo) {
|
||||
throw new Error(`Repository not found: ${context.repositoryId}`);
|
||||
}
|
||||
|
||||
// Check repository protocol
|
||||
if (!repo.protocols.includes(context.protocol as TRegistryProtocol)) {
|
||||
throw new Error(`Repository does not support ${context.protocol} protocol`);
|
||||
}
|
||||
|
||||
return context;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is successfully stored
|
||||
* Update database records and metrics
|
||||
*/
|
||||
public async afterStore(context: plugins.smartregistry.IStorageContext): Promise<void> {
|
||||
public async afterPut(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version || 'unknown';
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
// Get or create package record
|
||||
let pkg = await Package.findById(packageId);
|
||||
if (!pkg) {
|
||||
pkg = new Package();
|
||||
pkg.id = packageId;
|
||||
pkg.organizationId = context.organizationId;
|
||||
pkg.repositoryId = context.repositoryId;
|
||||
pkg.organizationId = orgId;
|
||||
pkg.protocol = protocol;
|
||||
pkg.name = context.packageName;
|
||||
pkg.createdById = context.actorId || '';
|
||||
pkg.name = packageName;
|
||||
pkg.createdById = context.actor?.userId || '';
|
||||
pkg.createdAt = new Date();
|
||||
}
|
||||
|
||||
// Add version
|
||||
pkg.addVersion({
|
||||
version: context.version,
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: context.actorId || '',
|
||||
size: context.size || 0,
|
||||
checksum: context.checksum || '',
|
||||
checksumAlgorithm: context.checksumAlgorithm || 'sha256',
|
||||
publishedById: context.actor?.userId || '',
|
||||
size: context.metadata?.size || 0,
|
||||
digest: context.metadata?.digest,
|
||||
downloads: 0,
|
||||
metadata: context.metadata || {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
// Update dist tags if provided
|
||||
if (context.tags) {
|
||||
for (const [tag, version] of Object.entries(context.tags)) {
|
||||
pkg.distTags[tag] = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Set latest tag if not set
|
||||
if (!pkg.distTags['latest']) {
|
||||
pkg.distTags['latest'] = context.version;
|
||||
pkg.distTags['latest'] = version;
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update organization storage usage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes += context.size || 0;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(context.metadata?.size || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'anonymous',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackagePublished(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version,
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is fetched
|
||||
*/
|
||||
public async beforeFetch(context: plugins.smartregistry.IFetchContext): Promise<plugins.smartregistry.IFetchContext> {
|
||||
return context;
|
||||
if (context.actor?.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).logPackagePublished(packageId, packageName, version, orgId, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is fetched
|
||||
* Update download metrics
|
||||
*/
|
||||
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
|
||||
public async afterGet(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version;
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (pkg) {
|
||||
await pkg.incrementDownloads(context.version);
|
||||
}
|
||||
|
||||
// Audit log for authenticated users
|
||||
if (context.actorId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: 'user',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackageDownloaded(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version || 'latest',
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
await pkg.incrementDownloads(version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is deleted
|
||||
*/
|
||||
public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise<plugins.smartregistry.IDeleteContext> {
|
||||
return context;
|
||||
public async beforeDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is deleted
|
||||
*/
|
||||
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
|
||||
public async afterDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version;
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (!pkg) return;
|
||||
|
||||
if (context.version) {
|
||||
// Delete specific version
|
||||
const version = pkg.versions[context.version];
|
||||
if (version) {
|
||||
const sizeReduction = version.size;
|
||||
delete pkg.versions[context.version];
|
||||
if (version) {
|
||||
const versionData = pkg.versions[version];
|
||||
if (versionData) {
|
||||
const sizeReduction = versionData.size;
|
||||
delete pkg.versions[version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, ver] of Object.entries(pkg.distTags)) {
|
||||
if (ver === context.version) {
|
||||
if (ver === version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// If no versions left, delete the package
|
||||
if (Object.keys(pkg.versions).length === 0) {
|
||||
await pkg.delete();
|
||||
} else {
|
||||
await pkg.save();
|
||||
}
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(-sizeReduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete entire package
|
||||
const sizeReduction = pkg.storageBytes;
|
||||
await pkg.delete();
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(-sizeReduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'system',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: context.packageName,
|
||||
metadata: { version: context.version },
|
||||
success: true,
|
||||
});
|
||||
if (context.actor?.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,11 +229,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
data: Uint8Array,
|
||||
contentType?: string
|
||||
): Promise<string> {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
await bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(data),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
contents: data as unknown as string,
|
||||
});
|
||||
return path;
|
||||
}
|
||||
@@ -273,10 +242,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
*/
|
||||
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
const file = await bucket.fastGet({ path });
|
||||
if (!file) return null;
|
||||
return new Uint8Array(file.contents);
|
||||
return new Uint8Array(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -287,8 +256,8 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
*/
|
||||
public async deleteArtifact(path: string): Promise<boolean> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
await bucket.fastDelete({ path });
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
await bucket.fastRemove({ path });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
167
ts/registry.ts
167
ts/registry.ts
@@ -86,6 +86,7 @@ export class StackGalleryRegistry {
|
||||
// Initialize storage hooks
|
||||
this.storageHooks = new StackGalleryStorageHooks({
|
||||
bucket: this.smartBucket,
|
||||
bucketName: this.config.s3Bucket,
|
||||
basePath: this.config.storagePath!,
|
||||
});
|
||||
|
||||
@@ -95,16 +96,22 @@ export class StackGalleryRegistry {
|
||||
authProvider: this.authProvider,
|
||||
storageHooks: this.storageHooks,
|
||||
storage: {
|
||||
type: 's3',
|
||||
bucket: this.smartBucket,
|
||||
basePath: this.config.storagePath,
|
||||
endpoint: this.config.s3Endpoint,
|
||||
accessKey: this.config.s3AccessKey,
|
||||
accessSecret: this.config.s3SecretKey,
|
||||
bucketName: this.config.s3Bucket,
|
||||
region: this.config.s3Region,
|
||||
},
|
||||
auth: {
|
||||
jwtSecret: this.config.jwtSecret || 'change-me-in-production',
|
||||
tokenStore: 'database',
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
realm: 'stack.gallery',
|
||||
service: 'registry',
|
||||
},
|
||||
},
|
||||
upstreamCache: this.config.enableUpstreamCache
|
||||
? {
|
||||
enabled: true,
|
||||
expiryHours: this.config.upstreamCacheExpiry,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||
|
||||
@@ -161,30 +168,34 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
|
||||
// Registry protocol endpoints (handled by smartregistry)
|
||||
// NPM: /-/..., /@scope/package (but not /packages which is UI route)
|
||||
// OCI: /v2/...
|
||||
// Maven: /maven2/...
|
||||
// PyPI: /simple/..., /pypi/...
|
||||
// Cargo: /api/v1/crates/...
|
||||
// Composer: /packages.json, /p/...
|
||||
// RubyGems: /api/v1/gems/..., /gems/...
|
||||
const registryPaths = ['/-/', '/v2/', '/maven2/', '/simple/', '/pypi/', '/api/v1/crates/', '/packages.json', '/p/', '/api/v1/gems/', '/gems/'];
|
||||
const isRegistryPath = registryPaths.some(p => path.startsWith(p)) ||
|
||||
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
||||
const registryPaths = [
|
||||
'/-/',
|
||||
'/v2/',
|
||||
'/maven2/',
|
||||
'/simple/',
|
||||
'/pypi/',
|
||||
'/api/v1/crates/',
|
||||
'/packages.json',
|
||||
'/p/',
|
||||
'/api/v1/gems/',
|
||||
'/gems/',
|
||||
];
|
||||
const isRegistryPath =
|
||||
registryPaths.some((p) => path.startsWith(p)) ||
|
||||
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
||||
|
||||
if (this.smartRegistry && isRegistryPath) {
|
||||
try {
|
||||
const response = await this.smartRegistry.handleRequest(request);
|
||||
if (response) return response;
|
||||
// Convert Request to IRequestContext
|
||||
const requestContext = await this.requestToContext(request);
|
||||
const response = await this.smartRegistry.handleRequest(requestContext);
|
||||
if (response) return this.contextResponseToResponse(response);
|
||||
} catch (error) {
|
||||
console.error('[StackGalleryRegistry] Request error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Internal server error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +208,82 @@ export class StackGalleryRegistry {
|
||||
return this.serveStaticFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Deno Request to smartregistry IRequestContext
|
||||
*/
|
||||
private async requestToContext(
|
||||
request: Request
|
||||
): Promise<plugins.smartregistry.IRequestContext> {
|
||||
const url = new URL(request.url);
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const query: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
let body: unknown = undefined;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
let rawBody: any = undefined;
|
||||
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
try {
|
||||
const bytes = new Uint8Array(await request.arrayBuffer());
|
||||
rawBody = bytes;
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('json')) {
|
||||
body = JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
} catch {
|
||||
// Body parsing failed, continue with undefined body
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
let token: string | undefined;
|
||||
const authHeader = headers['authorization'];
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
return {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
headers,
|
||||
query,
|
||||
body,
|
||||
rawBody,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert smartregistry IResponse to Deno Response
|
||||
*/
|
||||
private contextResponseToResponse(response: plugins.smartregistry.IResponse): Response {
|
||||
const headers = new Headers(response.headers || {});
|
||||
let body: BodyInit | null = null;
|
||||
|
||||
if (response.body !== undefined) {
|
||||
if (typeof response.body === 'string') {
|
||||
body = response.body;
|
||||
} else if (response.body instanceof Uint8Array) {
|
||||
body = response.body as unknown as BodyInit;
|
||||
} else {
|
||||
body = JSON.stringify(response.body);
|
||||
if (!headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from embedded UI
|
||||
*/
|
||||
@@ -206,7 +293,7 @@ export class StackGalleryRegistry {
|
||||
// Get embedded file
|
||||
const embeddedFile = getEmbeddedFile(filePath);
|
||||
if (embeddedFile) {
|
||||
return new Response(embeddedFile.data, {
|
||||
return new Response(embeddedFile.data as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': embeddedFile.contentType },
|
||||
});
|
||||
@@ -215,7 +302,7 @@ export class StackGalleryRegistry {
|
||||
// SPA fallback: serve index.html for unknown paths
|
||||
const indexFile = getEmbeddedFile('/index.html');
|
||||
if (indexFile) {
|
||||
return new Response(indexFile.data, {
|
||||
return new Response(indexFile.data as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
@@ -229,13 +316,10 @@ export class StackGalleryRegistry {
|
||||
*/
|
||||
private async handleApiRequest(request: Request): Promise<Response> {
|
||||
if (!this.apiRouter) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'API router not initialized' }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: 'API router not initialized' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return await this.apiRouter.handle(request);
|
||||
@@ -336,7 +420,9 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
|
||||
|
||||
const config: IRegistryConfig = {
|
||||
mongoUrl: env.MONGODB_URL || `mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoUrl:
|
||||
env.MONGODB_URL ||
|
||||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
||||
s3Endpoint: s3Endpoint,
|
||||
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
||||
@@ -356,7 +442,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables');
|
||||
} else {
|
||||
console.warn('[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:', error);
|
||||
console.warn(
|
||||
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
||||
error
|
||||
);
|
||||
}
|
||||
return createRegistryFromEnv();
|
||||
}
|
||||
|
||||
@@ -109,13 +109,13 @@ export class AuditService {
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('USER_LOGIN', 'user', userId);
|
||||
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('USER_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
return await this.logFailure('AUTH_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('USER_LOGOUT', 'user', userId);
|
||||
return await this.logSuccess('AUTH_LOGOUT', 'user', userId);
|
||||
}
|
||||
|
||||
public async logTokenCreated(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
@@ -133,7 +133,7 @@ export class AuditService {
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUBLISHED', 'package', {
|
||||
return await this.log('PACKAGE_PUSHED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
@@ -150,7 +150,7 @@ export class AuditService {
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_DOWNLOADED', 'package', {
|
||||
return await this.log('PACKAGE_PULLED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
@@ -161,7 +161,7 @@ export class AuditService {
|
||||
}
|
||||
|
||||
public async logOrganizationCreated(orgId: string, orgName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName);
|
||||
return await this.logSuccess('ORG_CREATED', 'organization', orgId, orgName);
|
||||
}
|
||||
|
||||
public async logRepositoryCreated(
|
||||
@@ -169,7 +169,7 @@ export class AuditService {
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPOSITORY_CREATED', 'repository', {
|
||||
return await this.log('REPO_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
resourceName: repoName,
|
||||
organizationId,
|
||||
@@ -184,7 +184,7 @@ export class AuditService {
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PERMISSION_CHANGED', resourceType, {
|
||||
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
|
||||
@@ -226,7 +226,7 @@ export class AuthService {
|
||||
actorId: userId,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
}).log('USER_LOGOUT', 'user', {
|
||||
}).log('AUTH_LOGOUT', 'user', {
|
||||
resourceId: userId,
|
||||
metadata: { sessionsInvalidated: count },
|
||||
success: true,
|
||||
|
||||
@@ -50,9 +50,9 @@ export class CryptoService {
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encoded
|
||||
encoded.buffer as ArrayBuffer
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
@@ -86,9 +86,9 @@ export class CryptoService {
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encrypted
|
||||
encrypted.buffer as ArrayBuffer
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
@@ -120,7 +120,7 @@ export class CryptoService {
|
||||
const keyBytes = this.hexToBytes(keyHex);
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ExternalAuthService {
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
await this.auditService.log('AUTH_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
@@ -143,7 +143,7 @@ export class ExternalAuthService {
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
}).log('AUTH_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
@@ -194,7 +194,7 @@ export class ExternalAuthService {
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
await this.auditService.log('AUTH_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
@@ -235,7 +235,7 @@ export class ExternalAuthService {
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
}).log('AUTH_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["ts/**/*", "mod.ts"],
|
||||
"exclude": ["node_modules", "dist", "ui"]
|
||||
}
|
||||
Reference in New Issue
Block a user