11 Commits

Author SHA1 Message Date
0fc74ff995 v1.4.2
All checks were successful
Release / build-and-release (push) Successful in 3m43s
2026-03-20 14:14:39 +00:00
d71ae08645 fix(registry): align registry integrations with updated auth, storage, repository, and audit models 2026-03-20 14:14:39 +00:00
fe3cb75095 v1.4.1
All checks were successful
Release / build-and-release (push) Successful in 3m49s
2026-03-20 13:57:11 +00:00
f76778ce45 fix(repo): no changes to commit 2026-03-20 13:57:11 +00:00
15ca1a67f4 v1.4.0
All checks were successful
Release / build-and-release (push) Successful in 3m51s
2026-03-20 13:56:43 +00:00
b05c53f967 feat(release,build,tests): add automated multi-platform release pipeline and align runtime, model, and test updates 2026-03-20 13:56:43 +00:00
4d561b3874 v1.3.0 2025-12-03 22:09:35 +00:00
d3fd40ce2f feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support 2025-12-03 22:09:35 +00:00
44e92d48f2 Add unit tests for models and services
- Implemented unit tests for the Package model, covering methods such as generateId, findById, findByName, and version management.
- Created unit tests for the Repository model, including repository creation, name validation, and retrieval methods.
- Added tests for the Session model, focusing on session creation, validation, and invalidation.
- Developed unit tests for the User model, ensuring user creation, password hashing, and retrieval methods function correctly.
- Implemented AuthService tests, validating login, token refresh, and session management.
- Added TokenService tests, covering token creation, validation, and revocation processes.
2025-11-28 15:27:04 +00:00
61324ba195 v1.2.0 2025-11-28 12:57:17 +00:00
dface47942 feat(tokens): Add support for organization-owned API tokens and org-level token management 2025-11-28 12:57:17 +00:00
101 changed files with 13372 additions and 1588 deletions

View 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 ""

9
.gitignore vendored
View File

@@ -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,16 @@ coverage/
*.tmp
*.temp
# Playwright MCP
.playwright-mcp/
# Debug
.nogit/
# Claude
CLAUDE.md
.claude/
stories/
# Package manager locks (keep pnpm-lock.yaml)
package-lock.json

View File

@@ -1,5 +1,53 @@
# 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
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to store provider configs, links, and platform auth settings
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation
- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler)
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection
- Register new API routes in ApiRouter and wire server-side handlers into the router
- Implement safeguards: mask secrets in admin responses, validate provider configs, and track connection test results and audit logs
## 2025-11-28 - 1.2.0 - feat(tokens)
Add support for organization-owned API tokens and org-level token management
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and new static getOrgTokens method
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate
- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks org management permissions
- Router: PermissionService instantiated and passed to TokenApi
- UI: api.service types and methods updated — IToken and ITokenScope include organizationId/createdById; getTokens and createToken now support an organizationId parameter and scoped scopes
- .gitignore: added stories/ to ignore
## 2025-11-28 - 1.1.0 - feat(registry)
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks

View File

@@ -1,39 +1,42 @@
{
"name": "@stack.gallery/registry",
"version": "1.1.0",
"version": "1.4.2",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"test": "deno test --allow-all",
"test": "deno test --allow-all --no-check test/",
"test:unit": "deno test --allow-all --no-check test/unit/",
"test:integration": "deno test --allow-all --no-check test/integration/",
"test:e2e": "deno test --allow-all --no-check test/e2e/",
"test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait",
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
"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"

1488
deno.lock generated

File diff suppressed because it is too large Load Diff

15
mod.ts
View File

@@ -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
View 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": {}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.1.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

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
overrides:
'@push.rocks/smartdata': link:../../push.rocks/smartdata

438
readme.md
View File

@@ -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.

View File

@@ -0,0 +1,48 @@
version: "3.8"
services:
mongodb-test:
image: mongo:7
container_name: stack-gallery-test-mongo
ports:
- "27117:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: testadmin
MONGO_INITDB_ROOT_PASSWORD: testpass
tmpfs:
- /data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
minio-test:
image: minio/minio:latest
container_name: stack-gallery-test-minio
ports:
- "9100:9000"
- "9101:9001"
environment:
MINIO_ROOT_USER: testadmin
MINIO_ROOT_PASSWORD: testpassword
command: server /data --console-address ":9001"
tmpfs:
- /data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
minio-setup:
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set testminio http://minio-test:9000 testadmin testpassword;
mc mb testminio/test-registry --ignore-existing;
exit 0;
"

290
test/e2e/npm.e2e.test.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* NPM Protocol E2E Tests
*
* Tests the full NPM package lifecycle: publish -> fetch -> delete
* Requires: npm CLI, running registry, Docker test infrastructure
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
createTestApiToken,
clients,
skipIfMissing,
runCommand,
testConfig,
} from '../helpers/index.ts';
const FIXTURE_DIR = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
'../fixtures/npm/@stack-test/demo-package'
);
describe('NPM E2E: Full lifecycle', () => {
let testUserId: string;
let testOrgName: string;
let apiToken: string;
let registryUrl: string;
let shouldSkip = false;
beforeAll(async () => {
// Check if npm is available
shouldSkip = await skipIfMissing('npm');
if (shouldSkip) return;
await setupTestDb();
registryUrl = testConfig.registry.url;
});
afterAll(async () => {
if (!shouldSkip) {
await teardownTestDb();
}
});
beforeEach(async () => {
if (shouldSkip) return;
await cleanupTestDb();
// Create test user and org
const { user } = await createTestUser({ status: 'active' });
testUserId = user.id;
const { organization } = await createOrgWithOwner(testUserId, { name: 'npm-test' });
testOrgName = organization.name;
// Create repository for npm packages
await createTestRepository({
organizationId: organization.id,
createdById: testUserId,
name: 'packages',
protocol: 'npm',
});
// Create API token with npm permissions
const { rawToken } = await createTestApiToken({
userId: testUserId,
name: 'npm-publish-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read', 'write', 'delete'] }],
});
apiToken = rawToken;
});
it('should publish package', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// Configure npm to use our registry
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `
//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}
@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/
`;
await Deno.writeTextFile(npmrcPath, npmrcContent);
try {
const result = await clients.npm.publish(
FIXTURE_DIR,
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
);
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
} finally {
// Cleanup .npmrc
try {
await Deno.remove(npmrcPath);
} catch {
// Ignore
}
}
});
it('should fetch package metadata', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
await Deno.writeTextFile(npmrcPath, npmrcContent);
try {
await clients.npm.publish(
FIXTURE_DIR,
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
);
// Fetch metadata via npm view
const viewResult = await runCommand(
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
{ env: { npm_config__authToken: apiToken } }
);
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
assertEquals(viewResult.stdout.includes('@stack-test/demo-package'), true);
} finally {
try {
await Deno.remove(npmrcPath);
} catch {
// Ignore
}
}
});
it('should install package', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// Create temp directory for installation
const tempDir = await Deno.makeTempDir({ prefix: 'npm-e2e-' });
try {
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
await Deno.writeTextFile(npmrcPath, npmrcContent);
await clients.npm.publish(
FIXTURE_DIR,
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
);
// Create package.json in temp dir
await Deno.writeTextFile(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'test-install', version: '1.0.0' })
);
// Create .npmrc in temp dir
await Deno.writeTextFile(
path.join(tempDir, '.npmrc'),
`@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`
);
// Install
const installResult = await clients.npm.install(
'@stack-test/demo-package@1.0.0',
`${registryUrl}/-/npm/${testOrgName}/`,
tempDir
);
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
// Verify installed
const pkgPath = path.join(tempDir, 'node_modules/@stack-test/demo-package');
const stat = await Deno.stat(pkgPath);
assertEquals(stat.isDirectory, true);
// Cleanup fixture .npmrc
try {
await Deno.remove(npmrcPath);
} catch {
// Ignore
}
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
it('should unpublish package', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
await Deno.writeTextFile(npmrcPath, npmrcContent);
try {
await clients.npm.publish(
FIXTURE_DIR,
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
);
// Unpublish
const unpublishResult = await clients.npm.unpublish(
'@stack-test/demo-package@1.0.0',
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
);
assertEquals(
unpublishResult.success,
true,
`npm unpublish failed: ${unpublishResult.stderr}`
);
// Verify package is gone
const viewResult = await runCommand(
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
{ env: { npm_config__authToken: apiToken } }
);
// Should fail since package was unpublished
assertEquals(viewResult.success, false);
} finally {
try {
await Deno.remove(npmrcPath);
} catch {
// Ignore
}
}
});
});
describe('NPM E2E: Edge cases', () => {
let shouldSkip = false;
beforeAll(async () => {
shouldSkip = await skipIfMissing('npm');
});
it('should handle scoped packages correctly', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// Test scoped package name handling
const scopedName = '@stack-test/demo-package';
assertEquals(scopedName.startsWith('@'), true);
assertEquals(scopedName.includes('/'), true);
});
it('should reject invalid package names', async function () {
if (shouldSkip) {
console.log('Skipping: npm not available');
return;
}
// npm has strict naming rules
const invalidNames = ['UPPERCASE', '..dots..', 'spaces here', '_underscore'];
for (const name of invalidNames) {
// Just verify these are considered invalid by npm standards
assertEquals(!/^[a-z0-9][-a-z0-9._]*$/.test(name), true);
}
});
});

190
test/e2e/oci.e2e.test.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* OCI Protocol E2E Tests
*
* Tests the full OCI container image lifecycle: push -> pull -> delete
* Requires: docker CLI, running registry, Docker test infrastructure
*/
import { assertEquals } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
createTestApiToken,
clients,
skipIfMissing,
testConfig,
} from '../helpers/index.ts';
const FIXTURE_DIR = path.join(
path.dirname(path.fromFileUrl(import.meta.url)),
'../fixtures/oci'
);
describe('OCI E2E: Full lifecycle', () => {
let testUserId: string;
let testOrgName: string;
let apiToken: string;
let registryHost: string;
let shouldSkip = false;
beforeAll(async () => {
// Check if docker is available
shouldSkip = await skipIfMissing('docker');
if (shouldSkip) return;
await setupTestDb();
const url = new URL(testConfig.registry.url);
registryHost = url.host;
});
afterAll(async () => {
if (!shouldSkip) {
await teardownTestDb();
}
});
beforeEach(async () => {
if (shouldSkip) return;
await cleanupTestDb();
// Create test user and org
const { user } = await createTestUser({ status: 'active' });
testUserId = user.id;
const { organization } = await createOrgWithOwner(testUserId, { name: 'oci-test' });
testOrgName = organization.name;
// Create repository for OCI images
await createTestRepository({
organizationId: organization.id,
createdById: testUserId,
name: 'images',
protocol: 'oci',
});
// Create API token with OCI permissions
const { rawToken } = await createTestApiToken({
userId: testUserId,
name: 'oci-push-token',
protocols: ['oci'],
scopes: [{ protocol: 'oci', actions: ['read', 'write', 'delete'] }],
});
apiToken = rawToken;
});
it('should build and push image', async function () {
if (shouldSkip) {
console.log('Skipping: docker not available');
return;
}
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
try {
// Build image
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
// Login to registry
const loginResult = await clients.docker.login(registryHost, 'token', apiToken);
assertEquals(loginResult.success, true, `docker login failed: ${loginResult.stderr}`);
// Push image
const pushResult = await clients.docker.push(imageName);
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
} finally {
// Cleanup local image
await clients.docker.rmi(imageName, true);
}
});
it('should pull image', async function () {
if (shouldSkip) {
console.log('Skipping: docker not available');
return;
}
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
try {
// Build and push first
await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
await clients.docker.login(registryHost, 'token', apiToken);
await clients.docker.push(imageName);
// Remove local image
await clients.docker.rmi(imageName, true);
// Pull from registry
const pullResult = await clients.docker.pull(imageName);
assertEquals(pullResult.success, true, `docker pull failed: ${pullResult.stderr}`);
} finally {
await clients.docker.rmi(imageName, true);
}
});
it('should handle multi-layer images', async function () {
if (shouldSkip) {
console.log('Skipping: docker not available');
return;
}
const imageName = `${registryHost}/v2/${testOrgName}/multi:1.0.0`;
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.multi-layer');
try {
// Build multi-stage image
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
// Login and push
await clients.docker.login(registryHost, 'token', apiToken);
const pushResult = await clients.docker.push(imageName);
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
} finally {
await clients.docker.rmi(imageName, true);
}
});
});
describe('OCI E2E: Tags and versions', () => {
let shouldSkip = false;
beforeAll(async () => {
shouldSkip = await skipIfMissing('docker');
});
it('should handle multiple tags for same image', async function () {
if (shouldSkip) {
console.log('Skipping: docker not available');
return;
}
// Verify tag handling logic
const tags = ['1.0.0', '1.0', '1', 'latest'];
for (const tag of tags) {
assertEquals(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag), true);
}
});
it('should handle SHA256 digests', async function () {
if (shouldSkip) {
console.log('Skipping: docker not available');
return;
}
// Verify digest format
const digest = 'sha256:' + 'a'.repeat(64);
assertEquals(digest.startsWith('sha256:'), true);
assertEquals(digest.length, 71);
});
});

View File

@@ -0,0 +1,15 @@
[package]
name = "demo-crate"
version = "1.0.0"
edition = "2021"
authors = ["Stack.Gallery Test <test@stack.gallery>"]
description = "Demo crate for Stack.Gallery Registry e2e tests"
license = "MIT"
repository = "https://github.com/stack-gallery/demo-crate"
readme = "README.md"
keywords = ["demo", "test", "stack-gallery"]
categories = ["development-tools"]
[lib]
name = "demo_crate"
path = "src/lib.rs"

View File

@@ -0,0 +1,13 @@
# demo-crate
Demo crate for Stack.Gallery Registry e2e tests.
## Usage
```rust
use demo_crate::greet;
fn main() {
println!("{}", greet("World")); // Hello, World!
}
```

View File

@@ -0,0 +1,16 @@
//! Demo crate for Stack.Gallery Registry e2e tests
/// Greets the given name
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("World"), "Hello, World!");
}
}

View File

@@ -0,0 +1,13 @@
# stacktest/demo-package
Demo package for Stack.Gallery Registry e2e tests.
## Usage
```php
<?php
use StackTest\DemoPackage\Demo;
echo Demo::greet("World"); // Hello, World!
```

View File

@@ -0,0 +1,21 @@
{
"name": "stacktest/demo-package",
"description": "Demo package for Stack.Gallery Registry e2e tests",
"version": "1.0.0",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Stack.Gallery Test",
"email": "test@stack.gallery"
}
],
"require": {
"php": ">=8.0"
},
"autoload": {
"psr-4": {
"StackTest\\DemoPackage\\": "src/"
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace StackTest\DemoPackage;
/**
* Demo class for Stack.Gallery Registry e2e tests.
*/
class Demo
{
/**
* Greet the given name.
*
* @param string $name The name to greet
* @return string A greeting message
*/
public static function greet(string $name): string
{
return "Hello, {$name}!";
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.stacktest</groupId>
<artifactId>demo-artifact</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Stack.Gallery Demo Artifact</name>
<description>Demo Maven artifact for e2e tests</description>
<url>https://github.com/stack-gallery/demo-artifact</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<developers>
<developer>
<name>Stack.Gallery Test</name>
<email>test@stack.gallery</email>
</developer>
</developers>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@@ -0,0 +1,19 @@
package com.stacktest;
/**
* Demo class for Stack.Gallery Registry e2e tests.
*/
public class Demo {
/**
* Greet the given name.
* @param name The name to greet
* @return A greeting message
*/
public static String greet(String name) {
return "Hello, " + name + "!";
}
public static void main(String[] args) {
System.out.println(greet("World"));
}
}

View File

@@ -0,0 +1,10 @@
# @stack-test/demo-package
Demo package for Stack.Gallery Registry e2e tests.
## Usage
```javascript
const demo = require('@stack-test/demo-package');
console.log(demo.greet('World')); // Hello, World!
```

View File

@@ -0,0 +1,9 @@
/**
* Demo package for Stack.Gallery Registry e2e tests
*/
module.exports = {
name: 'demo-package',
greet: (name) => `Hello, ${name}!`,
version: () => require('./package.json').version
};

View File

@@ -0,0 +1,13 @@
{
"name": "@stack-test/demo-package",
"version": "1.0.0",
"description": "Demo package for Stack.Gallery Registry e2e tests",
"main": "index.js",
"author": "Stack.Gallery Test <test@stack.gallery>",
"license": "MIT",
"keywords": ["demo", "test", "stack-gallery"],
"repository": {
"type": "git",
"url": "https://github.com/stack-gallery/demo-package"
}
}

View File

@@ -0,0 +1,9 @@
FROM alpine:3.19 AS builder
RUN echo "Building..." > /build.log
FROM alpine:3.19
LABEL org.opencontainers.image.title="stack-test-demo-multi"
LABEL org.opencontainers.image.version="1.0.0"
COPY --from=builder /build.log /build.log
RUN echo "Stack.Gallery Multi-Layer Demo" > /README.txt
CMD ["cat", "/README.txt"]

6
test/fixtures/oci/Dockerfile.simple vendored Normal file
View File

@@ -0,0 +1,6 @@
FROM alpine:3.19
LABEL org.opencontainers.image.title="stack-test-demo"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.description="Demo image for Stack.Gallery Registry e2e tests"
RUN echo "Stack.Gallery Demo Image" > /README.txt
CMD ["cat", "/README.txt"]

View File

@@ -0,0 +1,11 @@
# stack-test-demo-package
Demo package for Stack.Gallery Registry e2e tests.
## Usage
```python
from demo_package import greet
print(greet("World")) # Hello, World!
```

View File

@@ -0,0 +1,8 @@
"""Demo package for Stack.Gallery Registry e2e tests."""
__version__ = "1.0.0"
def greet(name: str) -> str:
"""Greet the given name."""
return f"Hello, {name}!"

View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "stack-test-demo-package"
version = "1.0.0"
description = "Demo package for Stack.Gallery Registry e2e tests"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "Stack.Gallery Test", email = "test@stack.gallery"}
]
keywords = ["demo", "test", "stack-gallery"]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
]
[tool.setuptools.packages.find]
where = ["."]

View File

@@ -0,0 +1,11 @@
# stack-test-demo-gem
Demo gem for Stack.Gallery Registry e2e tests.
## Usage
```ruby
require 'demo-gem'
puts StackTestDemoGem.greet("World") # Hello, World!
```

View File

@@ -0,0 +1,16 @@
Gem::Specification.new do |spec|
spec.name = "stack-test-demo-gem"
spec.version = "1.0.0"
spec.authors = ["Stack.Gallery Test"]
spec.email = ["test@stack.gallery"]
spec.summary = "Demo gem for Stack.Gallery Registry e2e tests"
spec.description = "A demonstration gem for testing Stack.Gallery Registry"
spec.homepage = "https://github.com/stack-gallery/demo-gem"
spec.license = "MIT"
spec.required_ruby_version = ">= 2.7.0"
spec.files = Dir["lib/**/*", "README.md"]
spec.require_paths = ["lib"]
end

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
# Demo gem for Stack.Gallery Registry e2e tests
module StackTestDemoGem
VERSION = "1.0.0"
# Greet the given name
# @param name [String] The name to greet
# @return [String] A greeting message
def self.greet(name)
"Hello, #{name}!"
end
end

141
test/helpers/auth.helper.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Authentication test helper - creates test users, tokens, and sessions
*/
import { User } from '../../ts/models/user.ts';
import { ApiToken } from '../../ts/models/apitoken.ts';
import { AuthService } from '../../ts/services/auth.service.ts';
import { TokenService } from '../../ts/services/token.service.ts';
import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts';
import { testConfig } from '../test.config.ts';
const TEST_PASSWORD = 'TestPassword123!';
export interface ICreateTestUserOptions {
email?: string;
username?: string;
password?: string;
displayName?: string;
status?: TUserStatus;
isPlatformAdmin?: boolean;
emailVerified?: boolean;
}
/**
* Create a test user with sensible defaults
*/
export async function createTestUser(
overrides: ICreateTestUserOptions = {}
): Promise<{ user: User; password: string }> {
const uniqueId = crypto.randomUUID().slice(0, 8);
const password = overrides.password || TEST_PASSWORD;
const passwordHash = await User.hashPassword(password);
const user = await User.createUser({
email: overrides.email || `test-${uniqueId}@example.com`,
username: overrides.username || `testuser-${uniqueId}`,
passwordHash,
displayName: overrides.displayName || `Test User ${uniqueId}`,
});
// Set additional properties
user.status = overrides.status || 'active';
user.emailVerified = overrides.emailVerified ?? true;
if (overrides.isPlatformAdmin) {
user.isPlatformAdmin = true;
}
await user.save();
return { user, password };
}
/**
* Create admin user
*/
export async function createAdminUser(): Promise<{ user: User; password: string }> {
return createTestUser({ isPlatformAdmin: true });
}
/**
* Login and get tokens
*/
export async function loginUser(
email: string,
password: string
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
const authService = new AuthService({
jwtSecret: testConfig.jwt.secret,
});
const result = await authService.login(email, password, {
userAgent: 'TestAgent/1.0',
ipAddress: '127.0.0.1',
});
if (!result.success) {
throw new Error(`Login failed: ${result.errorMessage}`);
}
return {
accessToken: result.accessToken!,
refreshToken: result.refreshToken!,
sessionId: result.sessionId!,
};
}
export interface ICreateTestApiTokenOptions {
userId: string;
name?: string;
protocols?: TRegistryProtocol[];
scopes?: ITokenScope[];
organizationId?: string;
expiresInDays?: number;
}
/**
* Create test API token
*/
export async function createTestApiToken(
options: ICreateTestApiTokenOptions
): Promise<{ rawToken: string; token: ApiToken }> {
const tokenService = new TokenService();
return tokenService.createToken({
userId: options.userId,
organizationId: options.organizationId,
name: options.name || `test-token-${crypto.randomUUID().slice(0, 8)}`,
protocols: options.protocols || ['npm', 'oci'],
scopes: options.scopes || [
{
protocol: '*',
actions: ['read', 'write', 'delete'],
},
],
expiresInDays: options.expiresInDays,
});
}
/**
* Create auth header for API requests
*/
export function createAuthHeader(token: string): { Authorization: string } {
return { Authorization: `Bearer ${token}` };
}
/**
* Create basic auth header (for registry protocols)
*/
export function createBasicAuthHeader(
username: string,
password: string
): { Authorization: string } {
const credentials = btoa(`${username}:${password}`);
return { Authorization: `Basic ${credentials}` };
}
/**
* Get the default test password
*/
export function getTestPassword(): string {
return TEST_PASSWORD;
}

106
test/helpers/db.helper.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Database test helper - manages test database lifecycle
*
* NOTE: The smartdata models use a global `db` singleton. This helper
* ensures proper initialization and cleanup for tests.
*/
import * as plugins from '../../ts/plugins.ts';
import { testConfig } from '../test.config.ts';
// Test database instance - separate from production
let testDb: plugins.smartdata.SmartdataDb | null = null;
let testDbName: string = '';
let isConnected = false;
// We need to patch the global db export since models reference it
// This is done by re-initializing with the test config
import { initDb, closeDb } from '../../ts/models/db.ts';
/**
* Initialize test database with unique name per test run
*/
export async function setupTestDb(config?: {
mongoUrl?: string;
dbName?: string;
}): Promise<void> {
// If already connected, reuse the connection
if (isConnected && testDb) {
return;
}
const mongoUrl = config?.mongoUrl || testConfig.mongodb.url;
// Generate unique database name for this test session
const uniqueId = crypto.randomUUID().slice(0, 8);
testDbName = config?.dbName || `${testConfig.mongodb.name}-${uniqueId}`;
// Initialize the global db singleton with test configuration
testDb = await initDb(mongoUrl, testDbName);
isConnected = true;
}
/**
* Clean up test database - deletes all documents from collections
* This is safer than dropping collections which causes index rebuild issues
*/
export async function cleanupTestDb(): Promise<void> {
if (!testDb || !isConnected) return;
try {
const collections = await testDb.mongoDb.listCollections().toArray();
for (const col of collections) {
// Delete all documents but preserve indexes
await testDb.mongoDb.collection(col.name).deleteMany({});
}
} catch (error) {
console.warn('[TestHelper] Error cleaning database:', error);
}
}
/**
* Teardown test database - drops database and closes connection
*/
export async function teardownTestDb(): Promise<void> {
if (!testDb || !isConnected) return;
try {
// Drop the test database
await testDb.mongoDb.dropDatabase();
// Close the connection
await closeDb();
testDb = null;
isConnected = false;
} catch (error) {
console.warn('[TestHelper] Error tearing down database:', error);
}
}
/**
* Clear specific collection(s) - deletes all documents
*/
export async function clearCollections(...collectionNames: string[]): Promise<void> {
if (!testDb || !isConnected) return;
for (const name of collectionNames) {
try {
await testDb.mongoDb.collection(name).deleteMany({});
} catch {
// Collection may not exist, ignore
}
}
}
/**
* Get the current test database name
*/
export function getTestDbName(): string {
return testDbName;
}
/**
* Get the database instance for direct access
*/
export function getTestDb(): plugins.smartdata.SmartdataDb | null {
return testDb;
}

View File

@@ -0,0 +1,268 @@
/**
* Factory helper - creates test entities with sensible defaults
*/
import { Organization } from '../../ts/models/organization.ts';
import { OrganizationMember } from '../../ts/models/organization.member.ts';
import { Repository } from '../../ts/models/repository.ts';
import { Team } from '../../ts/models/team.ts';
import { TeamMember } from '../../ts/models/team.member.ts';
import { Package } from '../../ts/models/package.ts';
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
import type {
TOrganizationRole,
TTeamRole,
TRepositoryRole,
TRepositoryVisibility,
TRegistryProtocol,
} from '../../ts/interfaces/auth.interfaces.ts';
export interface ICreateTestOrganizationOptions {
createdById: string;
name?: string;
displayName?: string;
description?: string;
isPublic?: boolean;
}
/**
* Create test organization
*/
export async function createTestOrganization(
options: ICreateTestOrganizationOptions
): Promise<Organization> {
const uniqueId = crypto.randomUUID().slice(0, 8);
const org = await Organization.createOrganization({
name: options.name || `test-org-${uniqueId}`,
displayName: options.displayName || `Test Org ${uniqueId}`,
description: options.description || 'Test organization',
createdById: options.createdById,
});
if (options.isPublic !== undefined) {
org.isPublic = options.isPublic;
await org.save();
}
return org;
}
/**
* Create organization with owner membership
*/
export async function createOrgWithOwner(
ownerId: string,
orgOptions?: Partial<ICreateTestOrganizationOptions>
): Promise<{
organization: Organization;
membership: OrganizationMember;
}> {
const organization = await createTestOrganization({
createdById: ownerId,
...orgOptions,
});
const membership = await OrganizationMember.addMember({
organizationId: organization.id,
userId: ownerId,
role: 'owner',
});
organization.memberCount = 1;
await organization.save();
return { organization, membership };
}
/**
* Add member to organization
*/
export async function addOrgMember(
organizationId: string,
userId: string,
role: TOrganizationRole = 'member',
invitedBy?: string
): Promise<OrganizationMember> {
const membership = await OrganizationMember.addMember({
organizationId,
userId,
role,
invitedBy,
});
const org = await Organization.findById(organizationId);
if (org) {
org.memberCount += 1;
await org.save();
}
return membership;
}
export interface ICreateTestRepositoryOptions {
organizationId: string;
createdById: string;
name?: string;
protocol?: TRegistryProtocol;
visibility?: TRepositoryVisibility;
description?: string;
}
/**
* Create test repository
*/
export async function createTestRepository(
options: ICreateTestRepositoryOptions
): Promise<Repository> {
const uniqueId = crypto.randomUUID().slice(0, 8);
return Repository.createRepository({
organizationId: options.organizationId,
name: options.name || `test-repo-${uniqueId}`,
protocol: options.protocol || 'npm',
visibility: options.visibility || 'private',
description: options.description || 'Test repository',
createdById: options.createdById,
});
}
export interface ICreateTestTeamOptions {
organizationId: string;
name?: string;
description?: string;
}
/**
* Create test team
*/
export async function createTestTeam(options: ICreateTestTeamOptions): Promise<Team> {
const uniqueId = crypto.randomUUID().slice(0, 8);
return Team.createTeam({
organizationId: options.organizationId,
name: options.name || `test-team-${uniqueId}`,
description: options.description || 'Test team',
});
}
/**
* Add member to team
*/
export async function addTeamMember(
teamId: string,
userId: string,
role: TTeamRole = 'member'
): Promise<TeamMember> {
const member = new TeamMember();
member.id = await TeamMember.getNewId();
member.teamId = teamId;
member.userId = userId;
member.role = role;
member.createdAt = new Date();
await member.save();
return member;
}
export interface IGrantRepoPermissionOptions {
repositoryId: string;
userId?: string;
teamId?: string;
role: TRepositoryRole;
grantedById: string;
}
/**
* Grant repository permission
*/
export async function grantRepoPermission(
options: IGrantRepoPermissionOptions
): Promise<RepositoryPermission> {
const perm = new RepositoryPermission();
perm.id = await RepositoryPermission.getNewId();
perm.repositoryId = options.repositoryId;
perm.userId = options.userId;
perm.teamId = options.teamId;
perm.role = options.role;
perm.grantedById = options.grantedById;
perm.createdAt = new Date();
await perm.save();
return perm;
}
export interface ICreateTestPackageOptions {
organizationId: string;
repositoryId: string;
createdById: string;
name?: string;
protocol?: TRegistryProtocol;
versions?: string[];
isPrivate?: boolean;
}
/**
* Create test package
*/
export async function createTestPackage(options: ICreateTestPackageOptions): Promise<Package> {
const uniqueId = crypto.randomUUID().slice(0, 8);
const protocol = options.protocol || 'npm';
const name = options.name || `test-package-${uniqueId}`;
const pkg = new Package();
pkg.id = Package.generateId(protocol, options.organizationId, name);
pkg.organizationId = options.organizationId;
pkg.repositoryId = options.repositoryId;
pkg.protocol = protocol;
pkg.name = name;
pkg.isPrivate = options.isPrivate ?? true;
pkg.createdById = options.createdById;
pkg.createdAt = new Date();
pkg.updatedAt = new Date();
const versions = options.versions || ['1.0.0'];
for (const version of versions) {
pkg.addVersion({
version,
publishedAt: new Date(),
publishedById: options.createdById,
size: 1024,
digest: `sha256:${crypto.randomUUID().replace(/-/g, '')}`,
downloads: 0,
metadata: {},
});
}
pkg.distTags['latest'] = versions[versions.length - 1];
await pkg.save();
return pkg;
}
/**
* Create complete test scenario with org, repo, team, and package
*/
export async function createFullTestScenario(ownerId: string): Promise<{
organization: Organization;
repository: Repository;
team: Team;
package: Package;
}> {
const { organization } = await createOrgWithOwner(ownerId);
const repository = await createTestRepository({
organizationId: organization.id,
createdById: ownerId,
protocol: 'npm',
});
const team = await createTestTeam({
organizationId: organization.id,
});
const pkg = await createTestPackage({
organizationId: organization.id,
repositoryId: repository.id,
createdById: ownerId,
});
return { organization, repository, team, package: pkg };
}

116
test/helpers/http.helper.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* HTTP test helper - utilities for testing API endpoints
*/
import { testConfig } from '../test.config.ts';
export interface ITestRequest {
method: string;
path: string;
body?: unknown;
headers?: Record<string, string>;
query?: Record<string, string>;
}
export interface ITestResponse {
status: number;
body: unknown;
headers: Headers;
}
/**
* Make a test request to the registry API
*/
export async function testRequest(options: ITestRequest): Promise<ITestResponse> {
const baseUrl = testConfig.registry.url;
let url = `${baseUrl}${options.path}`;
if (options.query) {
const params = new URLSearchParams(options.query);
url += `?${params.toString()}`;
}
const response = await fetch(url, {
method: options.method,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text();
}
return {
status: response.status,
body,
headers: response.headers,
};
}
// Convenience methods
export const get = (path: string, headers?: Record<string, string>) =>
testRequest({ method: 'GET', path, headers });
export const post = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'POST', path, body, headers });
export const put = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'PUT', path, body, headers });
export const patch = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'PATCH', path, body, headers });
export const del = (path: string, headers?: Record<string, string>) =>
testRequest({ method: 'DELETE', path, headers });
/**
* Assert response status
*/
export function assertStatus(response: ITestResponse, expected: number): void {
if (response.status !== expected) {
throw new Error(
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`
);
}
}
/**
* Assert response body has specific keys
*/
export function assertBodyHas(response: ITestResponse, keys: string[]): void {
const body = response.body as Record<string, unknown>;
for (const key of keys) {
if (!(key in body)) {
throw new Error(`Expected response to have key "${key}", body: ${JSON.stringify(body)}`);
}
}
}
/**
* Assert response is successful (2xx)
*/
export function assertSuccess(response: ITestResponse): void {
if (response.status < 200 || response.status >= 300) {
throw new Error(
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`
);
}
}
/**
* Assert response is an error (4xx or 5xx)
*/
export function assertError(response: ITestResponse, expectedStatus?: number): void {
if (response.status < 400) {
throw new Error(`Expected error response but got ${response.status}`);
}
if (expectedStatus !== undefined && response.status !== expectedStatus) {
throw new Error(`Expected status ${expectedStatus} but got ${response.status}`);
}
}

85
test/helpers/index.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Test helpers index - re-exports all helper modules
*/
// Database helpers
export {
setupTestDb,
cleanupTestDb,
teardownTestDb,
clearCollections,
getTestDbName,
getTestDb,
} from './db.helper.ts';
// Auth helpers
export {
createTestUser,
createAdminUser,
loginUser,
createTestApiToken,
createAuthHeader,
createBasicAuthHeader,
getTestPassword,
type ICreateTestUserOptions,
type ICreateTestApiTokenOptions,
} from './auth.helper.ts';
// Factory helpers
export {
createTestOrganization,
createOrgWithOwner,
addOrgMember,
createTestRepository,
createTestTeam,
addTeamMember,
grantRepoPermission,
createTestPackage,
createFullTestScenario,
type ICreateTestOrganizationOptions,
type ICreateTestRepositoryOptions,
type ICreateTestTeamOptions,
type IGrantRepoPermissionOptions,
type ICreateTestPackageOptions,
} from './factory.helper.ts';
// HTTP helpers
export {
testRequest,
get,
post,
put,
patch,
del,
assertStatus,
assertBodyHas,
assertSuccess,
assertError,
type ITestRequest,
type ITestResponse,
} from './http.helper.ts';
// Subprocess helpers
export {
runCommand,
commandExists,
clients,
skipIfMissing,
type ICommandResult,
type ICommandOptions,
} from './subprocess.helper.ts';
// Storage helpers
export {
setupTestStorage,
checkStorageAvailable,
objectExists,
listObjects,
deleteObject,
deletePrefix,
cleanupTestStorage,
isStorageAvailable,
} from './storage.helper.ts';
// Re-export test config
export { testConfig, getTestConfig } from '../test.config.ts';

View File

@@ -0,0 +1,104 @@
/**
* Storage helper - S3/MinIO verification utilities for tests
*
* NOTE: These are stub implementations for testing.
* The actual smartbucket API should be verified against the real library.
*/
import { testConfig } from '../test.config.ts';
// Storage is optional for unit/integration tests
// E2E tests with actual S3 operations would need proper implementation
let storageAvailable = false;
/**
* Check if test storage is available
*/
export async function checkStorageAvailable(): Promise<boolean> {
try {
// Try to connect to MinIO
const response = await fetch(`${testConfig.s3.endpoint}/minio/health/live`, {
method: 'GET',
});
storageAvailable = response.ok;
return storageAvailable;
} catch {
storageAvailable = false;
return false;
}
}
/**
* Initialize test storage connection
*/
export async function setupTestStorage(): Promise<void> {
await checkStorageAvailable();
if (storageAvailable) {
console.log('[Test Storage] MinIO available at', testConfig.s3.endpoint);
} else {
console.log('[Test Storage] MinIO not available, storage tests will be skipped');
}
}
/**
* Check if an object exists in storage (stub)
*/
export async function objectExists(_key: string): Promise<boolean> {
if (!storageAvailable) return false;
// Would implement actual check here
return false;
}
/**
* List objects with a given prefix (stub)
*/
export async function listObjects(_prefix: string): Promise<string[]> {
if (!storageAvailable) return [];
// Would implement actual list here
return [];
}
/**
* Delete an object from storage (stub)
*/
export async function deleteObject(_key: string): Promise<void> {
if (!storageAvailable) return;
// Would implement actual delete here
}
/**
* Delete all objects with a given prefix
*/
export async function deletePrefix(prefix: string): Promise<void> {
const objects = await listObjects(prefix);
for (const key of objects) {
await deleteObject(key);
}
}
/**
* Clean up test storage
*/
export async function cleanupTestStorage(): Promise<void> {
if (!storageAvailable) return;
try {
// Delete all test objects
await deletePrefix('npm/');
await deletePrefix('oci/');
await deletePrefix('maven/');
await deletePrefix('cargo/');
await deletePrefix('pypi/');
await deletePrefix('composer/');
await deletePrefix('rubygems/');
} catch {
// Ignore errors
}
}
/**
* Check if storage is available
*/
export function isStorageAvailable(): boolean {
return storageAvailable;
}

View File

@@ -0,0 +1,208 @@
/**
* Subprocess helper - utilities for running protocol clients in tests
*/
export interface ICommandResult {
success: boolean;
stdout: string;
stderr: string;
code: number;
signal?: Deno.Signal;
}
export interface ICommandOptions {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
stdin?: string;
}
/**
* Execute a command and return the result
*/
export async function runCommand(
cmd: string[],
options: ICommandOptions = {}
): Promise<ICommandResult> {
const { cwd, env, timeout = 60000, stdin } = options;
const command = new Deno.Command(cmd[0], {
args: cmd.slice(1),
cwd,
env: { ...Deno.env.toObject(), ...env },
stdin: stdin ? 'piped' : 'null',
stdout: 'piped',
stderr: 'piped',
});
const child = command.spawn();
if (stdin && child.stdin) {
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(stdin));
await writer.close();
}
const timeoutId = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch {
/* ignore */
}
}, timeout);
const output = await child.output();
clearTimeout(timeoutId);
return {
success: output.success,
stdout: new TextDecoder().decode(output.stdout),
stderr: new TextDecoder().decode(output.stderr),
code: output.code,
signal: output.signal ?? undefined,
};
}
/**
* Check if a command is available
*/
export async function commandExists(cmd: string): Promise<boolean> {
try {
const result = await runCommand(['which', cmd], { timeout: 5000 });
return result.success;
} catch {
return false;
}
}
/**
* Protocol client wrappers
*/
export const clients = {
npm: {
check: () => commandExists('npm'),
publish: (dir: string, registry: string, token: string) =>
runCommand(['npm', 'publish', '--registry', registry], {
cwd: dir,
env: { NPM_TOKEN: token, npm_config__authToken: token },
}),
install: (pkg: string, registry: string, dir: string) =>
runCommand(['npm', 'install', pkg, '--registry', registry], { cwd: dir }),
unpublish: (pkg: string, registry: string, token: string) =>
runCommand(['npm', 'unpublish', pkg, '--registry', registry, '--force'], {
env: { NPM_TOKEN: token, npm_config__authToken: token },
}),
pack: (dir: string) => runCommand(['npm', 'pack'], { cwd: dir }),
},
docker: {
check: () => commandExists('docker'),
build: (dockerfile: string, tag: string, context: string) =>
runCommand(['docker', 'build', '-f', dockerfile, '-t', tag, context]),
push: (image: string) => runCommand(['docker', 'push', image]),
pull: (image: string) => runCommand(['docker', 'pull', image]),
rmi: (image: string, force = false) =>
runCommand(['docker', 'rmi', ...(force ? ['-f'] : []), image]),
login: (registry: string, username: string, password: string) =>
runCommand(['docker', 'login', registry, '-u', username, '--password-stdin'], {
stdin: password,
}),
tag: (source: string, target: string) => runCommand(['docker', 'tag', source, target]),
},
cargo: {
check: () => commandExists('cargo'),
package: (dir: string) => runCommand(['cargo', 'package', '--allow-dirty'], { cwd: dir }),
publish: (dir: string, registry: string, token: string) =>
runCommand(
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
{ cwd: dir }
),
yank: (crate: string, version: string, token: string) =>
runCommand([
'cargo',
'yank',
crate,
'--version',
version,
'--registry',
'stack-test',
'--token',
token,
]),
},
pip: {
check: () => commandExists('pip'),
build: (dir: string) => runCommand(['python', '-m', 'build', dir]),
upload: (dist: string, repository: string, token: string) =>
runCommand([
'python',
'-m',
'twine',
'upload',
'--repository-url',
repository,
'-u',
'__token__',
'-p',
token,
`${dist}/*`,
]),
install: (pkg: string, indexUrl: string) =>
runCommand(['pip', 'install', pkg, '--index-url', indexUrl]),
},
composer: {
check: () => commandExists('composer'),
install: (pkg: string, repository: string, dir: string) =>
runCommand(
[
'composer',
'require',
pkg,
'--repository',
JSON.stringify({ type: 'composer', url: repository }),
],
{ cwd: dir }
),
},
gem: {
check: () => commandExists('gem'),
build: (gemspec: string, dir: string) => runCommand(['gem', 'build', gemspec], { cwd: dir }),
push: (gemFile: string, host: string, key: string) =>
runCommand(['gem', 'push', gemFile, '--host', host, '--key', key]),
install: (gemName: string, source: string) =>
runCommand(['gem', 'install', gemName, '--source', source]),
yank: (gemName: string, version: string, host: string, key: string) =>
runCommand(['gem', 'yank', gemName, '-v', version, '--host', host, '--key', key]),
},
maven: {
check: () => commandExists('mvn'),
deploy: (dir: string, repositoryUrl: string, username: string, password: string) =>
runCommand(
[
'mvn',
'deploy',
`-DaltDeploymentRepository=stack-test::default::${repositoryUrl}`,
`-Dusername=${username}`,
`-Dpassword=${password}`,
],
{ cwd: dir }
),
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
},
};
/**
* Skip test if command is not available
*/
export async function skipIfMissing(cmd: string): Promise<boolean> {
const exists = await commandExists(cmd);
if (!exists) {
console.warn(`[Skip] ${cmd} not available`);
}
return !exists;
}

View File

@@ -0,0 +1,169 @@
/**
* Authentication integration tests
* Tests the full authentication flow through the API
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
post,
get,
assertStatus,
createAuthHeader,
} from '../helpers/index.ts';
describe('Auth API Integration', () => {
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
});
describe('POST /api/v1/auth/login', () => {
it('should login with valid credentials', async () => {
const { user, password } = await createTestUser({
email: 'api-login@example.com',
status: 'active',
});
const response = await post('/api/v1/auth/login', {
email: user.email,
password: password,
});
assertStatus(response, 200);
const body = response.body as Record<string, unknown>;
assertExists(body.accessToken);
assertExists(body.refreshToken);
assertExists(body.user);
});
it('should return 401 for invalid credentials', async () => {
const response = await post('/api/v1/auth/login', {
email: 'nonexistent@example.com',
password: 'wrongpassword',
});
assertStatus(response, 401);
const body = response.body as Record<string, unknown>;
assertEquals(body.error, 'INVALID_CREDENTIALS');
});
it('should return 401 for inactive user', async () => {
const { user, password } = await createTestUser({
email: 'suspended@example.com',
status: 'suspended',
});
const response = await post('/api/v1/auth/login', {
email: user.email,
password: password,
});
assertStatus(response, 401);
const body = response.body as Record<string, unknown>;
assertEquals(body.error, 'ACCOUNT_INACTIVE');
});
});
describe('POST /api/v1/auth/refresh', () => {
it('should refresh access token', async () => {
const { user, password } = await createTestUser({
email: 'refresh@example.com',
status: 'active',
});
// Login first
const loginResponse = await post('/api/v1/auth/login', {
email: user.email,
password: password,
});
const loginBody = loginResponse.body as Record<string, unknown>;
// Refresh
const refreshResponse = await post('/api/v1/auth/refresh', {
refreshToken: loginBody.refreshToken,
});
assertStatus(refreshResponse, 200);
const refreshBody = refreshResponse.body as Record<string, unknown>;
assertExists(refreshBody.accessToken);
});
it('should return 401 for invalid refresh token', async () => {
const response = await post('/api/v1/auth/refresh', {
refreshToken: 'invalid-token',
});
assertStatus(response, 401);
});
});
describe('GET /api/v1/auth/me', () => {
it('should return current user info', async () => {
const { user, password } = await createTestUser({
email: 'me@example.com',
status: 'active',
});
// Login
const loginResponse = await post('/api/v1/auth/login', {
email: user.email,
password: password,
});
const loginBody = loginResponse.body as Record<string, unknown>;
// Get current user
const meResponse = await get(
'/api/v1/auth/me',
createAuthHeader(loginBody.accessToken as string)
);
assertStatus(meResponse, 200);
const meBody = meResponse.body as Record<string, unknown>;
assertEquals(meBody.email, user.email);
});
it('should return 401 without token', async () => {
const response = await get('/api/v1/auth/me');
assertStatus(response, 401);
});
});
describe('POST /api/v1/auth/logout', () => {
it('should invalidate session', async () => {
const { user, password } = await createTestUser({
email: 'logout@example.com',
status: 'active',
});
// Login
const loginResponse = await post('/api/v1/auth/login', {
email: user.email,
password: password,
});
const loginBody = loginResponse.body as Record<string, unknown>;
const token = loginBody.accessToken as string;
// Logout
const logoutResponse = await post('/api/v1/auth/logout', {}, createAuthHeader(token));
assertStatus(logoutResponse, 200);
// Token should no longer work
const meResponse = await get('/api/v1/auth/me', createAuthHeader(token));
assertStatus(meResponse, 401);
});
});
});

View File

@@ -0,0 +1,228 @@
/**
* Organization integration tests
* Tests organization CRUD and member management through the API
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
loginUser,
post,
get,
put,
del,
assertStatus,
createAuthHeader,
} from '../helpers/index.ts';
describe('Organization API Integration', () => {
let accessToken: string;
let testUserId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user, password } = await createTestUser({ status: 'active' });
testUserId = user.id;
const tokens = await loginUser(user.email, password);
accessToken = tokens.accessToken;
});
describe('POST /api/v1/organizations', () => {
it('should create organization', async () => {
const response = await post(
'/api/v1/organizations',
{
name: 'my-org',
displayName: 'My Organization',
description: 'A test organization',
},
createAuthHeader(accessToken)
);
assertStatus(response, 201);
const body = response.body as Record<string, unknown>;
assertEquals(body.name, 'my-org');
assertEquals(body.displayName, 'My Organization');
});
it('should create organization with dots in name', async () => {
const response = await post(
'/api/v1/organizations',
{
name: 'push.rocks',
displayName: 'Push Rocks',
},
createAuthHeader(accessToken)
);
assertStatus(response, 201);
const body = response.body as Record<string, unknown>;
assertEquals(body.name, 'push.rocks');
});
it('should reject duplicate org name', async () => {
await post(
'/api/v1/organizations',
{ name: 'duplicate', displayName: 'First' },
createAuthHeader(accessToken)
);
const response = await post(
'/api/v1/organizations',
{ name: 'duplicate', displayName: 'Second' },
createAuthHeader(accessToken)
);
assertStatus(response, 409);
});
it('should reject invalid org name', async () => {
const response = await post(
'/api/v1/organizations',
{ name: '.invalid', displayName: 'Invalid' },
createAuthHeader(accessToken)
);
assertStatus(response, 400);
});
});
describe('GET /api/v1/organizations', () => {
it('should list user organizations', async () => {
// Create some organizations
await post(
'/api/v1/organizations',
{ name: 'org1', displayName: 'Org 1' },
createAuthHeader(accessToken)
);
await post(
'/api/v1/organizations',
{ name: 'org2', displayName: 'Org 2' },
createAuthHeader(accessToken)
);
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
assertStatus(response, 200);
const body = response.body as Record<string, unknown>[];
assertEquals(body.length >= 2, true);
});
});
describe('GET /api/v1/organizations/:orgName', () => {
it('should get organization by name', async () => {
await post(
'/api/v1/organizations',
{ name: 'get-me', displayName: 'Get Me' },
createAuthHeader(accessToken)
);
const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken));
assertStatus(response, 200);
const body = response.body as Record<string, unknown>;
assertEquals(body.name, 'get-me');
});
it('should return 404 for non-existent org', async () => {
const response = await get(
'/api/v1/organizations/non-existent',
createAuthHeader(accessToken)
);
assertStatus(response, 404);
});
});
describe('PUT /api/v1/organizations/:orgName', () => {
it('should update organization', async () => {
await post(
'/api/v1/organizations',
{ name: 'update-me', displayName: 'Original' },
createAuthHeader(accessToken)
);
const response = await put(
'/api/v1/organizations/update-me',
{ displayName: 'Updated', description: 'New description' },
createAuthHeader(accessToken)
);
assertStatus(response, 200);
const body = response.body as Record<string, unknown>;
assertEquals(body.displayName, 'Updated');
assertEquals(body.description, 'New description');
});
});
describe('DELETE /api/v1/organizations/:orgName', () => {
it('should delete organization', async () => {
await post(
'/api/v1/organizations',
{ name: 'delete-me', displayName: 'Delete Me' },
createAuthHeader(accessToken)
);
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
assertStatus(response, 200);
// Verify deleted
const getResponse = await get(
'/api/v1/organizations/delete-me',
createAuthHeader(accessToken)
);
assertStatus(getResponse, 404);
});
});
describe('Organization Members', () => {
it('should list organization members', async () => {
await post(
'/api/v1/organizations',
{ name: 'members-org', displayName: 'Members Org' },
createAuthHeader(accessToken)
);
const response = await get(
'/api/v1/organizations/members-org/members',
createAuthHeader(accessToken)
);
assertStatus(response, 200);
const body = response.body as Record<string, unknown>[];
assertEquals(body.length >= 1, true); // At least the creator
});
it('should add member to organization', async () => {
// Create another user
const { user: newUser } = await createTestUser({ email: 'newmember@example.com' });
await post(
'/api/v1/organizations',
{ name: 'add-member-org', displayName: 'Add Member Org' },
createAuthHeader(accessToken)
);
const response = await post(
'/api/v1/organizations/add-member-org/members',
{ userId: newUser.id, role: 'member' },
createAuthHeader(accessToken)
);
assertStatus(response, 201);
});
});
});

62
test/test.config.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* 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: mongoUrl,
name: mongoName,
},
s3: {
endpoint: s3EndpointUrl,
accessKey: s3AccessKey,
secretKey: s3SecretKey,
bucket: s3Bucket,
region: 'us-east-1',
},
jwt: {
secret: 'test-jwt-secret-for-testing-only',
refreshSecret: 'test-refresh-secret-for-testing-only',
},
registry: {
url: 'http://localhost:3000',
port: 3000,
},
testUser: {
email: 'test@stack.gallery',
password: 'TestPassword123!',
username: 'testuser',
},
adminUser: {
email: 'admin@stack.gallery',
password: 'admin',
username: 'admin',
},
};
/**
* Get test config (kept for backward compatibility)
*/
export function getTestConfig() {
return testConfig;
}

View File

@@ -0,0 +1,232 @@
/**
* ApiToken model unit tests
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { ApiToken } from '../../../ts/models/apitoken.ts';
describe('ApiToken Model', () => {
let testUserId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
});
async function createToken(overrides: Partial<ApiToken> = {}): Promise<ApiToken> {
const token = new ApiToken();
token.id = await ApiToken.getNewId();
token.userId = overrides.userId || testUserId;
token.name = overrides.name || 'test-token';
token.tokenHash = overrides.tokenHash || `hash-${crypto.randomUUID()}`;
token.tokenPrefix = overrides.tokenPrefix || 'srg_test';
token.protocols = overrides.protocols || ['npm', 'oci'];
token.scopes = overrides.scopes || [{ protocol: '*', actions: ['read', 'write'] }];
token.createdAt = new Date();
if (overrides.expiresAt) token.expiresAt = overrides.expiresAt;
if (overrides.isRevoked) token.isRevoked = overrides.isRevoked;
if (overrides.organizationId) token.organizationId = overrides.organizationId;
await token.save();
return token;
}
describe('findByHash', () => {
it('should find token by hash', async () => {
const created = await createToken({ tokenHash: 'unique-hash-123' });
const found = await ApiToken.findByHash('unique-hash-123');
assertExists(found);
assertEquals(found.id, created.id);
});
it('should not find revoked tokens', async () => {
await createToken({
tokenHash: 'revoked-hash',
isRevoked: true,
});
const found = await ApiToken.findByHash('revoked-hash');
assertEquals(found, null);
});
});
describe('getUserTokens', () => {
it('should return all user tokens', async () => {
await createToken({ name: 'token1' });
await createToken({ name: 'token2' });
const tokens = await ApiToken.getUserTokens(testUserId);
assertEquals(tokens.length, 2);
});
it('should not return revoked tokens', async () => {
await createToken({ name: 'active' });
await createToken({ name: 'revoked', isRevoked: true });
const tokens = await ApiToken.getUserTokens(testUserId);
assertEquals(tokens.length, 1);
assertEquals(tokens[0].name, 'active');
});
});
describe('getOrgTokens', () => {
it('should return organization tokens', async () => {
const orgId = 'org-123';
await createToken({ name: 'org-token', organizationId: orgId });
await createToken({ name: 'personal-token' }); // No org
const tokens = await ApiToken.getOrgTokens(orgId);
assertEquals(tokens.length, 1);
assertEquals(tokens[0].name, 'org-token');
});
});
describe('isValid', () => {
it('should return true for valid token', async () => {
const token = await createToken();
assertEquals(token.isValid(), true);
});
it('should return false for revoked token', async () => {
const token = await createToken({ isRevoked: true });
assertEquals(token.isValid(), false);
});
it('should return false for expired token', async () => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 1);
const token = await createToken({ expiresAt: pastDate });
assertEquals(token.isValid(), false);
});
it('should return true for non-expired token', async () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 30);
const token = await createToken({ expiresAt: futureDate });
assertEquals(token.isValid(), true);
});
});
describe('recordUsage', () => {
it('should update usage stats', async () => {
const token = await createToken();
await token.recordUsage('192.168.1.1');
assertExists(token.lastUsedAt);
assertEquals(token.lastUsedIp, '192.168.1.1');
assertEquals(token.usageCount, 1);
});
it('should increment usage count', async () => {
const token = await createToken();
await token.recordUsage();
await token.recordUsage();
await token.recordUsage();
assertEquals(token.usageCount, 3);
});
});
describe('revoke', () => {
it('should revoke token with reason', async () => {
const token = await createToken();
await token.revoke('Security concern');
assertEquals(token.isRevoked, true);
assertExists(token.revokedAt);
assertEquals(token.revokedReason, 'Security concern');
});
it('should revoke token without reason', async () => {
const token = await createToken();
await token.revoke();
assertEquals(token.isRevoked, true);
assertExists(token.revokedAt);
assertEquals(token.revokedReason, undefined);
});
});
describe('hasProtocol', () => {
it('should return true for allowed protocol', async () => {
const token = await createToken({ protocols: ['npm', 'oci'] });
assertEquals(token.hasProtocol('npm'), true);
assertEquals(token.hasProtocol('oci'), true);
});
it('should return false for disallowed protocol', async () => {
const token = await createToken({ protocols: ['npm'] });
assertEquals(token.hasProtocol('maven'), false);
});
});
describe('hasScope', () => {
it('should allow wildcard protocol scope', async () => {
const token = await createToken({
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
});
assertEquals(token.hasScope('npm'), true);
assertEquals(token.hasScope('oci'), true);
assertEquals(token.hasScope('maven'), true);
});
it('should restrict by specific protocol', async () => {
const token = await createToken({
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
assertEquals(token.hasScope('npm'), true);
assertEquals(token.hasScope('oci'), false);
});
it('should restrict by organization', async () => {
const token = await createToken({
scopes: [{ protocol: '*', organizationId: 'org-123', actions: ['read'] }],
});
assertEquals(token.hasScope('npm', 'org-123'), true);
assertEquals(token.hasScope('npm', 'org-456'), false);
});
it('should check action permissions', async () => {
const token = await createToken({
scopes: [{ protocol: '*', actions: ['read'] }],
});
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), false);
});
it('should allow wildcard action', async () => {
const token = await createToken({
scopes: [{ protocol: '*', actions: ['*'] }],
});
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), true);
assertEquals(token.hasScope('npm', undefined, undefined, 'delete'), true);
});
});
});

View File

@@ -0,0 +1,220 @@
/**
* Organization model unit tests
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { Organization } from '../../../ts/models/organization.ts';
describe('Organization Model', () => {
let testUserId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
});
describe('createOrganization', () => {
it('should create an organization with valid data', async () => {
const org = await Organization.createOrganization({
name: 'test-org',
displayName: 'Test Organization',
description: 'A test organization',
createdById: testUserId,
});
assertExists(org.id);
assertEquals(org.name, 'test-org');
assertEquals(org.displayName, 'Test Organization');
assertEquals(org.description, 'A test organization');
assertEquals(org.createdById, testUserId);
assertEquals(org.isPublic, false);
assertEquals(org.memberCount, 0);
assertEquals(org.plan, 'free');
});
it('should allow dots in org name (domain-like)', async () => {
const org = await Organization.createOrganization({
name: 'push.rocks',
displayName: 'Push Rocks',
createdById: testUserId,
});
assertEquals(org.name, 'push.rocks');
});
it('should allow hyphens in org name', async () => {
const org = await Organization.createOrganization({
name: 'my-awesome-org',
displayName: 'My Awesome Org',
createdById: testUserId,
});
assertEquals(org.name, 'my-awesome-org');
});
it('should reject uppercase names (must be lowercase)', async () => {
await assertRejects(
async () => {
await Organization.createOrganization({
name: 'UPPERCASE',
displayName: 'Uppercase Org',
createdById: testUserId,
});
},
Error,
'lowercase alphanumeric'
);
});
it('should reject invalid names starting with dot', async () => {
await assertRejects(
async () => {
await Organization.createOrganization({
name: '.invalid',
displayName: 'Invalid',
createdById: testUserId,
});
},
Error,
'lowercase alphanumeric'
);
});
it('should reject invalid names ending with dot', async () => {
await assertRejects(
async () => {
await Organization.createOrganization({
name: 'invalid.',
displayName: 'Invalid',
createdById: testUserId,
});
},
Error,
'lowercase alphanumeric'
);
});
it('should reject names with special characters', async () => {
await assertRejects(
async () => {
await Organization.createOrganization({
name: 'invalid@org',
displayName: 'Invalid',
createdById: testUserId,
});
},
Error,
'lowercase alphanumeric'
);
});
it('should set default settings', async () => {
const org = await Organization.createOrganization({
name: 'defaults',
displayName: 'Defaults Test',
createdById: testUserId,
});
assertEquals(org.settings.requireMfa, false);
assertEquals(org.settings.allowPublicRepositories, true);
assertEquals(org.settings.defaultRepositoryVisibility, 'private');
assertEquals(org.settings.allowedProtocols.length, 7);
});
});
describe('findById', () => {
it('should find organization by ID', async () => {
const created = await Organization.createOrganization({
name: 'findable',
displayName: 'Findable Org',
createdById: testUserId,
});
const found = await Organization.findById(created.id);
assertExists(found);
assertEquals(found.id, created.id);
});
it('should return null for non-existent ID', async () => {
const found = await Organization.findById('non-existent-id');
assertEquals(found, null);
});
});
describe('findByName', () => {
it('should find organization by name (case-insensitive)', async () => {
await Organization.createOrganization({
name: 'byname',
displayName: 'By Name',
createdById: testUserId,
});
const found = await Organization.findByName('BYNAME');
assertExists(found);
assertEquals(found.name, 'byname');
});
});
describe('storage quota', () => {
it('should have default 5GB quota', async () => {
const org = await Organization.createOrganization({
name: 'quota-test',
displayName: 'Quota Test',
createdById: testUserId,
});
assertEquals(org.storageQuotaBytes, 5 * 1024 * 1024 * 1024);
assertEquals(org.usedStorageBytes, 0);
});
it('should check available storage', async () => {
const org = await Organization.createOrganization({
name: 'storage-check',
displayName: 'Storage Check',
createdById: testUserId,
});
assertEquals(org.hasStorageAvailable(1024), true);
assertEquals(org.hasStorageAvailable(6 * 1024 * 1024 * 1024), false);
});
it('should allow unlimited storage with -1 quota', async () => {
const org = await Organization.createOrganization({
name: 'unlimited',
displayName: 'Unlimited',
createdById: testUserId,
});
org.storageQuotaBytes = -1;
assertEquals(org.hasStorageAvailable(1000 * 1024 * 1024 * 1024), true);
});
it('should update storage usage', async () => {
const org = await Organization.createOrganization({
name: 'usage-test',
displayName: 'Usage Test',
createdById: testUserId,
});
await org.updateStorageUsage(1000);
assertEquals(org.usedStorageBytes, 1000);
await org.updateStorageUsage(500);
assertEquals(org.usedStorageBytes, 1500);
await org.updateStorageUsage(-2000);
assertEquals(org.usedStorageBytes, 0); // Should not go negative
});
});
});

View File

@@ -0,0 +1,239 @@
/**
* Package model unit tests
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
} from '../../helpers/index.ts';
import { Package } from '../../../ts/models/package.ts';
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
describe('Package Model', () => {
let testUserId: string;
let testOrgId: string;
let testRepoId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
const { organization } = await createOrgWithOwner(testUserId);
testOrgId = organization.id;
const repo = await createTestRepository({
organizationId: testOrgId,
createdById: testUserId,
protocol: 'npm',
});
testRepoId = repo.id;
});
function createVersion(version: string): IPackageVersion {
return {
version,
publishedAt: new Date(),
publishedById: testUserId,
size: 1024,
digest: `sha256:${crypto.randomUUID()}`,
downloads: 0,
metadata: {},
};
}
async function createPackage(name: string, versions: string[] = ['1.0.0']): Promise<Package> {
const pkg = new Package();
pkg.id = Package.generateId('npm', testOrgId, name);
pkg.organizationId = testOrgId;
pkg.repositoryId = testRepoId;
pkg.protocol = 'npm';
pkg.name = name;
pkg.createdById = testUserId;
pkg.createdAt = new Date();
pkg.updatedAt = new Date();
for (const v of versions) {
pkg.addVersion(createVersion(v));
}
pkg.distTags['latest'] = versions[versions.length - 1];
await pkg.save();
return pkg;
}
describe('generateId', () => {
it('should generate correct format', () => {
const id = Package.generateId('npm', 'my-org', 'my-package');
assertEquals(id, 'npm:my-org:my-package');
});
});
describe('findById', () => {
it('should find package by ID', async () => {
const created = await createPackage('findable');
const found = await Package.findById(created.id);
assertExists(found);
assertEquals(found.name, 'findable');
});
it('should return null for non-existent ID', async () => {
const found = await Package.findById('npm:fake:package');
assertEquals(found, null);
});
});
describe('findByName', () => {
it('should find package by protocol, org, and name', async () => {
await createPackage('by-name');
const found = await Package.findByName('npm', testOrgId, 'by-name');
assertExists(found);
assertEquals(found.name, 'by-name');
});
});
describe('getOrgPackages', () => {
it('should return all packages in organization', async () => {
await createPackage('pkg1');
await createPackage('pkg2');
await createPackage('pkg3');
const packages = await Package.getOrgPackages(testOrgId);
assertEquals(packages.length, 3);
});
});
describe('search', () => {
it('should find packages by name', async () => {
await createPackage('search-me');
await createPackage('find-this');
await createPackage('other');
const results = await Package.searchPackages('search');
assertEquals(results.length, 1);
assertEquals(results[0].name, 'search-me');
});
it('should find packages by description', async () => {
const pkg = await createPackage('described');
pkg.description = 'A unique description for testing';
await pkg.save();
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.searchPackages('npm', { protocol: 'oci' });
assertEquals(results.length, 0);
});
it('should apply pagination', async () => {
await createPackage('page1');
await createPackage('page2');
await createPackage('page3');
const firstPage = await Package.searchPackages('page', { limit: 2, offset: 0 });
assertEquals(firstPage.length, 2);
const secondPage = await Package.searchPackages('page', { limit: 2, offset: 2 });
assertEquals(secondPage.length, 1);
});
});
describe('versions', () => {
it('should add version and update storage', async () => {
const pkg = await createPackage('versioned', []);
pkg.addVersion(createVersion('1.0.0'));
assertEquals(Object.keys(pkg.versions).length, 1);
assertEquals(pkg.storageBytes, 1024);
});
it('should get specific version', async () => {
const pkg = await createPackage('multi-version', ['1.0.0', '1.1.0', '2.0.0']);
const v1 = pkg.getVersion('1.0.0');
assertExists(v1);
assertEquals(v1.version, '1.0.0');
const v2 = pkg.getVersion('2.0.0');
assertExists(v2);
assertEquals(v2.version, '2.0.0');
});
it('should return undefined for non-existent version', async () => {
const pkg = await createPackage('single', ['1.0.0']);
const missing = pkg.getVersion('9.9.9');
assertEquals(missing, undefined);
});
});
describe('getLatestVersion', () => {
it('should return version from distTags.latest', async () => {
const pkg = await createPackage('tagged', ['1.0.0', '2.0.0']);
pkg.distTags['latest'] = '1.0.0'; // Set older version as latest
await pkg.save();
const latest = pkg.getLatestVersion();
assertExists(latest);
assertEquals(latest.version, '1.0.0');
});
it('should fallback to last version if no latest tag', async () => {
const pkg = await createPackage('untagged', ['1.0.0', '2.0.0']);
delete pkg.distTags['latest'];
const latest = pkg.getLatestVersion();
assertExists(latest);
assertEquals(latest.version, '2.0.0');
});
it('should return undefined for empty versions', async () => {
const pkg = await createPackage('empty', []);
delete pkg.distTags['latest'];
const latest = pkg.getLatestVersion();
assertEquals(latest, undefined);
});
});
describe('incrementDownloads', () => {
it('should increment total download count', async () => {
const pkg = await createPackage('downloads');
await pkg.incrementDownloads();
assertEquals(pkg.downloadCount, 1);
await pkg.incrementDownloads();
await pkg.incrementDownloads();
assertEquals(pkg.downloadCount, 3);
});
it('should increment version-specific downloads', async () => {
const pkg = await createPackage('version-downloads', ['1.0.0', '2.0.0']);
await pkg.incrementDownloads('1.0.0');
assertEquals(pkg.versions['1.0.0'].downloads, 1);
assertEquals(pkg.versions['2.0.0'].downloads, 0);
});
});
});

View File

@@ -0,0 +1,285 @@
/**
* Repository model unit tests
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
} from '../../helpers/index.ts';
import { Repository } from '../../../ts/models/repository.ts';
describe('Repository Model', () => {
let testUserId: string;
let testOrgId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
const { organization } = await createOrgWithOwner(testUserId);
testOrgId = organization.id;
});
describe('createRepository', () => {
it('should create a repository with valid data', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'test-repo',
description: 'A test repository',
protocol: 'npm',
createdById: testUserId,
});
assertExists(repo.id);
assertEquals(repo.name, 'test-repo');
assertEquals(repo.organizationId, testOrgId);
assertEquals(repo.protocol, 'npm');
assertEquals(repo.visibility, 'private');
assertEquals(repo.downloadCount, 0);
assertEquals(repo.starCount, 0);
});
it('should allow dots and underscores in name', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'my.test_repo',
protocol: 'npm',
createdById: testUserId,
});
assertEquals(repo.name, 'my.test_repo');
});
it('should lowercase the name', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'UPPERCASE',
protocol: 'npm',
createdById: testUserId,
});
assertEquals(repo.name, 'uppercase');
});
it('should set correct storage namespace', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'packages',
protocol: 'npm',
createdById: testUserId,
});
assertEquals(repo.storageNamespace, `npm/${testOrgId}/packages`);
});
it('should reject duplicate name+protocol in same org', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'unique',
protocol: 'npm',
createdById: testUserId,
});
await assertRejects(
async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'unique',
protocol: 'npm',
createdById: testUserId,
});
},
Error,
'already exists'
);
});
it('should allow same name with different protocol', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'packages',
protocol: 'npm',
createdById: testUserId,
});
const ociRepo = await Repository.createRepository({
organizationId: testOrgId,
name: 'packages',
protocol: 'oci',
createdById: testUserId,
});
assertEquals(ociRepo.name, 'packages');
assertEquals(ociRepo.protocol, 'oci');
});
it('should reject invalid names', async () => {
await assertRejects(
async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: '-invalid',
protocol: 'npm',
createdById: testUserId,
});
},
Error,
'lowercase alphanumeric'
);
});
it('should set visibility when provided', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'public-repo',
protocol: 'npm',
visibility: 'public',
createdById: testUserId,
});
assertEquals(repo.visibility, 'public');
});
});
describe('findByName', () => {
it('should find repository by org, name, and protocol', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'findable',
protocol: 'npm',
createdById: testUserId,
});
const found = await Repository.findByName(testOrgId, 'FINDABLE', 'npm');
assertExists(found);
assertEquals(found.name, 'findable');
});
it('should return null for wrong protocol', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'npm-only',
protocol: 'npm',
createdById: testUserId,
});
const found = await Repository.findByName(testOrgId, 'npm-only', 'oci');
assertEquals(found, null);
});
});
describe('getOrgRepositories', () => {
it('should return all org repositories', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'repo1',
protocol: 'npm',
createdById: testUserId,
});
await Repository.createRepository({
organizationId: testOrgId,
name: 'repo2',
protocol: 'oci',
createdById: testUserId,
});
await Repository.createRepository({
organizationId: testOrgId,
name: 'repo3',
protocol: 'maven',
createdById: testUserId,
});
const repos = await Repository.getOrgRepositories(testOrgId);
assertEquals(repos.length, 3);
});
});
describe('getPublicRepositories', () => {
it('should return only public repositories', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'public1',
protocol: 'npm',
visibility: 'public',
createdById: testUserId,
});
await Repository.createRepository({
organizationId: testOrgId,
name: 'private1',
protocol: 'npm',
visibility: 'private',
createdById: testUserId,
});
const repos = await Repository.getPublicRepositories();
assertEquals(repos.length, 1);
assertEquals(repos[0].name, 'public1');
});
it('should filter by protocol when provided', async () => {
await Repository.createRepository({
organizationId: testOrgId,
name: 'npm-public',
protocol: 'npm',
visibility: 'public',
createdById: testUserId,
});
await Repository.createRepository({
organizationId: testOrgId,
name: 'oci-public',
protocol: 'oci',
visibility: 'public',
createdById: testUserId,
});
const repos = await Repository.getPublicRepositories('npm');
assertEquals(repos.length, 1);
assertEquals(repos[0].protocol, 'npm');
});
});
describe('incrementDownloads', () => {
it('should increment download count', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'downloads',
protocol: 'npm',
createdById: testUserId,
});
await repo.incrementDownloads();
assertEquals(repo.downloadCount, 1);
await repo.incrementDownloads();
await repo.incrementDownloads();
assertEquals(repo.downloadCount, 3);
});
});
describe('getFullPath', () => {
it('should return org/repo path', async () => {
const repo = await Repository.createRepository({
organizationId: testOrgId,
name: 'my-package',
protocol: 'npm',
createdById: testUserId,
});
const path = repo.getFullPath('my-org');
assertEquals(path, 'my-org/my-package');
});
});
});

View File

@@ -0,0 +1,142 @@
/**
* Session model unit tests
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { Session } from '../../../ts/models/session.ts';
describe('Session Model', () => {
let testUserId: string;
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
});
describe('createSession', () => {
it('should create a session with valid data', async () => {
const session = await Session.createSession({
userId: testUserId,
userAgent: 'Mozilla/5.0',
ipAddress: '192.168.1.1',
});
assertExists(session.id);
assertEquals(session.userId, testUserId);
assertEquals(session.userAgent, 'Mozilla/5.0');
assertEquals(session.ipAddress, '192.168.1.1');
assertEquals(session.isValid, true);
assertExists(session.createdAt);
assertExists(session.lastActivityAt);
});
});
describe('findValidSession', () => {
it('should find valid session by ID', async () => {
const created = await Session.createSession({
userId: testUserId,
userAgent: 'Test Agent',
ipAddress: '127.0.0.1',
});
const found = await Session.findValidSession(created.id);
assertExists(found);
assertEquals(found.id, created.id);
});
it('should not find invalidated session', async () => {
const session = await Session.createSession({
userId: testUserId,
userAgent: 'Test Agent',
ipAddress: '127.0.0.1',
});
await session.invalidate('Logged out');
const found = await Session.findValidSession(session.id);
assertEquals(found, null);
});
});
describe('getUserSessions', () => {
it('should return all valid sessions for user', async () => {
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
const sessions = await Session.getUserSessions(testUserId);
assertEquals(sessions.length, 3);
});
it('should not return invalidated sessions', async () => {
await Session.createSession({ userId: testUserId, userAgent: 'Valid', ipAddress: '1.1.1.1' });
const invalid = await Session.createSession({
userId: testUserId,
userAgent: 'Invalid',
ipAddress: '2.2.2.2',
});
await invalid.invalidate('test');
const sessions = await Session.getUserSessions(testUserId);
assertEquals(sessions.length, 1);
});
});
describe('invalidate', () => {
it('should invalidate session with reason', async () => {
const session = await Session.createSession({
userId: testUserId,
userAgent: 'Test',
ipAddress: '127.0.0.1',
});
await session.invalidate('User logged out');
assertEquals(session.isValid, false);
assertExists(session.invalidatedAt);
assertEquals(session.invalidatedReason, 'User logged out');
});
});
describe('invalidateAllUserSessions', () => {
it('should invalidate all user sessions', async () => {
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
assertEquals(count, 3);
const remaining = await Session.getUserSessions(testUserId);
assertEquals(remaining.length, 0);
});
});
describe('touchActivity', () => {
it('should update lastActivityAt', async () => {
const session = await Session.createSession({
userId: testUserId,
userAgent: 'Test',
ipAddress: '127.0.0.1',
});
const originalActivity = session.lastActivityAt;
// Wait a bit to ensure time difference
await new Promise((r) => setTimeout(r, 10));
await session.touchActivity();
assertEquals(session.lastActivityAt > originalActivity, true);
});
});
});

View File

@@ -0,0 +1,228 @@
/**
* User model unit tests
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
import { User } from '../../../ts/models/user.ts';
describe('User Model', () => {
beforeAll(async () => {
await setupTestDb();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
});
describe('createUser', () => {
it('should create a user with valid data', async () => {
const passwordHash = await User.hashPassword('testpassword');
const user = await User.createUser({
email: 'test@example.com',
username: 'testuser',
passwordHash,
displayName: 'Test User',
});
assertExists(user.id);
assertEquals(user.email, 'test@example.com');
assertEquals(user.username, 'testuser');
assertEquals(user.displayName, 'Test User');
assertEquals(user.status, 'pending_verification');
assertEquals(user.emailVerified, false);
assertEquals(user.isPlatformAdmin, false);
});
it('should lowercase email and username', async () => {
const passwordHash = await User.hashPassword('testpassword');
const user = await User.createUser({
email: 'TEST@EXAMPLE.COM',
username: 'TestUser',
passwordHash,
});
assertEquals(user.email, 'test@example.com');
assertEquals(user.username, 'testuser');
});
it('should use username as displayName if not provided', async () => {
const passwordHash = await User.hashPassword('testpassword');
const user = await User.createUser({
email: 'test2@example.com',
username: 'testuser2',
passwordHash,
});
assertEquals(user.displayName, 'testuser2');
});
});
describe('findByEmail', () => {
it('should find user by email (case-insensitive)', async () => {
const passwordHash = await User.hashPassword('testpassword');
await User.createUser({
email: 'findme@example.com',
username: 'findme',
passwordHash,
});
const found = await User.findByEmail('FINDME@example.com');
assertExists(found);
assertEquals(found.email, 'findme@example.com');
});
it('should return null for non-existent email', async () => {
const found = await User.findByEmail('nonexistent@example.com');
assertEquals(found, null);
});
});
describe('findByUsername', () => {
it('should find user by username (case-insensitive)', async () => {
const passwordHash = await User.hashPassword('testpassword');
await User.createUser({
email: 'user@example.com',
username: 'findbyname',
passwordHash,
});
const found = await User.findByUsername('FINDBYNAME');
assertExists(found);
assertEquals(found.username, 'findbyname');
});
});
describe('findById', () => {
it('should find user by ID', async () => {
const passwordHash = await User.hashPassword('testpassword');
const created = await User.createUser({
email: 'byid@example.com',
username: 'byid',
passwordHash,
});
const found = await User.findById(created.id);
assertExists(found);
assertEquals(found.id, created.id);
});
});
describe('password hashing', () => {
it('should hash password with salt', async () => {
const hash = await User.hashPassword('mypassword');
assertExists(hash);
assertEquals(hash.includes(':'), true);
const [salt, _hashPart] = hash.split(':');
assertEquals(salt.length, 32); // 16 bytes = 32 hex chars
});
it('should produce different hashes for same password', async () => {
const hash1 = await User.hashPassword('samepassword');
const hash2 = await User.hashPassword('samepassword');
// Different salts should produce different hashes
assertEquals(hash1 !== hash2, true);
});
});
describe('verifyPassword', () => {
it('should verify correct password', async () => {
const passwordHash = await User.hashPassword('correctpassword');
const user = await User.createUser({
email: 'verify@example.com',
username: 'verifyuser',
passwordHash,
});
const isValid = await user.verifyPassword('correctpassword');
assertEquals(isValid, true);
});
it('should reject incorrect password', async () => {
const passwordHash = await User.hashPassword('correctpassword');
const user = await User.createUser({
email: 'reject@example.com',
username: 'rejectuser',
passwordHash,
});
const isValid = await user.verifyPassword('wrongpassword');
assertEquals(isValid, false);
});
it('should reject empty password', async () => {
const passwordHash = await User.hashPassword('correctpassword');
const user = await User.createUser({
email: 'empty@example.com',
username: 'emptyuser',
passwordHash,
});
const isValid = await user.verifyPassword('');
assertEquals(isValid, false);
});
});
describe('isActive', () => {
it('should return true for active status', async () => {
const passwordHash = await User.hashPassword('test');
const user = await User.createUser({
email: 'active@example.com',
username: 'activeuser',
passwordHash,
});
user.status = 'active';
await user.save();
assertEquals(user.isActive, true);
});
it('should return false for suspended status', async () => {
const passwordHash = await User.hashPassword('test');
const user = await User.createUser({
email: 'suspended@example.com',
username: 'suspendeduser',
passwordHash,
});
user.status = 'suspended';
assertEquals(user.isActive, false);
});
});
describe('isPlatformAdmin', () => {
it('should default to false', async () => {
const passwordHash = await User.hashPassword('test');
const user = await User.createUser({
email: 'notadmin@example.com',
username: 'notadmin',
passwordHash,
});
assertEquals(user.isPlatformAdmin, false);
assertEquals(user.isSystemAdmin, false);
});
it('should be settable to true', async () => {
const passwordHash = await User.hashPassword('test');
const user = await User.createUser({
email: 'admin@example.com',
username: 'adminuser',
passwordHash,
});
user.isPlatformAdmin = true;
await user.save();
const found = await User.findById(user.id);
assertEquals(found!.isPlatformAdmin, true);
assertEquals(found!.isSystemAdmin, true);
});
});
});

View File

@@ -0,0 +1,224 @@
/**
* AuthService unit tests
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { AuthService } from '../../../ts/services/auth.service.ts';
import { Session } from '../../../ts/models/session.ts';
import { testConfig } from '../../test.config.ts';
describe('AuthService', () => {
let authService: AuthService;
beforeAll(async () => {
await setupTestDb();
authService = new AuthService({
jwtSecret: testConfig.jwt.secret,
accessTokenExpiresIn: 60, // 1 minute for tests
refreshTokenExpiresIn: 300, // 5 minutes for tests
});
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
});
describe('login', () => {
it('should successfully login with valid credentials', async () => {
const { user, password } = await createTestUser({
email: 'login@example.com',
status: 'active',
});
const result = await authService.login(user.email, password, {
userAgent: 'TestAgent/1.0',
ipAddress: '127.0.0.1',
});
assertEquals(result.success, true);
assertExists(result.user);
assertEquals(result.user.id, user.id);
assertExists(result.accessToken);
assertExists(result.refreshToken);
assertExists(result.sessionId);
});
it('should fail with invalid email', async () => {
const result = await authService.login('nonexistent@example.com', 'password');
assertEquals(result.success, false);
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
});
it('should fail with invalid password', async () => {
const { user } = await createTestUser({ email: 'wrongpass@example.com' });
const result = await authService.login(user.email, 'wrongpassword');
assertEquals(result.success, false);
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
});
it('should fail for inactive user', async () => {
const { user, password } = await createTestUser({
email: 'inactive@example.com',
status: 'suspended',
});
const result = await authService.login(user.email, password);
assertEquals(result.success, false);
assertEquals(result.errorCode, 'ACCOUNT_INACTIVE');
});
it('should create a session on successful login', async () => {
const { user, password } = await createTestUser({ email: 'session@example.com' });
const result = await authService.login(user.email, password);
assertEquals(result.success, true);
assertExists(result.sessionId);
const session = await Session.findValidSession(result.sessionId!);
assertExists(session);
assertEquals(session.userId, user.id);
});
});
describe('refresh', () => {
it('should refresh access token with valid refresh token', async () => {
const { user, password } = await createTestUser({ email: 'refresh@example.com' });
const loginResult = await authService.login(user.email, password);
assertEquals(loginResult.success, true);
const refreshResult = await authService.refresh(loginResult.refreshToken!);
assertEquals(refreshResult.success, true);
assertExists(refreshResult.accessToken);
assertEquals(refreshResult.sessionId, loginResult.sessionId);
});
it('should fail with invalid refresh token', async () => {
const result = await authService.refresh('invalid-token');
assertEquals(result.success, false);
assertEquals(result.errorCode, 'INVALID_TOKEN');
});
it('should fail when session is invalidated', async () => {
const { user, password } = await createTestUser({ email: 'invalidsession@example.com' });
const loginResult = await authService.login(user.email, password);
// Invalidate session
const session = await Session.findValidSession(loginResult.sessionId!);
await session!.invalidate('test');
const refreshResult = await authService.refresh(loginResult.refreshToken!);
assertEquals(refreshResult.success, false);
assertEquals(refreshResult.errorCode, 'SESSION_INVALID');
});
});
describe('validateAccessToken', () => {
it('should validate valid access token', async () => {
const { user, password } = await createTestUser({ email: 'validate@example.com' });
const loginResult = await authService.login(user.email, password);
const validation = await authService.validateAccessToken(loginResult.accessToken!);
assertExists(validation);
assertEquals(validation.user.id, user.id);
assertEquals(validation.sessionId, loginResult.sessionId);
});
it('should reject invalid access token', async () => {
const validation = await authService.validateAccessToken('invalid-token');
assertEquals(validation, null);
});
it('should reject when session is invalidated', async () => {
const { user, password } = await createTestUser({ email: 'invalidated@example.com' });
const loginResult = await authService.login(user.email, password);
// Invalidate session
const session = await Session.findValidSession(loginResult.sessionId!);
await session!.invalidate('test');
const validation = await authService.validateAccessToken(loginResult.accessToken!);
assertEquals(validation, null);
});
});
describe('logout', () => {
it('should invalidate session', async () => {
const { user, password } = await createTestUser({ email: 'logout@example.com' });
const loginResult = await authService.login(user.email, password);
const success = await authService.logout(loginResult.sessionId!);
assertEquals(success, true);
const session = await Session.findValidSession(loginResult.sessionId!);
assertEquals(session, null);
});
it('should return false for non-existent session', async () => {
const success = await authService.logout('non-existent-session-id');
assertEquals(success, false);
});
});
describe('logoutAll', () => {
it('should invalidate all user sessions', async () => {
const { user, password } = await createTestUser({ email: 'logoutall@example.com' });
// Create multiple sessions
await authService.login(user.email, password);
await authService.login(user.email, password);
await authService.login(user.email, password);
const count = await authService.logoutAll(user.id);
assertEquals(count, 3);
const sessions = await Session.getUserSessions(user.id);
assertEquals(sessions.length, 0);
});
});
describe('static password methods', () => {
it('should hash and verify password', async () => {
const password = 'MySecurePassword123!';
const hash = await AuthService.hashPassword(password);
const isValid = await AuthService.verifyPassword(password, hash);
assertEquals(isValid, true);
const isInvalid = await AuthService.verifyPassword('WrongPassword', hash);
assertEquals(isInvalid, false);
});
it('should generate different hashes for same password', async () => {
const password = 'SamePassword';
const hash1 = await AuthService.hashPassword(password);
const hash2 = await AuthService.hashPassword(password);
assertEquals(hash1 !== hash2, true);
// But both should verify
assertEquals(await AuthService.verifyPassword(password, hash1), true);
assertEquals(await AuthService.verifyPassword(password, hash2), true);
});
});
});

View File

@@ -0,0 +1,260 @@
/**
* TokenService unit tests
*/
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { TokenService } from '../../../ts/services/token.service.ts';
import { ApiToken } from '../../../ts/models/apitoken.ts';
describe('TokenService', () => {
let tokenService: TokenService;
let testUserId: string;
beforeAll(async () => {
await setupTestDb();
tokenService = new TokenService();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanupTestDb();
const { user } = await createTestUser();
testUserId = user.id;
});
describe('createToken', () => {
it('should create token with correct format', async () => {
const result = await tokenService.createToken({
userId: testUserId,
name: 'test-token',
protocols: ['npm', 'oci'],
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
});
assertExists(result.rawToken);
assertExists(result.token);
// Check token format: srg_{prefix}_{random}
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
assertEquals(result.token.name, 'test-token');
assertEquals(result.token.protocols.includes('npm'), true);
assertEquals(result.token.protocols.includes('oci'), true);
});
it('should store hashed token', async () => {
const result = await tokenService.createToken({
userId: testUserId,
name: 'hashed-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
// The stored token should be hashed
assertEquals(result.token.tokenHash !== result.rawToken, true);
assertEquals(result.token.tokenHash.length, 64); // SHA-256 hex
});
it('should set expiration when provided', async () => {
const result = await tokenService.createToken({
userId: testUserId,
name: 'expiring-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
expiresInDays: 30,
});
assertExists(result.token.expiresAt);
const expectedExpiry = new Date();
expectedExpiry.setDate(expectedExpiry.getDate() + 30);
// Should be within a few seconds of expected
const diff = Math.abs(result.token.expiresAt.getTime() - expectedExpiry.getTime());
assertEquals(diff < 5000, true);
});
it('should create org-owned token', async () => {
const orgId = 'test-org-123';
const result = await tokenService.createToken({
userId: testUserId,
organizationId: orgId,
name: 'org-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', organizationId: orgId, actions: ['read', 'write'] }],
});
assertEquals(result.token.organizationId, orgId);
});
});
describe('validateToken', () => {
it('should validate correct token', async () => {
const { rawToken } = await tokenService.createToken({
userId: testUserId,
name: 'valid-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertExists(validation);
assertEquals(validation.token!.userId, testUserId);
assertEquals(validation.token!.protocols.includes('npm'), true);
});
it('should reject invalid token format', async () => {
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
assertEquals(validation, null);
});
it('should reject non-existent token', async () => {
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
assertEquals(validation, null);
});
it('should reject revoked token', async () => {
const { rawToken, token } = await tokenService.createToken({
userId: testUserId,
name: 'revoked-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await token.revoke('Test revocation');
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
});
it('should reject expired token', async () => {
const { rawToken, token } = await tokenService.createToken({
userId: testUserId,
name: 'expired-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
expiresInDays: 1,
});
// Manually set expiry to past
token.expiresAt = new Date(Date.now() - 86400000);
await token.save();
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
});
it('should record usage on validation', async () => {
const { rawToken, token } = await tokenService.createToken({
userId: testUserId,
name: 'usage-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await tokenService.validateToken(rawToken, '192.168.1.100');
// Reload token from DB
const updated = await ApiToken.findByHash(token.tokenHash);
assertExists(updated);
assertExists(updated.lastUsedAt);
assertEquals(updated.lastUsedIp, '192.168.1.100');
assertEquals(updated.usageCount, 1);
});
});
describe('getUserTokens', () => {
it('should return all user tokens', async () => {
await tokenService.createToken({
userId: testUserId,
name: 'token1',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await tokenService.createToken({
userId: testUserId,
name: 'token2',
protocols: ['oci'],
scopes: [{ protocol: 'oci', actions: ['read'] }],
});
const tokens = await tokenService.getUserTokens(testUserId);
assertEquals(tokens.length, 2);
});
it('should not return revoked tokens', async () => {
const { token } = await tokenService.createToken({
userId: testUserId,
name: 'revoked',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await tokenService.createToken({
userId: testUserId,
name: 'active',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await token.revoke('test');
const tokens = await tokenService.getUserTokens(testUserId);
assertEquals(tokens.length, 1);
assertEquals(tokens[0].name, 'active');
});
});
describe('revokeToken', () => {
it('should revoke token with reason', async () => {
const { token } = await tokenService.createToken({
userId: testUserId,
name: 'to-revoke',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await tokenService.revokeToken(token.id, 'Security concern');
const updated = await ApiToken.findByPrefix(token.tokenPrefix);
assertExists(updated);
assertEquals(updated.isRevoked, true);
assertEquals(updated.revokedReason, 'Security concern');
});
});
describe('getOrgTokens', () => {
it('should return organization tokens', async () => {
const orgId = 'org-123';
await tokenService.createToken({
userId: testUserId,
organizationId: orgId,
name: 'org-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
await tokenService.createToken({
userId: testUserId,
name: 'personal-token',
protocols: ['npm'],
scopes: [{ protocol: 'npm', actions: ['read'] }],
});
const tokens = await tokenService.getOrgTokens(orgId);
assertEquals(tokens.length, 1);
assertEquals(tokens[0].organizationId, orgId);
});
});
});

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@stack.gallery/registry',
version: '1.1.0',
version: '1.4.2',
description: 'Enterprise-grade multi-protocol package registry'
}

View File

@@ -0,0 +1,454 @@
/**
* Admin Auth API handlers
* Platform admin endpoints for managing authentication providers and settings
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
import { cryptoService } from '../../services/crypto.service.ts';
import { externalAuthService } from '../../services/external.auth.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import type {
ICreateAuthProviderDto,
IUpdateAuthProviderDto,
} from '../../interfaces/auth.interfaces.ts';
export class AdminAuthApi {
/**
* Check if actor is platform admin
*/
private requirePlatformAdmin(ctx: IApiContext): IApiResponse | null {
if (!ctx.actor?.userId || !ctx.actor.user?.isPlatformAdmin) {
return {
status: 403,
body: { error: 'Platform admin access required' },
};
}
return null;
}
/**
* GET /api/v1/admin/auth/providers
* List all authentication providers
*/
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const providers = await AuthProvider.getAllProviders();
return {
status: 200,
body: {
providers: providers.map((p) => p.toAdminInfo()),
},
};
} catch (error) {
console.error('[AdminAuthApi] List providers error:', error);
return {
status: 500,
body: { error: 'Failed to list providers' },
};
}
}
/**
* POST /api/v1/admin/auth/providers
* Create a new authentication provider
*/
public async createProvider(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const body = (await ctx.request.json()) as ICreateAuthProviderDto;
// Validate required fields
if (!body.name || !body.displayName || !body.type) {
return {
status: 400,
body: { error: 'name, displayName, and type are required' },
};
}
// Check name uniqueness
const existing = await AuthProvider.findByName(body.name);
if (existing) {
return {
status: 409,
body: { error: 'Provider name already exists' },
};
}
// Validate type-specific config
if (body.type === 'oidc' && !body.oauthConfig) {
return {
status: 400,
body: { error: 'oauthConfig is required for OIDC provider' },
};
}
if (body.type === 'ldap' && !body.ldapConfig) {
return {
status: 400,
body: { error: 'ldapConfig is required for LDAP provider' },
};
}
let provider: AuthProvider;
if (body.type === 'oidc' && body.oauthConfig) {
// Encrypt client secret
const encryptedSecret = await cryptoService.encrypt(body.oauthConfig.clientSecretEncrypted);
provider = await AuthProvider.createOAuthProvider({
name: body.name,
displayName: body.displayName,
oauthConfig: {
...body.oauthConfig,
clientSecretEncrypted: encryptedSecret,
},
attributeMapping: body.attributeMapping,
provisioning: body.provisioning,
createdById: ctx.actor!.userId!,
});
} else if (body.type === 'ldap' && body.ldapConfig) {
// Encrypt bind password
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
provider = await AuthProvider.createLdapProvider({
name: body.name,
displayName: body.displayName,
ldapConfig: {
...body.ldapConfig,
bindPasswordEncrypted: encryptedPassword,
},
attributeMapping: body.attributeMapping,
provisioning: body.provisioning,
createdById: ctx.actor!.userId!,
});
} else {
return {
status: 400,
body: { error: 'Invalid provider type' },
};
}
// Audit log
await AuditService.withContext({
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
providerName: provider.name,
providerType: provider.type,
},
});
return {
status: 201,
body: provider.toAdminInfo(),
};
} catch (error) {
console.error('[AdminAuthApi] Create provider error:', error);
return {
status: 500,
body: { error: 'Failed to create provider' },
};
}
}
/**
* GET /api/v1/admin/auth/providers/:id
* Get a specific authentication provider
*/
public async getProvider(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const { id } = ctx.params;
const provider = await AuthProvider.findById(id);
if (!provider) {
return {
status: 404,
body: { error: 'Provider not found' },
};
}
return {
status: 200,
body: provider.toAdminInfo(),
};
} catch (error) {
console.error('[AdminAuthApi] Get provider error:', error);
return {
status: 500,
body: { error: 'Failed to get provider' },
};
}
}
/**
* PUT /api/v1/admin/auth/providers/:id
* Update an authentication provider
*/
public async updateProvider(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const { id } = ctx.params;
const provider = await AuthProvider.findById(id);
if (!provider) {
return {
status: 404,
body: { error: 'Provider not found' },
};
}
const body = (await ctx.request.json()) as IUpdateAuthProviderDto;
// Update basic fields
if (body.displayName !== undefined) provider.displayName = body.displayName;
if (body.status !== undefined) provider.status = body.status;
if (body.priority !== undefined) provider.priority = body.priority;
// Update OAuth config
if (body.oauthConfig && provider.oauthConfig) {
const newOAuthConfig = { ...provider.oauthConfig, ...body.oauthConfig };
// Encrypt new client secret if provided and not already encrypted
if (
body.oauthConfig.clientSecretEncrypted &&
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
) {
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
body.oauthConfig.clientSecretEncrypted
);
}
provider.oauthConfig = newOAuthConfig;
}
// Update LDAP config
if (body.ldapConfig && provider.ldapConfig) {
const newLdapConfig = { ...provider.ldapConfig, ...body.ldapConfig };
// Encrypt new bind password if provided and not already encrypted
if (
body.ldapConfig.bindPasswordEncrypted &&
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
) {
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
body.ldapConfig.bindPasswordEncrypted
);
}
provider.ldapConfig = newLdapConfig;
}
// Update attribute mapping
if (body.attributeMapping) {
provider.attributeMapping = { ...provider.attributeMapping, ...body.attributeMapping };
}
// Update provisioning settings
if (body.provisioning) {
provider.provisioning = { ...provider.provisioning, ...body.provisioning };
}
await provider.save();
// Audit log
await AuditService.withContext({
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
providerName: provider.name,
},
});
return {
status: 200,
body: provider.toAdminInfo(),
};
} catch (error) {
console.error('[AdminAuthApi] Update provider error:', error);
return {
status: 500,
body: { error: 'Failed to update provider' },
};
}
}
/**
* DELETE /api/v1/admin/auth/providers/:id
* Delete (or disable) an authentication provider
*/
public async deleteProvider(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const { id } = ctx.params;
const provider = await AuthProvider.findById(id);
if (!provider) {
return {
status: 404,
body: { error: 'Provider not found' },
};
}
// For now, just disable the provider instead of deleting
// This preserves audit history and linked identities
provider.status = 'disabled';
await provider.save();
// Audit log
await AuditService.withContext({
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
providerName: provider.name,
},
});
return {
status: 200,
body: { message: 'Provider disabled' },
};
} catch (error) {
console.error('[AdminAuthApi] Delete provider error:', error);
return {
status: 500,
body: { error: 'Failed to delete provider' },
};
}
}
/**
* POST /api/v1/admin/auth/providers/:id/test
* Test provider connection
*/
public async testProvider(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const { id } = ctx.params;
const result = await externalAuthService.testConnection(id);
// Audit log
await AuditService.withContext({
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
resourceId: id,
success: result.success,
metadata: {
result: result.success ? 'success' : 'failure',
latencyMs: result.latencyMs,
error: result.error,
},
});
return {
status: 200,
body: result,
};
} catch (error) {
console.error('[AdminAuthApi] Test provider error:', error);
return {
status: 500,
body: { error: 'Failed to test provider' },
};
}
}
/**
* GET /api/v1/admin/auth/settings
* Get platform settings
*/
public async getSettings(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const settings = await PlatformSettings.get();
return {
status: 200,
body: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt,
updatedById: settings.updatedById,
},
};
} catch (error) {
console.error('[AdminAuthApi] Get settings error:', error);
return {
status: 500,
body: { error: 'Failed to get settings' },
};
}
}
/**
* PUT /api/v1/admin/auth/settings
* Update platform settings
*/
public async updateSettings(ctx: IApiContext): Promise<IApiResponse> {
const authError = this.requirePlatformAdmin(ctx);
if (authError) return authError;
try {
const body = await ctx.request.json();
const settings = await PlatformSettings.get();
if (body.auth) {
await settings.updateAuthSettings(body.auth, ctx.actor!.userId);
}
// Audit log
await AuditService.withContext({
actorId: ctx.actor!.userId,
actorType: 'user',
actorIp: ctx.ip,
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
resourceId: 'platform-settings',
success: true,
});
return {
status: 200,
body: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt,
updatedById: settings.updatedById,
},
};
} catch (error) {
console.error('[AdminAuthApi] Update settings error:', error);
return {
status: 500,
body: { error: 'Failed to update settings' },
};
}
}
}

View File

@@ -0,0 +1,188 @@
/**
* OAuth API handlers
* Public endpoints for OAuth/OIDC and LDAP authentication flows
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
import { externalAuthService } from '../../services/external.auth.service.ts';
export class OAuthApi {
/**
* GET /api/v1/auth/providers
* List active authentication providers (public info only)
*/
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
try {
const settings = await PlatformSettings.get();
const providers = await AuthProvider.getActiveProviders();
return {
status: 200,
body: {
providers: providers.map((p) => p.toPublicInfo()),
localAuthEnabled: settings.auth.localAuthEnabled,
defaultProviderId: settings.auth.defaultProviderId,
},
};
} catch (error) {
console.error('[OAuthApi] List providers error:', error);
return {
status: 500,
body: { error: 'Failed to list providers' },
};
}
}
/**
* GET /api/v1/auth/oauth/:id/authorize
* Initiate OAuth flow - redirects to provider
*/
public async authorize(ctx: IApiContext): Promise<IApiResponse> {
try {
const { id } = ctx.params;
const returnUrl = ctx.url.searchParams.get('returnUrl') || undefined;
const { authUrl } = await externalAuthService.initiateOAuth(id, returnUrl);
// Return redirect response
return {
status: 302,
headers: { Location: authUrl },
};
} catch (error) {
console.error('[OAuthApi] Authorize error:', error);
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
return {
status: 302,
headers: {
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
},
};
}
}
/**
* GET /api/v1/auth/oauth/:id/callback
* Handle OAuth callback from provider
*/
public async callback(ctx: IApiContext): Promise<IApiResponse> {
try {
const code = ctx.url.searchParams.get('code');
const state = ctx.url.searchParams.get('state');
const error = ctx.url.searchParams.get('error');
const errorDescription = ctx.url.searchParams.get('error_description');
if (error) {
return {
status: 302,
headers: {
Location: `/login?error=${encodeURIComponent(errorDescription || error)}`,
},
};
}
if (!code || !state) {
return {
status: 302,
headers: {
Location: '/login?error=missing_parameters',
},
};
}
const result = await externalAuthService.handleOAuthCallback(
{ code, state },
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
);
if (!result.success) {
return {
status: 302,
headers: {
Location: `/login?error=${encodeURIComponent(result.errorCode || 'auth_failed')}`,
},
};
}
// Redirect to OAuth callback page with tokens
const params = new URLSearchParams({
accessToken: result.accessToken!,
refreshToken: result.refreshToken!,
sessionId: result.sessionId!,
});
return {
status: 302,
headers: {
Location: `/oauth-callback?${params.toString()}`,
},
};
} catch (error) {
console.error('[OAuthApi] Callback error:', error);
const errorMessage = error instanceof Error ? error.message : 'Callback failed';
return {
status: 302,
headers: {
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
},
};
}
}
/**
* POST /api/v1/auth/ldap/:id/login
* LDAP authentication with username/password
*/
public async ldapLogin(ctx: IApiContext): Promise<IApiResponse> {
try {
const { id } = ctx.params;
const body = await ctx.request.json();
const { username, password } = body;
if (!username || !password) {
return {
status: 400,
body: { error: 'Username and password are required' },
};
}
const result = await externalAuthService.authenticateLdap(id, username, password, {
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
if (!result.success) {
return {
status: 401,
body: {
error: result.errorMessage,
code: result.errorCode,
},
};
}
return {
status: 200,
body: {
user: {
id: result.user!.id,
email: result.user!.email,
username: result.user!.username,
displayName: result.user!.displayName,
isSystemAdmin: result.user!.isSystemAdmin,
},
accessToken: result.accessToken,
refreshToken: result.refreshToken,
sessionId: result.sessionId,
},
};
} catch (error) {
console.error('[OAuthApi] LDAP login error:', error);
return {
status: 500,
body: { error: 'LDAP login failed' },
};
}
}
}

View File

@@ -15,6 +15,15 @@ export class OrganizationApi {
this.permissionService = permissionService;
}
/**
* Helper to resolve organization by ID or name
*/
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
return idOrName.startsWith('Organization:')
? await Organization.findById(idOrName)
: await Organization.findByName(idOrName);
}
/**
* GET /api/v1/organizations
*/
@@ -30,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 {
@@ -56,19 +71,20 @@ export class OrganizationApi {
/**
* GET /api/v1/organizations/:id
* Supports lookup by ID (e.g., Organization:abc123) or by name (e.g., push.rocks)
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const org = await Organization.findById(id);
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check access - public orgs are visible to all authenticated users
if (!org.isPublic && ctx.actor?.userId) {
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
@@ -112,11 +128,11 @@ export class OrganizationApi {
return { status: 400, body: { error: 'Organization name is required' } };
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
// Validate name format (allows dots for domain-like names)
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 hyphens and dots' },
};
}
@@ -145,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();
@@ -176,6 +192,7 @@ export class OrganizationApi {
/**
* PUT /api/v1/organizations/:id
* Supports lookup by ID or name
*/
public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -184,18 +201,18 @@ export class OrganizationApi {
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const org = await Organization.findById(id);
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check admin permission using org.id
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
const body = await ctx.request.json();
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
@@ -232,6 +249,7 @@ export class OrganizationApi {
/**
* DELETE /api/v1/organizations/:id
* Supports lookup by ID or name
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -240,18 +258,18 @@ export class OrganizationApi {
const { id } = ctx.params;
// Only owners and system admins can delete
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Owner access required' } };
}
try {
const org = await Organization.findById(id);
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Only owners and system admins can delete
const membership = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Owner access required' } };
}
// TODO: Check for packages, repositories before deletion
// For now, just delete the organization and memberships
await org.delete();
@@ -268,6 +286,7 @@ export class OrganizationApi {
/**
* GET /api/v1/organizations/:id/members
* Supports lookup by ID or name
*/
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -276,14 +295,19 @@ export class OrganizationApi {
const { id } = ctx.params;
// Check membership
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
try {
const members = await OrganizationMember.getOrgMembers(id);
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check membership
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
const members = await OrganizationMember.getOrgMembers(org.id);
// Fetch user details
const membersWithUsers = await Promise.all(
@@ -292,7 +316,7 @@ export class OrganizationApi {
return {
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
addedAt: m.joinedAt,
user: user
? {
username: user.username,
@@ -316,6 +340,7 @@ export class OrganizationApi {
/**
* POST /api/v1/organizations/:id/members
* Supports lookup by ID or name
*/
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -324,13 +349,18 @@ export class OrganizationApi {
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
const body = await ctx.request.json();
const { userId, role } = body as { userId: string; role: TOrganizationRole };
@@ -349,7 +379,7 @@ export class OrganizationApi {
}
// Check if already a member
const existing = await OrganizationMember.findMembership(id, userId);
const existing = await OrganizationMember.findMembership(org.id, userId);
if (existing) {
return { status: 409, body: { error: 'User is already a member' } };
}
@@ -357,27 +387,24 @@ export class OrganizationApi {
// Add member
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = id;
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();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount += 1;
await org.save();
}
org.memberCount += 1;
await org.save();
return {
status: 201,
body: {
userId: membership.userId,
role: membership.role,
addedAt: membership.addedAt,
addedAt: membership.joinedAt,
},
};
} catch (error) {
@@ -388,6 +415,7 @@ export class OrganizationApi {
/**
* PUT /api/v1/organizations/:id/members/:userId
* Supports lookup by ID or name
*/
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -396,13 +424,18 @@ export class OrganizationApi {
const { id, userId } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
const body = await ctx.request.json();
const { role } = body as { role: TOrganizationRole };
@@ -410,14 +443,14 @@ export class OrganizationApi {
return { status: 400, body: { error: 'Valid role is required' } };
}
const membership = await OrganizationMember.findMembership(id, userId);
const membership = await OrganizationMember.findMembership(org.id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot change last owner
if (membership.role === 'owner' && role !== 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const owners = await OrganizationMember.getOrgMembers(org.id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
@@ -442,6 +475,7 @@ export class OrganizationApi {
/**
* DELETE /api/v1/organizations/:id/members/:userId
* Supports lookup by ID or name
*/
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -450,23 +484,28 @@ export class OrganizationApi {
const { id, userId } = ctx.params;
// Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
}
try {
const membership = await OrganizationMember.findMembership(id, userId);
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
}
const membership = await OrganizationMember.findMembership(org.id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot remove last owner
if (membership.role === 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const owners = await OrganizationMember.getOrgMembers(org.id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
@@ -476,11 +515,8 @@ export class OrganizationApi {
await membership.delete();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount = Math.max(0, org.memberCount - 1);
await org.save();
}
org.memberCount = Math.max(0, org.memberCount - 1);
await org.save();
return {
status: 200,

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -4,17 +4,22 @@
import type { IApiContext, IApiResponse } from '../router.ts';
import { TokenService } from '../../services/token.service.ts';
import { PermissionService } from '../../services/permission.service.ts';
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
export class TokenApi {
private tokenService: TokenService;
private permissionService: PermissionService;
constructor(tokenService: TokenService) {
constructor(tokenService: TokenService, permissionService?: PermissionService) {
this.tokenService = tokenService;
this.permissionService = permissionService || new PermissionService();
}
/**
* GET /api/v1/tokens
* Query params:
* - organizationId: list org tokens (requires org admin)
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -22,7 +27,20 @@ export class TokenApi {
}
try {
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
const url = new URL(ctx.request.url);
const organizationId = url.searchParams.get('organizationId');
let tokens;
if (organizationId) {
// Check if user can manage org
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
}
tokens = await this.tokenService.getOrgTokens(organizationId);
} else {
tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
}
return {
status: 200,
@@ -33,6 +51,8 @@ export class TokenApi {
tokenPrefix: t.tokenPrefix,
protocols: t.protocols,
scopes: t.scopes,
organizationId: t.organizationId,
createdById: t.createdById,
expiresAt: t.expiresAt,
lastUsedAt: t.lastUsedAt,
usageCount: t.usageCount,
@@ -48,6 +68,12 @@ export class TokenApi {
/**
* POST /api/v1/tokens
* Body:
* - name: token name
* - organizationId: (optional) create org token instead of personal
* - protocols: array of protocols
* - scopes: array of scope objects
* - expiresInDays: (optional) token expiry
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -56,8 +82,9 @@ export class TokenApi {
try {
const body = await ctx.request.json();
const { name, protocols, scopes, expiresInDays } = body as {
const { name, organizationId, protocols, scopes, expiresInDays } = body as {
name: string;
organizationId?: string;
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
expiresInDays?: number;
@@ -90,8 +117,18 @@ export class TokenApi {
}
}
// If creating org token, verify permission
if (organizationId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
}
}
const result = await this.tokenService.createToken({
userId: ctx.actor.userId,
organizationId,
createdById: ctx.actor.userId,
name,
protocols,
scopes,
@@ -108,6 +145,7 @@ export class TokenApi {
tokenPrefix: result.token.tokenPrefix,
protocols: result.token.protocols,
scopes: result.token.scopes,
organizationId: result.token.organizationId,
expiresAt: result.token.expiresAt,
createdAt: result.token.createdAt,
warning: 'Store this token securely. It will not be shown again.',
@@ -121,6 +159,7 @@ export class TokenApi {
/**
* DELETE /api/v1/tokens/:id
* Allows revoking personal tokens or org tokens (if org admin)
*/
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
@@ -130,12 +169,27 @@ export class TokenApi {
const { id } = ctx.params;
try {
// Get the token to verify ownership
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
const token = tokens.find((t) => t.id === id);
// First check if it's a personal token
const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId);
let token = userTokens.find((t) => t.id === id);
if (!token) {
// Check if it's an org token and user can manage org
const { ApiToken } = await import('../../models/index.ts');
const anyToken = await ApiToken.getInstance({ id, isRevoked: false });
if (anyToken?.organizationId) {
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
anyToken.organizationId
);
if (canManage) {
token = anyToken;
}
}
}
if (!token) {
// Either doesn't exist or doesn't belong to user
return { status: 404, body: { error: 'Token not found' } };
}

View File

@@ -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 {

View File

@@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts';
import { PackageApi } from './handlers/package.api.ts';
import { TokenApi } from './handlers/token.api.ts';
import { AuditApi } from './handlers/audit.api.ts';
import { AdminAuthApi } from './handlers/admin.auth.api.ts';
import { OAuthApi } from './handlers/oauth.api.ts';
export interface IApiContext {
request: Request;
@@ -57,6 +59,8 @@ export class ApiRouter {
private packageApi: PackageApi;
private tokenApi: TokenApi;
private auditApi: AuditApi;
private adminAuthApi: AdminAuthApi;
private oauthApi: OAuthApi;
constructor() {
this.authService = new AuthService();
@@ -71,6 +75,8 @@ export class ApiRouter {
this.packageApi = new PackageApi(this.permissionService);
this.tokenApi = new TokenApi(this.tokenService);
this.auditApi = new AuditApi(this.permissionService);
this.adminAuthApi = new AdminAuthApi();
this.oauthApi = new OAuthApi();
this.registerRoutes();
}
@@ -124,6 +130,22 @@ export class ApiRouter {
// Audit routes
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
// OAuth/External auth routes (public)
this.addRoute('GET', '/api/v1/auth/providers', (ctx) => this.oauthApi.listProviders(ctx));
this.addRoute('GET', '/api/v1/auth/oauth/:id/authorize', (ctx) => this.oauthApi.authorize(ctx));
this.addRoute('GET', '/api/v1/auth/oauth/:id/callback', (ctx) => this.oauthApi.callback(ctx));
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
// Admin auth routes (platform admin only)
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
}
/**

View File

@@ -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';
// =============================================================================

View File

@@ -48,6 +48,9 @@ export interface IOrganization {
displayName: string;
description?: string;
avatarUrl?: string;
website?: string;
isPublic: boolean;
memberCount: number;
plan: TOrganizationPlan;
settings: IOrganizationSettings;
billingEmail?: string;
@@ -143,6 +146,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*';
export interface IApiToken {
id: string;
userId: string;
organizationId?: string; // For org-owned tokens
createdById?: string; // Who created the token (for audit)
name: string;
tokenHash: string;
tokenPrefix: string;
@@ -276,7 +281,145 @@ export interface ICreateRepositoryDto {
export interface ICreateTokenDto {
name: string;
organizationId?: string; // For org-owned tokens
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
expiresAt?: Date;
}
// =============================================================================
// External Authentication Types
// =============================================================================
export type TAuthProviderType = 'oidc' | 'ldap';
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
export interface IOAuthConfig {
clientId: string;
clientSecretEncrypted: string; // AES-256-GCM encrypted
issuer: string; // OIDC issuer URL (used for discovery)
authorizationUrl?: string; // Override discovery
tokenUrl?: string; // Override discovery
userInfoUrl?: string; // Override discovery
scopes: string[];
callbackUrl: string;
}
export interface ILdapConfig {
serverUrl: string; // ldap:// or ldaps://
bindDn: string;
bindPasswordEncrypted: string; // AES-256-GCM encrypted
baseDn: string;
userSearchFilter: string; // e.g., "(uid={{username}})" or "(sAMAccountName={{username}})"
tlsEnabled: boolean;
tlsCaCert?: string;
}
export interface IAttributeMapping {
email: string;
username: string;
displayName: string;
avatarUrl?: string;
groups?: string;
}
export interface IProvisioningSettings {
jitEnabled: boolean; // Create user on first login
autoLinkByEmail: boolean; // Link to existing user by email match
allowedEmailDomains?: string[]; // Restrict to specific domains
}
export interface IAuthProvider {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
status: TAuthProviderStatus;
priority: number;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping: IAttributeMapping;
provisioning: IProvisioningSettings;
createdAt: Date;
updatedAt: Date;
createdById: string;
lastTestedAt?: Date;
lastTestResult?: 'success' | 'failure';
lastTestError?: string;
}
export interface IExternalIdentity {
id: string;
userId: string;
providerId: string;
externalId: string;
externalEmail?: string;
externalUsername?: string;
rawAttributes?: Record<string, unknown>;
lastLoginAt?: Date;
createdAt: Date;
}
export interface IPlatformAuthSettings {
localAuthEnabled: boolean;
allowUserRegistration: boolean;
sessionDurationMinutes: number;
defaultProviderId?: string;
}
export interface IPlatformSettings {
id: string;
auth: IPlatformAuthSettings;
updatedAt: Date;
updatedById?: string;
}
// External auth flow types
export interface IExternalUserInfo {
externalId: string;
email: string;
username?: string;
displayName?: string;
avatarUrl?: string;
groups?: string[];
rawAttributes: Record<string, unknown>;
}
export interface IConnectionTestResult {
success: boolean;
latencyMs: number;
serverInfo?: Record<string, unknown>;
error?: string;
}
export interface IExternalAuthResult {
success: boolean;
user?: IUser;
accessToken?: string;
refreshToken?: string;
sessionId?: string;
isNewUser?: boolean;
errorCode?: string;
errorMessage?: string;
}
// Admin DTOs
export interface ICreateAuthProviderDto {
name: string;
displayName: string;
type: TAuthProviderType;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping?: IAttributeMapping;
provisioning?: IProvisioningSettings;
}
export interface IUpdateAuthProviderDto {
displayName?: string;
status?: TAuthProviderStatus;
priority?: number;
oauthConfig?: Partial<IOAuthConfig>;
ldapConfig?: Partial<ILdapConfig>;
attributeMapping?: Partial<IAttributeMapping>;
provisioning?: Partial<IProvisioningSettings>;
}

View File

@@ -16,7 +16,14 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
public userId: string = '';
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.index()
public organizationId?: string; // For org-owned tokens
@plugins.smartdata.svDb()
public createdById?: string; // Who created the token (for audit)
@plugins.smartdata.svDb()
public override name: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index({ unique: true })
@@ -90,6 +97,16 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
});
}
/**
* Get all tokens for an organization
*/
public static async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
return await ApiToken.getInstances({
organizationId,
isRevoked: false,
});
}
/**
* Check if token is valid (not expired, not revoked)
*/

252
ts/models/auth.provider.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* Authentication Provider model for Stack.Gallery Registry
* Stores OAuth/OIDC and LDAP provider configurations
*/
import * as plugins from '../plugins.ts';
import type {
IAuthProvider,
TAuthProviderType,
TAuthProviderStatus,
IOAuthConfig,
ILdapConfig,
IAttributeMapping,
IProvisioningSettings,
} from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
const DEFAULT_ATTRIBUTE_MAPPING: IAttributeMapping = {
email: 'email',
username: 'preferred_username',
displayName: 'name',
};
const DEFAULT_PROVISIONING: IProvisioningSettings = {
jitEnabled: true,
autoLinkByEmail: true,
};
@plugins.smartdata.Collection(() => db)
export class AuthProvider
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
implements IAuthProvider
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index({ unique: true })
public override name: string = ''; // URL-safe slug identifier
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public displayName: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public type: TAuthProviderType = 'oidc';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public status: TAuthProviderStatus = 'disabled';
@plugins.smartdata.svDb()
public priority: number = 100; // Lower = shown first in UI
// Type-specific config (only one should be populated based on type)
@plugins.smartdata.svDb()
public oauthConfig?: IOAuthConfig;
@plugins.smartdata.svDb()
public ldapConfig?: ILdapConfig;
@plugins.smartdata.svDb()
public attributeMapping: IAttributeMapping = DEFAULT_ATTRIBUTE_MAPPING;
@plugins.smartdata.svDb()
public provisioning: IProvisioningSettings = DEFAULT_PROVISIONING;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
@plugins.smartdata.svDb()
public createdById: string = '';
// Connection test tracking
@plugins.smartdata.svDb()
public lastTestedAt?: Date;
@plugins.smartdata.svDb()
public lastTestResult?: 'success' | 'failure';
@plugins.smartdata.svDb()
public lastTestError?: string;
/**
* Find provider by ID
*/
public static async findById(id: string): Promise<AuthProvider | null> {
return await AuthProvider.getInstance({ id });
}
/**
* Find provider by name (slug)
*/
public static async findByName(name: string): Promise<AuthProvider | null> {
return await AuthProvider.getInstance({ name: name.toLowerCase() });
}
/**
* Get all active providers (for login page)
*/
public static async getActiveProviders(): Promise<AuthProvider[]> {
const providers = await AuthProvider.getInstances({ status: 'active' });
return providers.sort((a, b) => a.priority - b.priority);
}
/**
* Get all providers (for admin)
*/
public static async getAllProviders(): Promise<AuthProvider[]> {
const providers = await AuthProvider.getInstances({});
return providers.sort((a, b) => a.priority - b.priority);
}
/**
* Create a new OAuth/OIDC provider
*/
public static async createOAuthProvider(data: {
name: string;
displayName: string;
oauthConfig: IOAuthConfig;
attributeMapping?: IAttributeMapping;
provisioning?: IProvisioningSettings;
createdById: string;
}): Promise<AuthProvider> {
const provider = new AuthProvider();
provider.id = await AuthProvider.getNewId();
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
provider.displayName = data.displayName;
provider.type = 'oidc';
provider.status = 'disabled';
provider.oauthConfig = data.oauthConfig;
provider.attributeMapping = data.attributeMapping || DEFAULT_ATTRIBUTE_MAPPING;
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
provider.createdById = data.createdById;
provider.createdAt = new Date();
provider.updatedAt = new Date();
await provider.save();
return provider;
}
/**
* Create a new LDAP provider
*/
public static async createLdapProvider(data: {
name: string;
displayName: string;
ldapConfig: ILdapConfig;
attributeMapping?: IAttributeMapping;
provisioning?: IProvisioningSettings;
createdById: string;
}): Promise<AuthProvider> {
const provider = new AuthProvider();
provider.id = await AuthProvider.getNewId();
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
provider.displayName = data.displayName;
provider.type = 'ldap';
provider.status = 'disabled';
provider.ldapConfig = data.ldapConfig;
provider.attributeMapping = data.attributeMapping || {
email: 'mail',
username: 'uid',
displayName: 'displayName',
};
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
provider.createdById = data.createdById;
provider.createdAt = new Date();
provider.updatedAt = new Date();
await provider.save();
return provider;
}
/**
* Update connection test result
*/
public async updateTestResult(success: boolean, error?: string): Promise<void> {
this.lastTestedAt = new Date();
this.lastTestResult = success ? 'success' : 'failure';
this.lastTestError = error;
await this.save();
}
/**
* Lifecycle hook: Update timestamps before save
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await AuthProvider.getNewId();
}
}
/**
* Get public info (for login page - no secrets)
*/
public toPublicInfo(): {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
} {
return {
id: this.id,
name: this.name,
displayName: this.displayName,
type: this.type,
};
}
/**
* Get admin info (secrets masked)
*/
public toAdminInfo(): Record<string, unknown> {
const info: Record<string, unknown> = {
id: this.id,
name: this.name,
displayName: this.displayName,
type: this.type,
status: this.status,
priority: this.priority,
attributeMapping: this.attributeMapping,
provisioning: this.provisioning,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
createdById: this.createdById,
lastTestedAt: this.lastTestedAt,
lastTestResult: this.lastTestResult,
lastTestError: this.lastTestError,
};
// Mask secrets in config
if (this.oauthConfig) {
info.oauthConfig = {
...this.oauthConfig,
clientSecretEncrypted: this.oauthConfig.clientSecretEncrypted ? '********' : undefined,
};
}
if (this.ldapConfig) {
info.ldapConfig = {
...this.ldapConfig,
bindPasswordEncrypted: this.ldapConfig.bindPasswordEncrypted ? '********' : undefined,
};
}
return info;
}
}

View File

@@ -0,0 +1,142 @@
/**
* External Identity model for Stack.Gallery Registry
* Links users to external authentication provider accounts
*/
import * as plugins from '../plugins.ts';
import type { IExternalIdentity } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class ExternalIdentity
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
implements IExternalIdentity
{
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public userId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public providerId: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public externalId: string = ''; // ID from the external provider
@plugins.smartdata.svDb()
public externalEmail?: string;
@plugins.smartdata.svDb()
public externalUsername?: string;
@plugins.smartdata.svDb()
public rawAttributes?: Record<string, unknown>;
@plugins.smartdata.svDb()
public lastLoginAt?: Date;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
/**
* Find by ID
*/
public static async findById(id: string): Promise<ExternalIdentity | null> {
return await ExternalIdentity.getInstance({ id });
}
/**
* Find by provider and external ID (unique combination)
*/
public static async findByExternalId(
providerId: string,
externalId: string
): Promise<ExternalIdentity | null> {
return await ExternalIdentity.getInstance({ providerId, externalId });
}
/**
* Find all identities for a user
*/
public static async findByUserId(userId: string): Promise<ExternalIdentity[]> {
return await ExternalIdentity.getInstances({ userId });
}
/**
* Find identity by user and provider
*/
public static async findByUserAndProvider(
userId: string,
providerId: string
): Promise<ExternalIdentity | null> {
return await ExternalIdentity.getInstance({ userId, providerId });
}
/**
* Create a new external identity link
*/
public static async createIdentity(data: {
userId: string;
providerId: string;
externalId: string;
externalEmail?: string;
externalUsername?: string;
rawAttributes?: Record<string, unknown>;
}): Promise<ExternalIdentity> {
// Check if this external ID is already linked
const existing = await ExternalIdentity.findByExternalId(data.providerId, data.externalId);
if (existing) {
throw new Error('This external account is already linked to a user');
}
const identity = new ExternalIdentity();
identity.id = await ExternalIdentity.getNewId();
identity.userId = data.userId;
identity.providerId = data.providerId;
identity.externalId = data.externalId;
identity.externalEmail = data.externalEmail;
identity.externalUsername = data.externalUsername;
identity.rawAttributes = data.rawAttributes;
identity.lastLoginAt = new Date();
identity.createdAt = new Date();
await identity.save();
return identity;
}
/**
* Update last login time
*/
public async updateLastLogin(): Promise<void> {
this.lastLoginAt = new Date();
await this.save();
}
/**
* Update attributes from provider
*/
public async updateAttributes(data: {
externalEmail?: string;
externalUsername?: string;
rawAttributes?: Record<string, unknown>;
}): Promise<void> {
if (data.externalEmail !== undefined) this.externalEmail = data.externalEmail;
if (data.externalUsername !== undefined) this.externalUsername = data.externalUsername;
if (data.rawAttributes !== undefined) this.rawAttributes = data.rawAttributes;
this.lastLoginAt = new Date();
await this.save();
}
/**
* Lifecycle hook: Generate ID before save
*/
public async beforeSave(): Promise<void> {
if (!this.id) {
this.id = await ExternalIdentity.getNewId();
}
}
}

View File

@@ -14,3 +14,8 @@ export { Package } from './package.ts';
export { ApiToken } from './apitoken.ts';
export { Session } from './session.ts';
export { AuditLog } from './auditlog.ts';
// External authentication models
export { AuthProvider } from './auth.provider.ts';
export { ExternalIdentity } from './external.identity.ts';
export { PlatformSettings } from './platform.settings.ts';

View File

@@ -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()
@@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
@plugins.smartdata.svDb()
public avatarUrl?: string;
@plugins.smartdata.svDb()
public website?: string;
@plugins.smartdata.svDb()
public isPublic: boolean = false;
@plugins.smartdata.svDb()
public memberCount: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public plan: TOrganizationPlan = 'free';
@@ -79,11 +88,11 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
description?: string;
createdById: string;
}): Promise<Organization> {
// Validate name (URL-safe)
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
// Validate name (URL-safe, allows dots for domain-like names)
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name)) {
throw new Error(
'Organization name must be lowercase alphanumeric with optional hyphens'
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
);
}
@@ -100,6 +109,13 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
return org;
}
/**
* Find organization by ID
*/
public static async findById(id: string): Promise<Organization | null> {
return await Organization.getInstance({ id });
}
/**
* Find organization by name (slug)
*/

View File

@@ -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;

View File

@@ -0,0 +1,90 @@
/**
* Platform Settings model for Stack.Gallery Registry
* Singleton model storing global platform configuration
*/
import * as plugins from '../plugins.ts';
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
localAuthEnabled: true,
allowUserRegistration: true,
sessionDurationMinutes: 10080, // 7 days
};
@plugins.smartdata.Collection(() => db)
export class PlatformSettings
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
implements IPlatformSettings
{
@plugins.smartdata.unI()
public id: string = 'singleton';
@plugins.smartdata.svDb()
public auth: IPlatformAuthSettings = DEFAULT_AUTH_SETTINGS;
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedById?: string;
/**
* Get the singleton settings instance (creates if not exists)
*/
public static async get(): Promise<PlatformSettings> {
let settings = await PlatformSettings.getInstance({ id: 'singleton' });
if (!settings) {
settings = new PlatformSettings();
settings.id = 'singleton';
settings.auth = DEFAULT_AUTH_SETTINGS;
settings.updatedAt = new Date();
await settings.save();
console.log('[PlatformSettings] Created default settings');
}
return settings;
}
/**
* Update auth settings
*/
public async updateAuthSettings(
settings: Partial<IPlatformAuthSettings>,
updatedById?: string
): Promise<void> {
this.auth = { ...this.auth, ...settings };
this.updatedAt = new Date();
this.updatedById = updatedById;
await this.save();
}
/**
* Check if local auth is enabled
*/
public isLocalAuthEnabled(): boolean {
return this.auth.localAuthEnabled;
}
/**
* Check if registration is allowed
*/
public isRegistrationAllowed(): boolean {
return this.auth.allowUserRegistration;
}
/**
* Get default provider ID (for auto-redirect)
*/
public getDefaultProviderId(): string | undefined {
return this.auth.defaultProviderId;
}
/**
* Lifecycle hook: Ensure singleton ID
*/
public async beforeSave(): Promise<void> {
this.id = 'singleton';
this.updatedAt = new Date();
}
}

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -67,6 +67,16 @@ export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implement
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
// External authentication fields
@plugins.smartdata.svDb()
public externalIdentityIds: string[] = [];
@plugins.smartdata.svDb()
public canUseLocalAuth: boolean = true;
@plugins.smartdata.svDb()
public provisionedByProviderId?: string; // Provider that JIT-created this user
/**
* Create a new user instance
*/

View File

@@ -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
}
/**

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,47 @@
/**
* Authentication Strategy Interface
* Base interface for OAuth/OIDC and LDAP authentication strategies
*/
import type {
IExternalUserInfo,
IConnectionTestResult,
} from '../../../interfaces/auth.interfaces.ts';
export interface IOAuthCallbackData {
code: string;
state: string;
error?: string;
errorDescription?: string;
}
export interface IAuthStrategy {
/**
* Get the authorization URL for OAuth/OIDC flow
* @param state - CSRF state token
* @param nonce - Optional nonce for OIDC
* @returns Authorization URL to redirect user to
*/
getAuthorizationUrl?(state: string, nonce?: string): Promise<string>;
/**
* Handle OAuth/OIDC callback
* @param data - Callback data including code and state
* @returns External user info from the provider
*/
handleCallback?(data: IOAuthCallbackData): Promise<IExternalUserInfo>;
/**
* Authenticate with credentials (LDAP)
* @param username - Username
* @param password - Password
* @returns External user info if authentication succeeds
*/
authenticateCredentials?(username: string, password: string): Promise<IExternalUserInfo>;
/**
* Test connection to the provider
* @returns Connection test result
*/
testConnection(): Promise<IConnectionTestResult>;
}

View File

@@ -0,0 +1,8 @@
/**
* Auth Strategy exports
*/
export type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
export { OAuthStrategy } from './oauth.strategy.ts';
export { LdapStrategy } from './ldap.strategy.ts';
export { AuthStrategyFactory } from './strategy.factory.ts';

View File

@@ -0,0 +1,242 @@
/**
* LDAP Authentication Strategy
* Handles LDAP/Active Directory authentication
*
* Note: This is a basic implementation. For production use with actual LDAP,
* you may need to integrate with a Deno-compatible LDAP library.
*/
import type { AuthProvider } from '../../../models/auth.provider.ts';
import type { CryptoService } from '../../crypto.service.ts';
import type {
IExternalUserInfo,
IConnectionTestResult,
} from '../../../interfaces/auth.interfaces.ts';
import type { IAuthStrategy } from './auth.strategy.interface.ts';
// LDAP entry type (simplified)
interface ILdapEntry {
dn: string;
[key: string]: unknown;
}
export class LdapStrategy implements IAuthStrategy {
constructor(
private provider: AuthProvider,
private cryptoService: CryptoService
) {}
/**
* Authenticate user with LDAP credentials
*/
public async authenticateCredentials(
username: string,
password: string
): Promise<IExternalUserInfo> {
const config = this.provider.ldapConfig;
if (!config) {
throw new Error('LDAP config not found');
}
// Escape username to prevent LDAP injection
const escapedUsername = this.escapeLdap(username);
// Build user search filter
const userFilter = config.userSearchFilter.replace('{{username}}', escapedUsername);
// Decrypt bind password
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
// Perform LDAP authentication
// This is a placeholder - actual implementation would use an LDAP library
const userEntry = await this.ldapBind(
config.serverUrl,
config.bindDn,
bindPassword,
config.baseDn,
userFilter,
password
);
// Map LDAP attributes to user info
return this.mapAttributes(userEntry);
}
/**
* Test LDAP connection
*/
public async testConnection(): Promise<IConnectionTestResult> {
const start = Date.now();
const config = this.provider.ldapConfig;
if (!config) {
return {
success: false,
latencyMs: Date.now() - start,
error: 'LDAP config not found',
};
}
try {
// Decrypt bind password
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
// Test connection by binding with service account
await this.testLdapConnection(
config.serverUrl,
config.bindDn,
bindPassword,
config.baseDn
);
return {
success: true,
latencyMs: Date.now() - start,
serverInfo: {
serverUrl: config.serverUrl,
baseDn: config.baseDn,
tlsEnabled: config.tlsEnabled,
},
};
} catch (error) {
return {
success: false,
latencyMs: Date.now() - start,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Escape special LDAP characters to prevent injection
*/
private escapeLdap(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\x00/g, '\\00');
}
/**
* Perform LDAP bind and search
* This is a placeholder implementation - actual LDAP would require a library
*/
private async ldapBind(
serverUrl: string,
bindDn: string,
bindPassword: string,
baseDn: string,
userFilter: string,
userPassword: string
): Promise<ILdapEntry> {
// In a real implementation, this would:
// 1. Connect to LDAP server
// 2. Bind with service account (bindDn/bindPassword)
// 3. Search for user with userFilter
// 4. Re-bind with user's DN and password to verify
// 5. Return user entry if successful
// For now, we throw an error indicating LDAP needs to be configured
// This allows the structure to be in place while the actual LDAP library
// integration can be done separately
console.log('[LdapStrategy] LDAP auth attempt:', {
serverUrl,
baseDn,
userFilter,
});
throw new Error(
'LDAP authentication is not yet fully implemented. ' +
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
);
}
/**
* Test LDAP connection
*/
private async testLdapConnection(
serverUrl: string,
bindDn: string,
bindPassword: string,
baseDn: string
): Promise<void> {
// Similar to ldapBind, this is a placeholder
// Would connect and bind with service account to verify connectivity
console.log('[LdapStrategy] Testing LDAP connection:', {
serverUrl,
bindDn,
baseDn,
});
// For now, check if server URL is valid
if (!serverUrl.startsWith('ldap://') && !serverUrl.startsWith('ldaps://')) {
throw new Error('Invalid LDAP server URL. Must start with ldap:// or ldaps://');
}
// In a real implementation, we would actually connect here
// For now, we just validate the configuration
if (!bindDn || !bindPassword || !baseDn) {
throw new Error('Missing required LDAP configuration');
}
// Return success for configuration validation
// Actual connectivity test would happen with LDAP library
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
}
/**
* Map LDAP attributes to standard user info
*/
private mapAttributes(entry: ILdapEntry): IExternalUserInfo {
const mapping = this.provider.attributeMapping;
// Get external ID (typically uid or sAMAccountName)
const externalId = String(entry[mapping.username] || entry.dn);
// Get email
const email = entry[mapping.email];
if (!email || typeof email !== 'string') {
throw new Error('Email not found in LDAP entry');
}
return {
externalId,
email,
username: entry[mapping.username]
? String(entry[mapping.username])
: undefined,
displayName: entry[mapping.displayName]
? String(entry[mapping.displayName])
: undefined,
groups: mapping.groups
? this.parseGroups(entry[mapping.groups])
: undefined,
rawAttributes: entry as Record<string, unknown>,
};
}
/**
* Parse LDAP group membership
*/
private parseGroups(memberOf: unknown): string[] {
if (!memberOf) return [];
if (Array.isArray(memberOf)) {
return memberOf.map((dn) => this.extractCnFromDn(String(dn)));
}
return [this.extractCnFromDn(String(memberOf))];
}
/**
* Extract CN (Common Name) from a DN (Distinguished Name)
*/
private extractCnFromDn(dn: string): string {
const match = dn.match(/^CN=([^,]+)/i);
return match ? match[1] : dn;
}
}

View File

@@ -0,0 +1,263 @@
/**
* OAuth/OIDC Authentication Strategy
* Handles OAuth 2.0 and OpenID Connect flows
*/
import type { AuthProvider } from '../../../models/auth.provider.ts';
import type { CryptoService } from '../../crypto.service.ts';
import type {
IExternalUserInfo,
IConnectionTestResult,
} from '../../../interfaces/auth.interfaces.ts';
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
interface ITokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
scope?: string;
}
interface IOIDCDiscovery {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri?: string;
scopes_supported?: string[];
}
export class OAuthStrategy implements IAuthStrategy {
private discoveryCache: IOIDCDiscovery | null = null;
constructor(
private provider: AuthProvider,
private cryptoService: CryptoService
) {}
/**
* Get the authorization URL for initiating OAuth flow
*/
public async getAuthorizationUrl(state: string, nonce?: string): Promise<string> {
const config = this.provider.oauthConfig;
if (!config) {
throw new Error('OAuth config not found');
}
// Get authorization URL from config or discovery
let authorizationUrl = config.authorizationUrl;
if (!authorizationUrl) {
const discovery = await this.getDiscovery();
authorizationUrl = discovery.authorization_endpoint;
}
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.callbackUrl,
response_type: 'code',
scope: config.scopes.join(' '),
state,
});
// Add nonce for OIDC
if (nonce) {
params.set('nonce', nonce);
}
return `${authorizationUrl}?${params.toString()}`;
}
/**
* Handle OAuth callback - exchange code for tokens and get user info
*/
public async handleCallback(data: IOAuthCallbackData): Promise<IExternalUserInfo> {
if (data.error) {
throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`);
}
const config = this.provider.oauthConfig;
if (!config) {
throw new Error('OAuth config not found');
}
// Exchange code for tokens
const tokens = await this.exchangeCodeForTokens(data.code);
// Get user info
const userInfo = await this.fetchUserInfo(tokens.access_token);
// Map attributes according to provider config
return this.mapAttributes(userInfo);
}
/**
* Test connection by fetching OIDC discovery document
*/
public async testConnection(): Promise<IConnectionTestResult> {
const start = Date.now();
const config = this.provider.oauthConfig;
if (!config) {
return {
success: false,
latencyMs: Date.now() - start,
error: 'OAuth config not found',
};
}
try {
const discovery = await this.getDiscovery();
return {
success: true,
latencyMs: Date.now() - start,
serverInfo: {
issuer: discovery.issuer,
scopes_supported: discovery.scopes_supported,
has_userinfo: !!discovery.userinfo_endpoint,
},
};
} catch (error) {
return {
success: false,
latencyMs: Date.now() - start,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Exchange authorization code for tokens
*/
private async exchangeCodeForTokens(code: string): Promise<ITokenResponse> {
const config = this.provider.oauthConfig!;
// Get token URL from config or discovery
let tokenUrl = config.tokenUrl;
if (!tokenUrl) {
const discovery = await this.getDiscovery();
tokenUrl = discovery.token_endpoint;
}
// Decrypt client secret
const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.callbackUrl,
client_id: config.clientId,
client_secret: clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
}
return await response.json();
}
/**
* Fetch user info from the provider
*/
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown>> {
const config = this.provider.oauthConfig!;
// Get userinfo URL from config or discovery
let userInfoUrl = config.userInfoUrl;
if (!userInfoUrl) {
const discovery = await this.getDiscovery();
userInfoUrl = discovery.userinfo_endpoint;
}
if (!userInfoUrl) {
throw new Error('UserInfo endpoint not found');
}
const response = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`);
}
return await response.json();
}
/**
* Get OIDC discovery document
*/
private async getDiscovery(): Promise<IOIDCDiscovery> {
if (this.discoveryCache) {
return this.discoveryCache;
}
const config = this.provider.oauthConfig!;
const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`OIDC discovery failed: ${response.status}`);
}
this.discoveryCache = await response.json();
return this.discoveryCache!;
}
/**
* Map provider attributes to standard user info
*/
private mapAttributes(rawInfo: Record<string, unknown>): IExternalUserInfo {
const mapping = this.provider.attributeMapping;
// Get external ID (sub for OIDC, or id for OAuth2)
const externalId = String(rawInfo.sub || rawInfo.id || '');
if (!externalId) {
throw new Error('External ID not found in user info');
}
// Get email
const email = rawInfo[mapping.email];
if (!email || typeof email !== 'string') {
throw new Error('Email not found in user info');
}
return {
externalId,
email,
username: rawInfo[mapping.username]
? String(rawInfo[mapping.username])
: undefined,
displayName: rawInfo[mapping.displayName]
? String(rawInfo[mapping.displayName])
: undefined,
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
? String(rawInfo[mapping.avatarUrl])
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
groups: mapping.groups && rawInfo[mapping.groups]
? (Array.isArray(rawInfo[mapping.groups])
? (rawInfo[mapping.groups] as string[])
: [String(rawInfo[mapping.groups])])
: undefined,
rawAttributes: rawInfo,
};
}
}

View File

@@ -0,0 +1,28 @@
/**
* Auth Strategy Factory
* Creates the appropriate authentication strategy based on provider type
*/
import type { AuthProvider } from '../../../models/auth.provider.ts';
import type { CryptoService } from '../../crypto.service.ts';
import type { IAuthStrategy } from './auth.strategy.interface.ts';
import { OAuthStrategy } from './oauth.strategy.ts';
import { LdapStrategy } from './ldap.strategy.ts';
export class AuthStrategyFactory {
constructor(private cryptoService: CryptoService) {}
/**
* Create the appropriate strategy for a provider
*/
public create(provider: AuthProvider): IAuthStrategy {
switch (provider.type) {
case 'oidc':
return new OAuthStrategy(provider, this.cryptoService);
case 'ldap':
return new LdapStrategy(provider, this.cryptoService);
default:
throw new Error(`Unsupported provider type: ${provider.type}`);
}
}
}

View File

@@ -0,0 +1,178 @@
/**
* Crypto Service for Stack.Gallery Registry
* Handles AES-256-GCM encryption/decryption of secrets
*/
export class CryptoService {
private masterKey: CryptoKey | null = null;
private initialized = false;
/**
* Initialize the crypto service with the master key
* The key should be a 64-character hex string (32 bytes = 256 bits)
*/
public async initialize(): Promise<void> {
if (this.initialized) return;
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
if (!keyHex) {
console.warn(
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
} else {
if (keyHex.length !== 64) {
throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
}
this.masterKey = await this.importKey(keyHex);
}
this.initialized = true;
}
/**
* Encrypt a plaintext string
* Returns format: base64(iv):base64(ciphertext)
*/
public async encrypt(plaintext: string): Promise<string> {
await this.initialize();
if (!this.masterKey) {
throw new Error('CryptoService not initialized');
}
// Generate random IV (12 bytes for AES-GCM)
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encode plaintext to bytes
const encoded = new TextEncoder().encode(plaintext);
// Encrypt
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encoded.buffer as ArrayBuffer
);
// Format: iv:ciphertext (both base64)
const ivBase64 = this.bytesToBase64(iv);
const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted));
return `${ivBase64}:${ciphertextBase64}`;
}
/**
* Decrypt an encrypted string
* Expects format: base64(iv):base64(ciphertext)
*/
public async decrypt(ciphertext: string): Promise<string> {
await this.initialize();
if (!this.masterKey) {
throw new Error('CryptoService not initialized');
}
const parts = ciphertext.split(':');
if (parts.length !== 2) {
throw new Error('Invalid ciphertext format');
}
const [ivBase64, encryptedBase64] = parts;
// Decode from base64
const iv = this.base64ToBytes(ivBase64);
const encrypted = this.base64ToBytes(encryptedBase64);
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encrypted.buffer as ArrayBuffer
);
// Decode to string
return new TextDecoder().decode(decrypted);
}
/**
* Check if a string is already encrypted (contains the iv:ciphertext format)
*/
public isEncrypted(value: string): boolean {
if (!value || typeof value !== 'string') return false;
const parts = value.split(':');
if (parts.length !== 2) return false;
// Check if both parts look like base64
try {
this.base64ToBytes(parts[0]);
this.base64ToBytes(parts[1]);
return true;
} catch {
return false;
}
}
/**
* Import a hex key as CryptoKey
*/
private async importKey(keyHex: string): Promise<CryptoKey> {
const keyBytes = this.hexToBytes(keyHex);
return await crypto.subtle.importKey(
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
}
/**
* Convert bytes to hex string
*/
private bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hex string to bytes
*/
private hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Convert bytes to base64
*/
private bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
/**
* Convert base64 to bytes
*/
private base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
/**
* Generate a new encryption key (for setup)
* Returns a 64-character hex string
*/
public static generateKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
}
// Singleton instance
export const cryptoService = new CryptoService();

View File

@@ -0,0 +1,568 @@
/**
* External Auth Service for Stack.Gallery Registry
* Orchestrates OAuth/OIDC and LDAP authentication flows
*/
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
import { AuthService, type IAuthResult } from './auth.service.ts';
import { AuditService } from './audit.service.ts';
import { cryptoService } from './crypto.service.ts';
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
export interface IOAuthState {
providerId: string;
returnUrl?: string;
nonce: string;
exp: number;
}
export class ExternalAuthService {
private strategyFactory: AuthStrategyFactory;
private authService: AuthService;
private auditService: AuditService;
constructor() {
this.strategyFactory = new AuthStrategyFactory(cryptoService);
this.authService = new AuthService();
this.auditService = new AuditService({ actorType: 'system' });
}
/**
* Initiate OAuth flow - returns authorization URL and state
*/
public async initiateOAuth(
providerId: string,
returnUrl?: string
): Promise<{ authUrl: string; state: string }> {
const provider = await AuthProvider.findById(providerId);
if (!provider) {
throw new Error('Provider not found');
}
if (provider.status !== 'active') {
throw new Error('Provider is not active');
}
if (provider.type !== 'oidc') {
throw new Error('Provider is not an OAuth/OIDC provider');
}
const strategy = this.strategyFactory.create(provider);
if (!strategy.getAuthorizationUrl) {
throw new Error('Provider does not support OAuth flow');
}
// Generate state with encoded data
const state = await this.generateState(providerId, returnUrl);
const nonce = crypto.randomUUID();
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
return { authUrl, state };
}
/**
* Handle OAuth callback - exchange code for user and create session
*/
public async handleOAuthCallback(
data: IOAuthCallbackData,
options: { ipAddress?: string; userAgent?: string } = {}
): Promise<IAuthResult> {
// Validate state
const stateData = await this.validateState(data.state);
if (!stateData) {
return {
success: false,
errorCode: 'INVALID_STATE',
errorMessage: 'Invalid or expired state',
};
}
// Get provider
const provider = await AuthProvider.findById(stateData.providerId);
if (!provider || provider.status !== 'active') {
return {
success: false,
errorCode: 'PROVIDER_INACTIVE',
errorMessage: 'Provider not found or inactive',
};
}
// Handle OAuth callback
const strategy = this.strategyFactory.create(provider);
if (!strategy.handleCallback) {
return {
success: false,
errorCode: 'INVALID_PROVIDER',
errorMessage: 'Provider does not support OAuth callback',
};
}
let externalUser: IExternalUserInfo;
try {
externalUser = await strategy.handleCallback(data);
} catch (error) {
await this.auditService.log('AUTH_LOGIN', 'user', {
success: false,
metadata: {
providerId: provider.id,
providerName: provider.name,
error: error instanceof Error ? error.message : String(error),
},
});
return {
success: false,
errorCode: 'PROVIDER_ERROR',
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
};
}
// Find or create user
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
// Create session
const session = await Session.createSession({
userId: user.id,
userAgent: options.userAgent || '',
ipAddress: options.ipAddress || '',
});
// Generate tokens using the existing AuthService approach
const accessToken = await this.generateAccessToken(user, session.id);
const refreshToken = await this.generateRefreshToken(user, session.id);
// Update user last login
user.lastLoginAt = new Date();
await user.save();
// Audit log
await AuditService.withContext({
actorId: user.id,
actorType: 'user',
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
}).log('AUTH_LOGIN', 'user', {
resourceId: user.id,
success: true,
metadata: {
providerId: provider.id,
providerName: provider.name,
isNewUser: isNew,
authMethod: 'oauth',
},
});
return {
success: true,
user,
accessToken,
refreshToken,
sessionId: session.id,
};
}
/**
* Authenticate with LDAP credentials
*/
public async authenticateLdap(
providerId: string,
username: string,
password: string,
options: { ipAddress?: string; userAgent?: string } = {}
): Promise<IAuthResult> {
const provider = await AuthProvider.findById(providerId);
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
return {
success: false,
errorCode: 'INVALID_PROVIDER',
errorMessage: 'Invalid LDAP provider',
};
}
const strategy = this.strategyFactory.create(provider);
if (!strategy.authenticateCredentials) {
return {
success: false,
errorCode: 'INVALID_PROVIDER',
errorMessage: 'Provider does not support credential authentication',
};
}
let externalUser: IExternalUserInfo;
try {
externalUser = await strategy.authenticateCredentials(username, password);
} catch (error) {
await this.auditService.log('AUTH_LOGIN', 'user', {
success: false,
metadata: {
providerId: provider.id,
providerName: provider.name,
username,
error: error instanceof Error ? error.message : String(error),
},
});
return {
success: false,
errorCode: 'AUTH_FAILED',
errorMessage: 'Invalid credentials',
};
}
// Find or create user
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
// Create session
const session = await Session.createSession({
userId: user.id,
userAgent: options.userAgent || '',
ipAddress: options.ipAddress || '',
});
// Generate tokens
const accessToken = await this.generateAccessToken(user, session.id);
const refreshToken = await this.generateRefreshToken(user, session.id);
// Update user last login
user.lastLoginAt = new Date();
await user.save();
// Audit log
await AuditService.withContext({
actorId: user.id,
actorType: 'user',
actorIp: options.ipAddress,
actorUserAgent: options.userAgent,
}).log('AUTH_LOGIN', 'user', {
resourceId: user.id,
success: true,
metadata: {
providerId: provider.id,
providerName: provider.name,
isNewUser: isNew,
authMethod: 'ldap',
},
});
return {
success: true,
user,
accessToken,
refreshToken,
sessionId: session.id,
};
}
/**
* Link an external provider to an existing user
*/
public async linkProvider(
userId: string,
providerId: string,
externalUser: IExternalUserInfo
): Promise<ExternalIdentity> {
// Check if this external ID is already linked to another user
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
if (existing) {
if (existing.userId === userId) {
// Already linked to this user, just update
await existing.updateAttributes({
externalEmail: externalUser.email,
externalUsername: externalUser.username,
rawAttributes: externalUser.rawAttributes,
});
return existing;
}
throw new Error('This external account is already linked to another user');
}
// Create new identity link
const identity = await ExternalIdentity.createIdentity({
userId,
providerId,
externalId: externalUser.externalId,
externalEmail: externalUser.email,
externalUsername: externalUser.username,
rawAttributes: externalUser.rawAttributes,
});
// Update user's external identity IDs
const user = await User.findById(userId);
if (user) {
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
await user.save();
}
// Audit log
await this.auditService.log('USER_UPDATED', 'user', {
resourceId: userId,
success: true,
metadata: {
action: 'link_provider',
providerId,
externalId: externalUser.externalId,
},
});
return identity;
}
/**
* Unlink an external provider from a user
*/
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
if (!identity) {
return false;
}
// Ensure user still has another auth method
const user = await User.findById(userId);
if (!user) return false;
const otherIdentities = await ExternalIdentity.findByUserId(userId);
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
if (otherIdentities.length <= 1 && !hasLocalAuth) {
throw new Error('Cannot unlink last authentication method');
}
// Remove identity
await identity.delete();
// Update user's external identity IDs
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
await user.save();
// Audit log
await this.auditService.log('USER_UPDATED', 'user', {
resourceId: userId,
success: true,
metadata: {
action: 'unlink_provider',
providerId,
},
});
return true;
}
/**
* Test provider connection
*/
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
const provider = await AuthProvider.findById(providerId);
if (!provider) {
return {
success: false,
latencyMs: 0,
error: 'Provider not found',
};
}
const strategy = this.strategyFactory.create(provider);
const result = await strategy.testConnection();
// Update provider test status
await provider.updateTestResult(result.success, result.error);
return result;
}
/**
* Find or create user from external authentication
*/
private async findOrCreateUser(
provider: AuthProvider,
externalUser: IExternalUserInfo,
options: { ipAddress?: string } = {}
): Promise<{ user: User; isNew: boolean }> {
// 1. Check if external identity already exists
const existingIdentity = await ExternalIdentity.findByExternalId(
provider.id,
externalUser.externalId
);
if (existingIdentity) {
const user = await User.findById(existingIdentity.userId);
if (user) {
// Update identity with latest info
await existingIdentity.updateAttributes({
externalEmail: externalUser.email,
externalUsername: externalUser.username,
rawAttributes: externalUser.rawAttributes,
});
return { user, isNew: false };
}
}
// 2. Try to link by email if enabled
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
const existingUser = await User.findByEmail(externalUser.email);
if (existingUser) {
await this.linkProvider(existingUser.id, provider.id, externalUser);
return { user: existingUser, isNew: false };
}
}
// 3. Create new user if JIT is enabled
if (!provider.provisioning.jitEnabled) {
throw new Error('User not found and JIT provisioning is disabled');
}
// Check domain restrictions
if (provider.provisioning.allowedEmailDomains?.length) {
const domain = externalUser.email.split('@')[1];
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
throw new Error(`Email domain ${domain} is not allowed`);
}
}
// Generate unique username
let username = externalUser.username || externalUser.email.split('@')[0];
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Ensure username is unique
let counter = 0;
let finalUsername = username;
while (await User.findByUsername(finalUsername)) {
counter++;
finalUsername = `${username}${counter}`;
}
// Create user
const user = new User();
user.id = await User.getNewId();
user.email = externalUser.email.toLowerCase();
user.username = finalUsername;
user.displayName = externalUser.displayName || finalUsername;
user.avatarUrl = externalUser.avatarUrl;
user.status = 'active';
user.emailVerified = true; // Trust the provider
user.canUseLocalAuth = false; // No password set
user.provisionedByProviderId = provider.id;
user.passwordHash = ''; // No local password
user.createdAt = new Date();
user.updatedAt = new Date();
await user.save();
// Link external identity
await this.linkProvider(user.id, provider.id, externalUser);
return { user, isNew: true };
}
/**
* Generate OAuth state token
*/
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
const stateData: IOAuthState = {
providerId,
returnUrl,
nonce: crypto.randomUUID(),
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
};
// Encode as base64
return btoa(JSON.stringify(stateData));
}
/**
* Validate OAuth state token
*/
private async validateState(state: string): Promise<IOAuthState | null> {
try {
const stateData: IOAuthState = JSON.parse(atob(state));
// Check expiration
if (stateData.exp < Date.now()) {
return null;
}
return stateData;
} catch {
return null;
}
}
/**
* Generate access token (mirrors AuthService logic)
*/
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
const now = Math.floor(Date.now() / 1000);
const expiresIn = 15 * 60; // 15 minutes
const payload = {
sub: user.id,
email: user.email,
sessionId,
type: 'access',
iat: now,
exp: now + expiresIn,
};
return await this.signJwt(payload, jwtSecret);
}
/**
* Generate refresh token (mirrors AuthService logic)
*/
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
const now = Math.floor(Date.now() / 1000);
const expiresIn = 7 * 24 * 60 * 60; // 7 days
const payload = {
sub: user.id,
email: user.email,
sessionId,
type: 'refresh',
iat: now,
exp: now + expiresIn,
};
return await this.signJwt(payload, jwtSecret);
}
/**
* Sign JWT token
*/
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
const data = `${encodedHeader}.${encodedPayload}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const encodedSignature = this.base64UrlEncode(
String.fromCharCode(...new Uint8Array(signature))
);
return `${data}.${encodedSignature}`;
}
/**
* Base64 URL encode
*/
private base64UrlEncode(str: string): string {
const base64 = btoa(str);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
}
// Singleton instance
export const externalAuthService = new ExternalAuthService();

View File

@@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts';
export interface ICreateTokenOptions {
userId: string;
organizationId?: string; // For org-owned tokens
createdById?: string; // Who created the token (defaults to userId)
name: string;
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
@@ -52,6 +54,8 @@ export class TokenService {
const token = new ApiToken();
token.id = await ApiToken.getNewId();
token.userId = options.userId;
token.organizationId = options.organizationId;
token.createdById = options.createdById || options.userId;
token.name = options.name;
token.tokenHash = tokenHash;
token.tokenPrefix = tokenPrefix;
@@ -150,6 +154,13 @@ export class TokenService {
return await ApiToken.getUserTokens(userId);
}
/**
* Get all tokens for an organization
*/
public async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
return await ApiToken.getOrgTokens(organizationId);
}
/**
* Revoke a token
*/
@@ -175,6 +186,18 @@ export class TokenService {
return tokens.length;
}
/**
* Revoke all tokens for an organization
*/
public async revokeAllOrgTokens(organizationId: string, reason?: string): Promise<number> {
const tokens = await ApiToken.getOrgTokens(organizationId);
for (const token of tokens) {
await token.revoke(reason);
await this.auditService.logTokenRevoked(token.id, token.name);
}
return tokens.length;
}
/**
* Check if token has permission for a specific action
*/

View File

@@ -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"]
}

View File

@@ -1,5 +1,6 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { adminGuard } from './core/guards/admin.guard';
export const routes: Routes = [
{
@@ -7,6 +8,13 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/login/login.component').then((m) => m.LoginComponent),
},
{
path: 'oauth-callback',
loadComponent: () =>
import('./features/oauth-callback/oauth-callback.component').then(
(m) => m.OAuthCallbackComponent
),
},
{
path: '',
loadComponent: () =>
@@ -38,14 +46,14 @@ export const routes: Routes = [
),
},
{
path: ':orgId',
path: ':orgName',
loadComponent: () =>
import('./features/organizations/organization-detail.component').then(
(m) => m.OrganizationDetailComponent
),
},
{
path: ':orgId/repositories/:repoId',
path: ':orgName/repositories/:repoId',
loadComponent: () =>
import('./features/repositories/repository-detail.component').then(
(m) => m.RepositoryDetailComponent
@@ -86,6 +94,39 @@ export const routes: Routes = [
(m) => m.SettingsComponent
),
},
// Admin routes
{
path: 'admin',
canActivate: [adminGuard],
children: [
{
path: '',
redirectTo: 'auth',
pathMatch: 'full',
},
{
path: 'auth',
loadComponent: () =>
import('./features/admin/auth-providers/auth-providers.component').then(
(m) => m.AuthProvidersComponent
),
},
{
path: 'auth/providers/new',
loadComponent: () =>
import('./features/admin/auth-providers/provider-form.component').then(
(m) => m.ProviderFormComponent
),
},
{
path: 'auth/providers/:id',
loadComponent: () =>
import('./features/admin/auth-providers/provider-form.component').then(
(m) => m.ProviderFormComponent
),
},
],
},
],
},
{

View File

@@ -0,0 +1,27 @@
import { inject } from '@angular/core';
import { Router, type CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const adminGuard: CanActivateFn = async () => {
const authService = inject(AuthService);
const router = inject(Router);
// First check if authenticated
if (!authService.isAuthenticated()) {
// Try to refresh the token
const refreshed = await authService.refreshAccessToken();
if (!refreshed) {
router.navigate(['/login']);
return false;
}
}
// Then check if admin
if (!authService.isAdmin()) {
// Not an admin, redirect to dashboard
router.navigate(['/dashboard']);
return false;
}
return true;
};

View File

@@ -0,0 +1,141 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
// Types
export type TAuthProviderType = 'oidc' | 'ldap';
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
export interface IOAuthConfig {
clientId: string;
clientSecretEncrypted: string;
issuer: string;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
scopes: string[];
callbackUrl: string;
}
export interface ILdapConfig {
serverUrl: string;
bindDn: string;
bindPasswordEncrypted: string;
baseDn: string;
userSearchFilter: string;
tlsEnabled: boolean;
tlsCaCert?: string;
}
export interface IAttributeMapping {
email: string;
username: string;
displayName: string;
avatarUrl?: string;
groups?: string;
}
export interface IProvisioningSettings {
jitEnabled: boolean;
autoLinkByEmail: boolean;
allowedEmailDomains?: string[];
}
export interface IAuthProvider {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
status: TAuthProviderStatus;
priority: number;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping: IAttributeMapping;
provisioning: IProvisioningSettings;
createdAt: string;
updatedAt: string;
createdById: string;
lastTestedAt?: string;
lastTestResult?: 'success' | 'failure';
lastTestError?: string;
}
export interface IPlatformAuthSettings {
localAuthEnabled: boolean;
allowUserRegistration: boolean;
sessionDurationMinutes: number;
defaultProviderId?: string;
}
export interface IPlatformSettings {
id: string;
auth: IPlatformAuthSettings;
updatedAt: string;
updatedById?: string;
}
export interface IConnectionTestResult {
success: boolean;
latencyMs: number;
serverInfo?: Record<string, unknown>;
error?: string;
}
export interface ICreateAuthProviderDto {
name: string;
displayName: string;
type: TAuthProviderType;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping?: IAttributeMapping;
provisioning?: IProvisioningSettings;
}
export interface IUpdateAuthProviderDto {
displayName?: string;
status?: TAuthProviderStatus;
priority?: number;
oauthConfig?: Partial<IOAuthConfig>;
ldapConfig?: Partial<ILdapConfig>;
attributeMapping?: Partial<IAttributeMapping>;
provisioning?: Partial<IProvisioningSettings>;
}
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
constructor(private http: HttpClient) {}
// Provider CRUD
listProviders(): Observable<{ providers: IAuthProvider[] }> {
return this.http.get<{ providers: IAuthProvider[] }>('/api/v1/admin/auth/providers');
}
getProvider(id: string): Observable<IAuthProvider> {
return this.http.get<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`);
}
createProvider(dto: ICreateAuthProviderDto): Observable<IAuthProvider> {
return this.http.post<IAuthProvider>('/api/v1/admin/auth/providers', dto);
}
updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable<IAuthProvider> {
return this.http.put<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`, dto);
}
deleteProvider(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`/api/v1/admin/auth/providers/${id}`);
}
testProvider(id: string): Observable<IConnectionTestResult> {
return this.http.post<IConnectionTestResult>(`/api/v1/admin/auth/providers/${id}/test`, {});
}
// Platform settings
getSettings(): Observable<IPlatformSettings> {
return this.http.get<IPlatformSettings>('/api/v1/admin/auth/settings');
}
updateSettings(settings: Partial<{ auth: Partial<IPlatformAuthSettings> }>): Observable<IPlatformSettings> {
return this.http.put<IPlatformSettings>('/api/v1/admin/auth/settings', settings);
}
}

View File

@@ -39,11 +39,21 @@ export interface IPackage {
updatedAt: string;
}
export interface ITokenScope {
protocol: string;
organizationId?: string;
repositoryId?: string;
actions: string[];
}
export interface IToken {
id: string;
name: string;
tokenPrefix: string;
protocols: string[];
scopes?: ITokenScope[];
organizationId?: string;
createdById?: string;
expiresAt?: string;
lastUsedAt?: string;
usageCount: number;
@@ -179,14 +189,21 @@ export class ApiService {
}
// Tokens
getTokens(): Observable<{ tokens: IToken[] }> {
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> {
let httpParams = new HttpParams();
if (organizationId) {
httpParams = httpParams.set('organizationId', organizationId);
}
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, {
params: httpParams,
});
}
createToken(data: {
name: string;
organizationId?: string;
protocols: string[];
scopes: { protocol: string; actions: string[] }[];
scopes: ITokenScope[];
expiresInDays?: number;
}): Observable<IToken & { token: string }> {
return this.http.post<IToken & { token: string }>(

View File

@@ -106,6 +106,19 @@ export class AuthService {
}
}
/**
* Handle OAuth callback tokens from external providers
*/
handleOAuthCallback(accessToken: string, refreshToken: string, sessionId: string): void {
this._accessToken.set(accessToken);
this._refreshToken.set(refreshToken);
this._sessionId.set(sessionId);
this.saveToStorage();
// Fetch user info asynchronously
this.fetchCurrentUser();
}
private loadFromStorage(): void {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');

View File

@@ -0,0 +1,522 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import {
AdminAuthService,
type IAuthProvider,
type IPlatformSettings,
type TAuthProviderStatus,
} from '../../../core/services/admin-auth.service';
import { ToastService } from '../../../core/services/toast.service';
@Component({
selector: 'app-auth-providers',
standalone: true,
template: `
<div class="p-6 max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<div class="section-header mb-2">
<div class="section-indicator"></div>
<span class="section-label">Admin</span>
</div>
<h1 class="font-mono text-2xl font-bold text-foreground">Authentication Providers</h1>
<p class="font-mono text-sm text-muted-foreground mt-1">Configure OAuth and LDAP authentication</p>
</div>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Provider
</button>
</div>
<!-- Platform Settings Card -->
<div class="card mb-6">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
</div>
</div>
<div class="card-content">
@if (settings()) {
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
<div>
<p class="font-mono text-sm font-medium text-foreground">Local Authentication</p>
<p class="font-mono text-xs text-muted-foreground">Allow email/password login</p>
</div>
<button
(click)="toggleLocalAuth()"
[class]="settings()!.auth.localAuthEnabled ? 'badge-accent' : 'badge-secondary'"
class="cursor-pointer"
>
{{ settings()!.auth.localAuthEnabled ? 'Enabled' : 'Disabled' }}
</button>
</div>
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
<div>
<p class="font-mono text-sm font-medium text-foreground">User Registration</p>
<p class="font-mono text-xs text-muted-foreground">Allow new account creation</p>
</div>
<button
(click)="toggleRegistration()"
[class]="settings()!.auth.allowUserRegistration ? 'badge-accent' : 'badge-secondary'"
class="cursor-pointer"
>
{{ settings()!.auth.allowUserRegistration ? 'Enabled' : 'Disabled' }}
</button>
</div>
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
<div>
<p class="font-mono text-sm font-medium text-foreground">Session Duration</p>
<p class="font-mono text-xs text-muted-foreground">{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}</p>
</div>
<button (click)="showSettingsModal.set(true)" class="btn-ghost btn-sm">
Edit
</button>
</div>
</div>
} @else {
<div class="animate-pulse flex space-x-4">
<div class="flex-1 space-y-2 py-1">
<div class="h-4 bg-muted"></div>
<div class="h-4 bg-muted w-5/6"></div>
</div>
</div>
}
</div>
</div>
<!-- Providers List -->
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else if (providers().length === 0) {
<div class="card card-content text-center py-12">
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h3 class="font-mono text-lg font-medium text-foreground mb-2">No providers configured</h3>
<p class="font-mono text-sm text-muted-foreground mb-4">Add an OAuth or LDAP provider to enable single sign-on</p>
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
Add Provider
</button>
</div>
} @else {
<div class="space-y-4">
@for (provider of providers(); track provider.id) {
<div class="card">
<div class="card-content">
<div class="flex items-start gap-4">
<div class="w-12 h-12 flex items-center justify-center flex-shrink-0" [class]="getProviderIconClass(provider.type)">
@if (provider.type === 'oidc') {
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
} @else {
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-mono font-semibold text-foreground">{{ provider.displayName }}</h3>
<span [class]="getStatusBadgeClass(provider.status)">{{ provider.status }}</span>
@if (settings()?.auth?.defaultProviderId === provider.id) {
<span class="badge-primary">Default</span>
}
</div>
<p class="font-mono text-sm text-muted-foreground">{{ provider.name }} · {{ provider.type.toUpperCase() }}</p>
@if (provider.type === 'oidc' && provider.oauthConfig) {
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.oauthConfig.issuer }}</p>
}
@if (provider.type === 'ldap' && provider.ldapConfig) {
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.ldapConfig.serverUrl }}</p>
}
@if (provider.lastTestedAt) {
<div class="flex items-center gap-2 mt-2 font-mono text-xs">
@if (provider.lastTestResult === 'success') {
<span class="text-accent flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Connection OK
</span>
} @else {
<span class="text-destructive flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Connection Failed
</span>
}
<span class="text-muted-foreground">
tested {{ formatDate(provider.lastTestedAt) }}
</span>
</div>
}
</div>
<div class="flex items-center gap-2">
<button
(click)="testProvider(provider)"
[disabled]="testing() === provider.id"
class="btn-ghost btn-sm"
>
@if (testing() === provider.id) {
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
} @else {
Test
}
</button>
<button (click)="editProvider(provider)" class="btn-ghost btn-sm">Edit</button>
<button (click)="confirmDelete(provider)" class="btn-ghost btn-sm text-destructive hover:text-destructive">
Delete
</button>
</div>
</div>
</div>
</div>
}
</div>
}
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Select Provider Type</span>
</div>
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content space-y-3">
<button
(click)="createProvider('oidc')"
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground">OAuth / OIDC</h4>
<p class="font-mono text-xs text-muted-foreground">Google, Azure AD, Okta, Auth0, etc.</p>
</div>
</div>
</button>
<button
(click)="createProvider('ldap')"
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground">LDAP / Active Directory</h4>
<p class="font-mono text-xs text-muted-foreground">Enterprise directory service</p>
</div>
</div>
</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (providerToDelete()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator bg-destructive"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Delete Provider</span>
</div>
<button (click)="providerToDelete.set(null)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content">
<p class="font-mono text-sm text-foreground">
Are you sure you want to delete <strong>{{ providerToDelete()!.displayName }}</strong>?
</p>
<p class="font-mono text-xs text-muted-foreground mt-2">
Users who signed in with this provider will no longer be able to authenticate through it.
</p>
</div>
<div class="card-footer flex justify-end gap-3">
<button (click)="providerToDelete.set(null)" class="btn-secondary btn-md">Cancel</button>
<button (click)="deleteProvider()" [disabled]="deleting()" class="btn-destructive btn-md">
@if (deleting()) {
Deleting...
} @else {
Delete
}
</button>
</div>
</div>
</div>
}
<!-- Settings Modal -->
@if (showSettingsModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
</div>
<button (click)="showSettingsModal.set(false)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Session Duration (minutes)</label>
<input
type="number"
[value]="editingSettings.sessionDurationMinutes"
(input)="editingSettings.sessionDurationMinutes = +($any($event.target).value)"
class="input"
min="60"
max="43200"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">How long user sessions remain valid (60-43200 minutes)</p>
</div>
<div>
<label class="label block mb-1.5">Default Provider</label>
<select
[value]="editingSettings.defaultProviderId || ''"
(change)="editingSettings.defaultProviderId = $any($event.target).value || undefined"
class="input"
>
<option value="">None (show all options)</option>
@for (provider of providers(); track provider.id) {
@if (provider.status === 'active') {
<option [value]="provider.id">{{ provider.displayName }}</option>
}
}
</select>
<p class="font-mono text-xs text-muted-foreground mt-1">Automatically redirect to this provider on login</p>
</div>
</div>
<div class="card-footer flex justify-end gap-3">
<button (click)="showSettingsModal.set(false)" class="btn-secondary btn-md">Cancel</button>
<button (click)="saveSettings()" [disabled]="savingSettings()" class="btn-primary btn-md">
@if (savingSettings()) {
Saving...
} @else {
Save
}
</button>
</div>
</div>
</div>
}
</div>
`,
})
export class AuthProvidersComponent implements OnInit {
private adminAuthService = inject(AdminAuthService);
private toastService = inject(ToastService);
providers = signal<IAuthProvider[]>([]);
settings = signal<IPlatformSettings | null>(null);
loading = signal(true);
testing = signal<string | null>(null);
deleting = signal(false);
savingSettings = signal(false);
showCreateModal = signal(false);
showSettingsModal = signal(false);
providerToDelete = signal<IAuthProvider | null>(null);
selectedProviderForEdit = signal<IAuthProvider | null>(null);
editingSettings = {
sessionDurationMinutes: 10080,
defaultProviderId: undefined as string | undefined,
};
ngOnInit(): void {
this.loadData();
}
private async loadData(): Promise<void> {
this.loading.set(true);
try {
const [providersRes, settingsRes] = await Promise.all([
this.adminAuthService.listProviders().toPromise(),
this.adminAuthService.getSettings().toPromise(),
]);
this.providers.set(providersRes?.providers || []);
if (settingsRes) {
this.settings.set(settingsRes);
this.editingSettings = {
sessionDurationMinutes: settingsRes.auth.sessionDurationMinutes,
defaultProviderId: settingsRes.auth.defaultProviderId,
};
}
} catch (error) {
this.toastService.error('Failed to load authentication settings');
} finally {
this.loading.set(false);
}
}
createProvider(type: 'oidc' | 'ldap'): void {
this.showCreateModal.set(false);
// Navigate to provider form
window.location.href = `/admin/auth/providers/new?type=${type}`;
}
editProvider(provider: IAuthProvider): void {
window.location.href = `/admin/auth/providers/${provider.id}`;
}
async testProvider(provider: IAuthProvider): Promise<void> {
this.testing.set(provider.id);
try {
const result = await this.adminAuthService.testProvider(provider.id).toPromise();
if (result?.success) {
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
} else {
this.toastService.error(result?.error || 'Connection failed');
}
// Reload to get updated test results
await this.loadData();
} catch (error) {
this.toastService.error('Failed to test provider');
} finally {
this.testing.set(null);
}
}
confirmDelete(provider: IAuthProvider): void {
this.providerToDelete.set(provider);
}
async deleteProvider(): Promise<void> {
const provider = this.providerToDelete();
if (!provider) return;
this.deleting.set(true);
try {
await this.adminAuthService.deleteProvider(provider.id).toPromise();
this.toastService.success('Provider deleted');
this.providerToDelete.set(null);
await this.loadData();
} catch (error) {
this.toastService.error('Failed to delete provider');
} finally {
this.deleting.set(false);
}
}
async toggleLocalAuth(): Promise<void> {
const current = this.settings();
if (!current) return;
try {
await this.adminAuthService.updateSettings({
auth: { localAuthEnabled: !current.auth.localAuthEnabled },
}).toPromise();
this.toastService.success('Settings updated');
await this.loadData();
} catch (error) {
this.toastService.error('Failed to update settings');
}
}
async toggleRegistration(): Promise<void> {
const current = this.settings();
if (!current) return;
try {
await this.adminAuthService.updateSettings({
auth: { allowUserRegistration: !current.auth.allowUserRegistration },
}).toPromise();
this.toastService.success('Settings updated');
await this.loadData();
} catch (error) {
this.toastService.error('Failed to update settings');
}
}
async saveSettings(): Promise<void> {
this.savingSettings.set(true);
try {
await this.adminAuthService.updateSettings({
auth: {
sessionDurationMinutes: this.editingSettings.sessionDurationMinutes,
defaultProviderId: this.editingSettings.defaultProviderId,
},
}).toPromise();
this.toastService.success('Settings saved');
this.showSettingsModal.set(false);
await this.loadData();
} catch (error) {
this.toastService.error('Failed to save settings');
} finally {
this.savingSettings.set(false);
}
}
getProviderIconClass(type: string): string {
return type === 'oidc' ? 'bg-primary/10 text-primary' : 'bg-accent/10 text-accent';
}
getStatusBadgeClass(status: TAuthProviderStatus): string {
switch (status) {
case 'active':
return 'badge-accent';
case 'testing':
return 'badge-warning';
case 'disabled':
return 'badge-secondary';
default:
return 'badge-secondary';
}
}
formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} minutes`;
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} days`;
}
formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
}

View File

@@ -0,0 +1,705 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import {
AdminAuthService,
type IAuthProvider,
type ICreateAuthProviderDto,
type IUpdateAuthProviderDto,
type TAuthProviderType,
type TAuthProviderStatus,
} from '../../../core/services/admin-auth.service';
import { ToastService } from '../../../core/services/toast.service';
@Component({
selector: 'app-provider-form',
standalone: true,
imports: [FormsModule],
template: `
<div class="p-6 max-w-4xl mx-auto">
<div class="mb-6">
<div class="section-header mb-2">
<div class="section-indicator"></div>
<span class="section-label">Admin / Auth Providers</span>
</div>
<h1 class="font-mono text-2xl font-bold text-foreground">
{{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }}
</h1>
</div>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
} @else {
<form (ngSubmit)="saveProvider()" class="space-y-6">
<!-- Basic Info -->
<div class="card">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Basic Information</span>
</div>
</div>
<div class="card-content space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label block mb-1.5">Name (identifier)</label>
<input
type="text"
[(ngModel)]="form.name"
name="name"
class="input"
placeholder="google, azure-ad, corp-ldap"
required
[disabled]="isEditMode()"
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase, alphanumeric with hyphens</p>
</div>
<div>
<label class="label block mb-1.5">Display Name</label>
<input
type="text"
[(ngModel)]="form.displayName"
name="displayName"
class="input"
placeholder="Google SSO, Corporate LDAP"
required
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Shown on login page</p>
</div>
</div>
@if (isEditMode()) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label block mb-1.5">Status</label>
<select [(ngModel)]="form.status" name="status" class="input">
<option value="active">Active</option>
<option value="testing">Testing</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div>
<label class="label block mb-1.5">Priority</label>
<input
type="number"
[(ngModel)]="form.priority"
name="priority"
class="input"
min="0"
max="100"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Higher = shown first (0-100)</p>
</div>
</div>
}
</div>
</div>
<!-- OAuth Config -->
@if (providerType() === 'oidc') {
<div class="card">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">OAuth / OIDC Configuration</span>
</div>
</div>
<div class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Issuer URL</label>
<input
type="url"
[(ngModel)]="form.oauthConfig.issuer"
name="issuer"
class="input"
placeholder="https://accounts.google.com"
required
/>
<p class="font-mono text-xs text-muted-foreground mt-1">OIDC discovery endpoint base URL</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label block mb-1.5">Client ID</label>
<input
type="text"
[(ngModel)]="form.oauthConfig.clientId"
name="clientId"
class="input"
placeholder="your-client-id"
required
/>
</div>
<div>
<label class="label block mb-1.5">Client Secret</label>
<input
type="password"
[(ngModel)]="form.oauthConfig.clientSecretEncrypted"
name="clientSecret"
class="input"
[placeholder]="isEditMode() ? '••••••••' : 'your-client-secret'"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">
@if (isEditMode()) {
Leave empty to keep existing secret
} @else {
Will be encrypted at rest
}
</p>
</div>
</div>
<div>
<label class="label block mb-1.5">Scopes</label>
<input
type="text"
[(ngModel)]="scopesInput"
name="scopes"
class="input"
placeholder="openid profile email"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Space-separated OAuth scopes</p>
</div>
<div>
<label class="label block mb-1.5">Callback URL</label>
<div class="flex gap-2">
<input
type="text"
[value]="getCallbackUrl()"
class="input flex-1"
readonly
/>
<button type="button" (click)="copyCallbackUrl()" class="btn-secondary btn-md">Copy</button>
</div>
<p class="font-mono text-xs text-muted-foreground mt-1">Add this to your OAuth provider's allowed redirect URIs</p>
</div>
<!-- Advanced OAuth Settings -->
<details class="group">
<summary class="font-mono text-sm font-medium text-foreground cursor-pointer hover:text-primary">
Advanced Settings
</summary>
<div class="mt-4 space-y-4 pl-4 border-l border-border">
<div>
<label class="label block mb-1.5">Authorization URL (optional)</label>
<input
type="url"
[(ngModel)]="form.oauthConfig.authorizationUrl"
name="authorizationUrl"
class="input"
placeholder="Override OIDC discovery"
/>
</div>
<div>
<label class="label block mb-1.5">Token URL (optional)</label>
<input
type="url"
[(ngModel)]="form.oauthConfig.tokenUrl"
name="tokenUrl"
class="input"
placeholder="Override OIDC discovery"
/>
</div>
<div>
<label class="label block mb-1.5">User Info URL (optional)</label>
<input
type="url"
[(ngModel)]="form.oauthConfig.userInfoUrl"
name="userInfoUrl"
class="input"
placeholder="Override OIDC discovery"
/>
</div>
</div>
</details>
</div>
</div>
}
<!-- LDAP Config -->
@if (providerType() === 'ldap') {
<div class="card">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">LDAP Configuration</span>
</div>
</div>
<div class="card-content space-y-4">
<div>
<label class="label block mb-1.5">Server URL</label>
<input
type="text"
[(ngModel)]="form.ldapConfig.serverUrl"
name="serverUrl"
class="input"
placeholder="ldap://ldap.example.com:389"
required
/>
<p class="font-mono text-xs text-muted-foreground mt-1">LDAP or LDAPS protocol URL</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label block mb-1.5">Bind DN</label>
<input
type="text"
[(ngModel)]="form.ldapConfig.bindDn"
name="bindDn"
class="input"
placeholder="cn=admin,dc=example,dc=com"
required
/>
</div>
<div>
<label class="label block mb-1.5">Bind Password</label>
<input
type="password"
[(ngModel)]="form.ldapConfig.bindPasswordEncrypted"
name="bindPassword"
class="input"
[placeholder]="isEditMode() ? '••••••••' : 'your-bind-password'"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">
@if (isEditMode()) {
Leave empty to keep existing password
} @else {
Will be encrypted at rest
}
</p>
</div>
</div>
<div>
<label class="label block mb-1.5">Base DN</label>
<input
type="text"
[(ngModel)]="form.ldapConfig.baseDn"
name="baseDn"
class="input"
placeholder="ou=users,dc=example,dc=com"
required
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Base DN for user searches</p>
</div>
<div>
<label class="label block mb-1.5">User Search Filter</label>
<input
type="text"
[(ngModel)]="form.ldapConfig.userSearchFilter"
name="userSearchFilter"
class="input"
[placeholder]="'(uid=' + '{{' + 'username' + '}}' + ')'"
required
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Use double-brace username placeholder</p>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="form.ldapConfig.tlsEnabled"
name="tlsEnabled"
class="w-4 h-4 border-border text-primary focus:ring-primary"
/>
<span class="font-mono text-sm text-foreground">Enable TLS/StartTLS</span>
</label>
</div>
@if (form.ldapConfig.tlsEnabled) {
<div>
<label class="label block mb-1.5">CA Certificate (optional)</label>
<textarea
[(ngModel)]="form.ldapConfig.tlsCaCert"
name="tlsCaCert"
class="input min-h-[100px] font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
></textarea>
<p class="font-mono text-xs text-muted-foreground mt-1">PEM-encoded CA certificate for self-signed servers</p>
</div>
}
</div>
</div>
}
<!-- Attribute Mapping -->
<div class="card">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Attribute Mapping</span>
</div>
</div>
<div class="card-content space-y-4">
<p class="font-mono text-xs text-muted-foreground">
Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label block mb-1.5">Email</label>
<input
type="text"
[(ngModel)]="form.attributeMapping.email"
name="mapEmail"
class="input"
placeholder="email"
/>
</div>
<div>
<label class="label block mb-1.5">Username</label>
<input
type="text"
[(ngModel)]="form.attributeMapping.username"
name="mapUsername"
class="input"
[placeholder]="providerType() === 'oidc' ? 'preferred_username' : 'uid'"
/>
</div>
<div>
<label class="label block mb-1.5">Display Name</label>
<input
type="text"
[(ngModel)]="form.attributeMapping.displayName"
name="mapDisplayName"
class="input"
[placeholder]="providerType() === 'oidc' ? 'name' : 'cn'"
/>
</div>
<div>
<label class="label block mb-1.5">Avatar URL (optional)</label>
<input
type="text"
[(ngModel)]="form.attributeMapping.avatarUrl"
name="mapAvatarUrl"
class="input"
placeholder="picture"
/>
</div>
</div>
<div>
<label class="label block mb-1.5">Groups (optional)</label>
<input
type="text"
[(ngModel)]="form.attributeMapping.groups"
name="mapGroups"
class="input"
[placeholder]="providerType() === 'oidc' ? 'groups' : 'memberOf'"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">For future group sync functionality</p>
</div>
</div>
</div>
<!-- Provisioning Settings -->
<div class="card">
<div class="card-header">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">User Provisioning</span>
</div>
</div>
<div class="card-content space-y-4">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="form.provisioning.jitEnabled"
name="jitEnabled"
class="w-4 h-4 border-border text-primary focus:ring-primary"
/>
<span class="font-mono text-sm text-foreground">Just-in-Time Provisioning</span>
</label>
</div>
<p class="font-mono text-xs text-muted-foreground pl-6">
Automatically create user accounts on first login
</p>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input
type="checkbox"
[(ngModel)]="form.provisioning.autoLinkByEmail"
name="autoLinkByEmail"
class="w-4 h-4 border-border text-primary focus:ring-primary"
/>
<span class="font-mono text-sm text-foreground">Auto-Link by Email</span>
</label>
</div>
<p class="font-mono text-xs text-muted-foreground pl-6">
Automatically link to existing accounts with matching email addresses
</p>
<div>
<label class="label block mb-1.5">Allowed Email Domains (optional)</label>
<input
type="text"
[(ngModel)]="domainsInput"
name="allowedDomains"
class="input"
placeholder="example.com, corp.example.com"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">
Comma-separated. Leave empty to allow all domains.
</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center">
<button type="button" (click)="cancel()" class="btn-secondary btn-md">Cancel</button>
<div class="flex gap-3">
@if (isEditMode()) {
<button type="button" (click)="testConnection()" [disabled]="testing()" class="btn-secondary btn-md">
@if (testing()) {
Testing...
} @else {
Test Connection
}
</button>
}
<button type="submit" [disabled]="saving()" class="btn-primary btn-md">
@if (saving()) {
Saving...
} @else {
{{ isEditMode() ? 'Save Changes' : 'Create Provider' }}
}
</button>
</div>
</div>
</form>
}
</div>
`,
})
export class ProviderFormComponent implements OnInit {
private adminAuthService = inject(AdminAuthService);
private toastService = inject(ToastService);
private router = inject(Router);
private route = inject(ActivatedRoute);
loading = signal(true);
saving = signal(false);
testing = signal(false);
isEditMode = signal(false);
providerType = signal<TAuthProviderType>('oidc');
providerId = signal<string | null>(null);
scopesInput = 'openid profile email';
domainsInput = '';
form = {
name: '',
displayName: '',
status: 'testing' as TAuthProviderStatus,
priority: 0,
oauthConfig: {
clientId: '',
clientSecretEncrypted: '',
issuer: '',
authorizationUrl: '',
tokenUrl: '',
userInfoUrl: '',
scopes: ['openid', 'profile', 'email'],
callbackUrl: '',
},
ldapConfig: {
serverUrl: '',
bindDn: '',
bindPasswordEncrypted: '',
baseDn: '',
userSearchFilter: '(uid={{username}})',
tlsEnabled: false,
tlsCaCert: '',
},
attributeMapping: {
email: 'email',
username: 'preferred_username',
displayName: 'name',
avatarUrl: '',
groups: '',
},
provisioning: {
jitEnabled: true,
autoLinkByEmail: true,
allowedEmailDomains: [] as string[],
},
};
ngOnInit(): void {
// Check for edit mode
const id = this.route.snapshot.paramMap.get('id');
if (id && id !== 'new') {
this.isEditMode.set(true);
this.providerId.set(id);
this.loadProvider(id);
} else {
// New provider mode
const type = this.route.snapshot.queryParamMap.get('type') as TAuthProviderType;
if (type && (type === 'oidc' || type === 'ldap')) {
this.providerType.set(type);
this.setDefaultMappings(type);
}
this.loading.set(false);
}
}
private async loadProvider(id: string): Promise<void> {
try {
const provider = await this.adminAuthService.getProvider(id).toPromise();
if (provider) {
this.providerType.set(provider.type);
this.form.name = provider.name;
this.form.displayName = provider.displayName;
this.form.status = provider.status;
this.form.priority = provider.priority;
if (provider.oauthConfig) {
this.form.oauthConfig = {
...this.form.oauthConfig,
...provider.oauthConfig,
clientSecretEncrypted: '', // Don't show encrypted secret
};
this.scopesInput = provider.oauthConfig.scopes.join(' ');
}
if (provider.ldapConfig) {
this.form.ldapConfig = {
...this.form.ldapConfig,
...provider.ldapConfig,
bindPasswordEncrypted: '', // Don't show encrypted password
};
}
if (provider.attributeMapping) {
this.form.attributeMapping = { ...this.form.attributeMapping, ...provider.attributeMapping };
}
if (provider.provisioning) {
this.form.provisioning = { ...this.form.provisioning, ...provider.provisioning };
this.domainsInput = provider.provisioning.allowedEmailDomains?.join(', ') || '';
}
}
} catch (error) {
this.toastService.error('Failed to load provider');
this.router.navigate(['/admin/auth']);
} finally {
this.loading.set(false);
}
}
private setDefaultMappings(type: TAuthProviderType): void {
if (type === 'ldap') {
this.form.attributeMapping = {
email: 'mail',
username: 'uid',
displayName: 'cn',
avatarUrl: '',
groups: 'memberOf',
};
this.form.ldapConfig.userSearchFilter = '(uid={{username}})';
}
}
getCallbackUrl(): string {
const baseUrl = window.location.origin;
const providerName = this.form.name || '{provider-name}';
return `${baseUrl}/api/v1/auth/oauth/${providerName}/callback`;
}
copyCallbackUrl(): void {
navigator.clipboard.writeText(this.getCallbackUrl());
this.toastService.success('Callback URL copied');
}
async saveProvider(): Promise<void> {
// Parse scopes and domains
this.form.oauthConfig.scopes = this.scopesInput.split(/\s+/).filter(Boolean);
this.form.provisioning.allowedEmailDomains = this.domainsInput
.split(',')
.map((d) => d.trim())
.filter(Boolean);
this.saving.set(true);
try {
if (this.isEditMode()) {
// Update existing provider
const dto: IUpdateAuthProviderDto = {
displayName: this.form.displayName,
status: this.form.status,
priority: this.form.priority,
attributeMapping: this.form.attributeMapping,
provisioning: this.form.provisioning,
};
if (this.providerType() === 'oidc') {
dto.oauthConfig = { ...this.form.oauthConfig };
// Only include secret if changed
if (!dto.oauthConfig.clientSecretEncrypted) {
delete dto.oauthConfig.clientSecretEncrypted;
}
} else {
dto.ldapConfig = { ...this.form.ldapConfig };
// Only include password if changed
if (!dto.ldapConfig.bindPasswordEncrypted) {
delete dto.ldapConfig.bindPasswordEncrypted;
}
}
await this.adminAuthService.updateProvider(this.providerId()!, dto).toPromise();
this.toastService.success('Provider updated');
} else {
// Create new provider
const dto: ICreateAuthProviderDto = {
name: this.form.name,
displayName: this.form.displayName,
type: this.providerType(),
attributeMapping: this.form.attributeMapping,
provisioning: this.form.provisioning,
};
if (this.providerType() === 'oidc') {
dto.oauthConfig = {
...this.form.oauthConfig,
callbackUrl: this.getCallbackUrl(),
};
} else {
dto.ldapConfig = this.form.ldapConfig;
}
await this.adminAuthService.createProvider(dto).toPromise();
this.toastService.success('Provider created');
}
this.router.navigate(['/admin/auth']);
} catch (error: any) {
const message = error?.error?.error || 'Failed to save provider';
this.toastService.error(message);
} finally {
this.saving.set(false);
}
}
async testConnection(): Promise<void> {
if (!this.providerId()) return;
this.testing.set(true);
try {
const result = await this.adminAuthService.testProvider(this.providerId()!).toPromise();
if (result?.success) {
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
} else {
this.toastService.error(result?.error || 'Connection failed');
}
} catch (error) {
this.toastService.error('Failed to test connection');
} finally {
this.testing.set(false);
}
}
cancel(): void {
this.router.navigate(['/admin/auth']);
}
}

View File

@@ -1,8 +1,23 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AuthService } from '../../core/services/auth.service';
import { ToastService } from '../../core/services/toast.service';
import { firstValueFrom } from 'rxjs';
interface IPublicProvider {
id: string;
name: string;
displayName: string;
type: 'oidc' | 'ldap';
}
interface IProvidersResponse {
providers: IPublicProvider[];
localAuthEnabled: boolean;
defaultProviderId?: string;
}
@Component({
selector: 'app-login',
@@ -22,68 +37,202 @@ import { ToastService } from '../../core/services/toast.service';
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
</div>
<!-- Login form -->
<form (ngSubmit)="login()" class="card p-6 space-y-6">
<!-- Terminal header -->
<div class="code-header -mx-6 -mt-6 mb-6">
<div class="terminal-dot dot-red"></div>
<div class="terminal-dot dot-orange"></div>
<div class="terminal-dot dot-green"></div>
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
@if (loadingProviders()) {
<div class="card p-6 flex items-center justify-center">
<svg class="animate-spin h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<div class="space-y-4">
<div>
<label for="email" class="label block mb-1.5">Email</label>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
class="input"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
<div>
<label for="password" class="label block mb-1.5">Password</label>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
class="input"
placeholder="Enter your password"
required
autocomplete="current-password"
/>
</div>
</div>
@if (error()) {
<div class="p-3 bg-destructive/10 border border-destructive/30">
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
} @else {
<!-- SSO Providers -->
@if (oauthProviders().length > 0) {
<div class="space-y-3 mb-6">
@for (provider of oauthProviders(); track provider.id) {
<button
(click)="loginWithOAuth(provider)"
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
>
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<span class="font-mono text-sm font-medium text-foreground">Continue with {{ provider.displayName }}</span>
</button>
}
</div>
}
<button
type="submit"
[disabled]="loading()"
class="btn-primary btn-md w-full"
>
@if (loading()) {
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<!-- LDAP Providers -->
@if (ldapProviders().length > 0 && !showLdapForm()) {
<div class="space-y-3 mb-6">
@for (provider of ldapProviders(); track provider.id) {
<button
(click)="selectLdapProvider(provider)"
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
>
<div class="w-10 h-10 bg-accent/10 flex items-center justify-center">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<span class="font-mono text-sm font-medium text-foreground">Sign in with {{ provider.displayName }}</span>
</button>
}
</div>
}
<!-- LDAP Login Form -->
@if (showLdapForm() && selectedLdapProvider()) {
<form (ngSubmit)="loginWithLdap()" class="card p-6 space-y-6 mb-6">
<div class="code-header -mx-6 -mt-6 mb-6">
<div class="terminal-dot dot-red"></div>
<div class="terminal-dot dot-orange"></div>
<div class="terminal-dot dot-green"></div>
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">{{ selectedLdapProvider()!.displayName }}</span>
</div>
<div class="space-y-4">
<div>
<label for="ldapUsername" class="label block mb-1.5">Username</label>
<input
type="text"
id="ldapUsername"
[(ngModel)]="ldapUsername"
name="ldapUsername"
class="input"
placeholder="your.username"
required
autocomplete="username"
/>
</div>
<div>
<label for="ldapPassword" class="label block mb-1.5">Password</label>
<input
type="password"
id="ldapPassword"
[(ngModel)]="ldapPassword"
name="ldapPassword"
class="input"
placeholder="Enter your password"
required
autocomplete="current-password"
/>
</div>
</div>
@if (ldapError()) {
<div class="p-3 bg-destructive/10 border border-destructive/30">
<p class="font-mono text-sm text-destructive">{{ ldapError() }}</p>
</div>
}
<div class="flex gap-3">
<button type="button" (click)="cancelLdap()" class="btn-secondary btn-md flex-1">
Back
</button>
<button type="submit" [disabled]="ldapLoading()" class="btn-primary btn-md flex-1">
@if (ldapLoading()) {
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
} @else {
Sign in
}
</button>
</div>
</form>
}
<!-- Divider -->
@if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) {
<div class="flex items-center gap-4 mb-6">
<div class="flex-1 border-t border-border"></div>
<span class="font-mono text-xs text-muted-foreground uppercase">or</span>
<div class="flex-1 border-t border-border"></div>
</div>
}
<!-- Local login form -->
@if (localAuthEnabled() && !showLdapForm()) {
<form (ngSubmit)="login()" class="card p-6 space-y-6">
<!-- Terminal header -->
<div class="code-header -mx-6 -mt-6 mb-6">
<div class="terminal-dot dot-red"></div>
<div class="terminal-dot dot-orange"></div>
<div class="terminal-dot dot-green"></div>
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">Sign In</span>
</div>
<div class="space-y-4">
<div>
<label for="email" class="label block mb-1.5">Email</label>
<input
type="email"
id="email"
[(ngModel)]="email"
name="email"
class="input"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
<div>
<label for="password" class="label block mb-1.5">Password</label>
<input
type="password"
id="password"
[(ngModel)]="password"
name="password"
class="input"
placeholder="Enter your password"
required
autocomplete="current-password"
/>
</div>
</div>
@if (error()) {
<div class="p-3 bg-destructive/10 border border-destructive/30">
<p class="font-mono text-sm text-destructive">{{ error() }}</p>
</div>
}
<button
type="submit"
[disabled]="loading()"
class="btn-primary btn-md w-full"
>
@if (loading()) {
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
} @else {
Sign in
}
</button>
</form>
}
<!-- No auth available message -->
@if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) {
<div class="card p-6 text-center">
<svg class="w-12 h-12 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Signing in...
} @else {
Sign in
}
</button>
</form>
<p class="font-mono text-sm text-muted-foreground">
No authentication methods available. Please contact your administrator.
</p>
</div>
}
}
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
Enterprise Package Registry
@@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';
</div>
`,
})
export class LoginComponent {
export class LoginComponent implements OnInit {
private authService = inject(AuthService);
private router = inject(Router);
private toastService = inject(ToastService);
private http = inject(HttpClient);
// Local login
email = '';
password = '';
loading = signal(false);
error = signal<string | null>(null);
// Providers
loadingProviders = signal(true);
localAuthEnabled = signal(true);
oauthProviders = signal<IPublicProvider[]>([]);
ldapProviders = signal<IPublicProvider[]>([]);
// LDAP form
showLdapForm = signal(false);
selectedLdapProvider = signal<IPublicProvider | null>(null);
ldapUsername = '';
ldapPassword = '';
ldapLoading = signal(false);
ldapError = signal<string | null>(null);
ngOnInit(): void {
// Check for error in URL params
const params = new URLSearchParams(window.location.search);
const errorParam = params.get('error');
if (errorParam) {
this.error.set(decodeURIComponent(errorParam));
}
this.loadProviders();
}
private async loadProviders(): Promise<void> {
try {
const response = await firstValueFrom(
this.http.get<IProvidersResponse>('/api/v1/auth/providers')
);
this.localAuthEnabled.set(response.localAuthEnabled);
this.oauthProviders.set(response.providers.filter((p) => p.type === 'oidc'));
this.ldapProviders.set(response.providers.filter((p) => p.type === 'ldap'));
// Auto-redirect to default provider if configured
if (response.defaultProviderId && !this.error()) {
const defaultProvider = response.providers.find((p) => p.id === response.defaultProviderId);
if (defaultProvider) {
if (defaultProvider.type === 'oidc') {
this.loginWithOAuth(defaultProvider);
return;
} else if (defaultProvider.type === 'ldap') {
this.selectLdapProvider(defaultProvider);
}
}
}
} catch (error) {
// If providers endpoint fails, show local auth
console.error('Failed to load providers:', error);
} finally {
this.loadingProviders.set(false);
}
}
async login(): Promise<void> {
if (!this.email || !this.password) {
this.error.set('Please enter your email and password');
@@ -126,4 +332,62 @@ export class LoginComponent {
this.loading.set(false);
}
}
loginWithOAuth(provider: IPublicProvider): void {
// Redirect to OAuth authorization endpoint
const returnUrl = encodeURIComponent(window.location.origin + '/dashboard');
window.location.href = `/api/v1/auth/oauth/${provider.id}/authorize?returnUrl=${returnUrl}`;
}
selectLdapProvider(provider: IPublicProvider): void {
this.selectedLdapProvider.set(provider);
this.showLdapForm.set(true);
this.ldapUsername = '';
this.ldapPassword = '';
this.ldapError.set(null);
}
cancelLdap(): void {
this.showLdapForm.set(false);
this.selectedLdapProvider.set(null);
}
async loginWithLdap(): Promise<void> {
const provider = this.selectedLdapProvider();
if (!provider || !this.ldapUsername || !this.ldapPassword) {
this.ldapError.set('Please enter your username and password');
return;
}
this.ldapLoading.set(true);
this.ldapError.set(null);
try {
const response = await firstValueFrom(
this.http.post<{
user: { id: string; email: string; username: string; displayName: string; isSystemAdmin: boolean };
accessToken: string;
refreshToken: string;
sessionId: string;
}>(`/api/v1/auth/ldap/${provider.id}/login`, {
username: this.ldapUsername,
password: this.ldapPassword,
})
);
this.authService.handleOAuthCallback(
response.accessToken,
response.refreshToken,
response.sessionId
);
this.toastService.success('Welcome!');
this.router.navigate(['/dashboard']);
} catch (err: any) {
const message = err?.error?.error || 'Authentication failed';
this.ldapError.set(message);
} finally {
this.ldapLoading.set(false);
}
}
}

View File

@@ -0,0 +1,68 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-oauth-callback',
standalone: true,
template: `
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="max-w-md w-full text-center">
@if (error()) {
<div class="w-16 h-16 bg-destructive/10 flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Authentication Failed</h1>
<p class="font-mono text-sm text-muted-foreground mb-6">{{ error() }}</p>
<a href="/login" class="btn-primary btn-md">Back to Login</a>
} @else {
<div class="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4">
<svg class="animate-spin w-10 h-10 text-primary-foreground" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Signing you in...</h1>
<p class="font-mono text-sm text-muted-foreground">Please wait while we complete authentication</p>
}
</div>
</div>
`,
})
export class OAuthCallbackComponent implements OnInit {
private authService = inject(AuthService);
private router = inject(Router);
private toastService = inject(ToastService);
error = signal<string | null>(null);
ngOnInit(): void {
this.handleCallback();
}
private handleCallback(): void {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('accessToken');
const refreshToken = params.get('refreshToken');
const sessionId = params.get('sessionId');
const errorParam = params.get('error');
if (errorParam) {
this.error.set(decodeURIComponent(errorParam));
return;
}
if (!accessToken || !refreshToken || !sessionId) {
this.error.set('Missing authentication tokens');
return;
}
// Store the tokens and redirect
this.authService.handleOAuthCallback(accessToken, refreshToken, sessionId);
this.toastService.success('Welcome!');
this.router.navigate(['/dashboard']);
}
}

View File

@@ -11,7 +11,7 @@ import { ToastService } from '../../core/services/toast.service';
<div class="p-6 max-w-7xl mx-auto">
@if (loading()) {
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
@@ -20,33 +20,36 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div class="flex items-center gap-4">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
<div class="w-16 h-16 bg-muted flex items-center justify-center">
<span class="font-mono text-2xl font-medium text-muted-foreground">
{{ organization()!.name.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1>
<p class="text-gray-500 dark:text-gray-400">&#64;{{ organization()!.name }}</p>
<h1 class="font-mono text-2xl font-bold text-foreground">{{ organization()!.displayName }}</h1>
<p class="font-mono text-muted-foreground">&#64;{{ organization()!.name }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if (organization()!.isPublic) {
<span class="badge-default">Public</span>
<span class="badge-accent">Public</span>
} @else {
<span class="badge-warning">Private</span>
<span class="badge-primary">Private</span>
}
</div>
</div>
@if (organization()!.description) {
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p>
<p class="font-mono text-muted-foreground mb-8">{{ organization()!.description }}</p>
}
<!-- Repositories Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2>
<div class="section-header">
<div class="section-indicator"></div>
<span class="section-label">Repositories</span>
</div>
<button class="btn-primary btn-sm">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
@@ -57,26 +60,26 @@ import { ToastService } from '../../core/services/toast.service';
@if (repositories().length === 0) {
<div class="card card-content text-center py-8">
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p>
<p class="font-mono text-muted-foreground">No repositories yet</p>
</div>
} @else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (repo of repositories(); track repo.id) {
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary/50 transition-colors">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p>
<h3 class="font-mono font-medium text-foreground">{{ repo.displayName }}</h3>
<p class="font-mono text-sm text-muted-foreground">{{ repo.name }}</p>
</div>
@if (repo.isPublic) {
<span class="badge-default">Public</span>
<span class="badge-accent">Public</span>
}
</div>
@if (repo.description) {
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p>
<p class="font-mono text-sm text-muted-foreground mt-2 line-clamp-2">{{ repo.description }}</p>
}
<div class="mt-3 flex items-center gap-4">
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1 font-mono text-sm text-muted-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
@@ -95,18 +98,24 @@ import { ToastService } from '../../core/services/toast.service';
</div>
<!-- Stats -->
<div class="mb-4">
<div class="section-header">
<div class="section-indicator"></div>
<span class="section-label">Statistics</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p>
<p class="font-mono text-sm text-muted-foreground">Members</p>
<p class="font-mono text-2xl font-bold text-foreground">{{ organization()!.memberCount }}</p>
</div>
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p>
<p class="font-mono text-sm text-muted-foreground">Repositories</p>
<p class="font-mono text-2xl font-bold text-foreground">{{ repositories().length }}</p>
</div>
<div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p>
<p class="font-mono text-sm text-muted-foreground">Created</p>
<p class="font-mono text-2xl font-bold text-foreground">{{ formatDate(organization()!.createdAt) }}</p>
</div>
</div>
}
@@ -123,18 +132,18 @@ export class OrganizationDetailComponent implements OnInit {
loading = signal(true);
ngOnInit(): void {
const orgId = this.route.snapshot.paramMap.get('orgId');
if (orgId) {
this.loadData(orgId);
const orgName = this.route.snapshot.paramMap.get('orgName');
if (orgName) {
this.loadData(orgName);
}
}
private async loadData(orgId: string): Promise<void> {
private async loadData(orgName: string): Promise<void> {
this.loading.set(true);
try {
const [org, reposResponse] = await Promise.all([
this.apiService.getOrganization(orgId).toPromise(),
this.apiService.getRepositories(orgId).toPromise(),
this.apiService.getOrganization(orgName).toPromise(),
this.apiService.getRepositories(orgName).toPromise(),
]);
this.organization.set(org || null);
this.repositories.set(reposResponse?.repositories || []);

View File

@@ -47,7 +47,7 @@ import { ToastService } from '../../core/services/toast.service';
} @else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (org of organizations(); track org.id) {
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary/50 transition-colors">
<a [routerLink]="['/organizations', org.name]" class="card hover:border-primary/50 transition-colors">
<div class="card-content">
<div class="flex items-start gap-4">
<div class="w-12 h-12 bg-muted flex items-center justify-center flex-shrink-0">
@@ -84,8 +84,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div class="card w-full max-w-md mx-4">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
@@ -105,20 +105,20 @@ import { ToastService } from '../../core/services/toast.service';
[(ngModel)]="newOrg.name"
name="name"
class="input"
placeholder="my-organization"
placeholder="push.rocks"
required
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
pattern="^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$"
/>
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, and hyphens only</p>
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, hyphens, and dots (e.g., push.rocks)</p>
</div>
<div>
<label class="label block mb-1.5">Display Name</label>
<label class="label block mb-1.5">Display Name (optional)</label>
<input
type="text"
[(ngModel)]="newOrg.displayName"
name="displayName"
class="input"
placeholder="My Organization"
placeholder="Defaults to name if empty"
/>
</div>
<div>
@@ -139,6 +139,11 @@ import { ToastService } from '../../core/services/toast.service';
class="w-4 h-4 border-border text-primary focus:ring-primary"
/>
<label for="isPublic" class="font-mono text-sm text-foreground">Make this organization public</label>
<button type="button" (click)="showPublicExplainer.set(true)" class="btn-ghost p-0 h-5 w-5 text-muted-foreground hover:text-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</form>
<div class="card-footer flex justify-end gap-3">
@@ -154,6 +159,52 @@ import { ToastService } from '../../core/services/toast.service';
</div>
</div>
}
<!-- Public/Private Explainer Modal -->
@if (showPublicExplainer()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Organization Visibility</span>
</div>
<button (click)="showPublicExplainer.set(false)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-accent/10 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Public Organization</h4>
<p class="font-mono text-sm text-muted-foreground">Anyone can view this organization and its public repositories. Useful for open-source projects or public packages.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Private Organization</h4>
<p class="font-mono text-sm text-muted-foreground">Only organization members can see this organization and access its repositories. Best for internal or proprietary packages.</p>
</div>
</div>
</div>
<div class="card-footer flex justify-end">
<button (click)="showPublicExplainer.set(false)" class="btn-primary btn-md">Got it</button>
</div>
</div>
</div>
}
</div>
`,
})
@@ -164,6 +215,7 @@ export class OrganizationsComponent implements OnInit {
organizations = signal<IOrganization[]>([]);
loading = signal(true);
showCreateModal = signal(false);
showPublicExplainer = signal(false);
creating = signal(false);
newOrg = {

View File

@@ -1,9 +1,15 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ApiService, type IToken } from '../../core/services/api.service';
import { ApiService, type IToken, type ITokenScope, type IOrganization } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
interface IScopeEntry {
protocol: string;
actions: string[];
organizationId?: string;
}
@Component({
selector: 'app-tokens',
standalone: true,
@@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service';
@for (token of tokens(); track token.id) {
<li class="px-6 py-4 hover:bg-muted/30 transition-colors">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
@for (protocol of token.protocols.slice(0, 3); track protocol) {
<span class="badge-accent">{{ protocol }}</span>
}
@if (token.protocols.length > 3) {
<span class="badge-default">+{{ token.protocols.length - 3 }}</span>
@if (token.organizationId) {
<span class="badge-primary">Org Token</span>
} @else {
<span class="badge-default">Personal</span>
}
</div>
<p class="font-mono text-sm text-muted-foreground mt-1">
<!-- Scope summary -->
<div class="flex flex-wrap gap-1.5 mt-2">
@for (scope of token.scopes?.slice(0, 4) || []; track $index) {
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-muted border border-border">
<span class="font-semibold">{{ scope.protocol === '*' ? 'All' : scope.protocol }}</span>
<span class="text-muted-foreground">{{ formatActions(scope.actions) }}</span>
</span>
}
@if ((token.scopes?.length || 0) > 4) {
<span class="badge-default text-xs">+{{ (token.scopes?.length || 0) - 4 }} more</span>
}
</div>
<p class="font-mono text-sm text-muted-foreground mt-2">
<code>{{ token.tokenPrefix }}...</code>
@if (token.expiresAt) {
<span class="mx-2">·</span>
@@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service';
· {{ token.usageCount }} uses
</p>
</div>
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10">
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10 ml-4">
Revoke
</button>
</div>
@@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Create Modal -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div class="card w-full max-w-lg mx-4">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8 modal-backdrop">
<div class="card w-full max-w-2xl mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
@@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div class="card-content space-y-5">
<!-- Token Name -->
<div>
<label class="label block mb-1.5">Token Name</label>
<input
@@ -108,27 +126,134 @@ import { ToastService } from '../../core/services/toast.service';
placeholder="my-ci-token"
/>
</div>
<!-- Token Type -->
<div>
<label class="label block mb-1.5">Protocols</label>
<div class="flex flex-wrap gap-2">
@for (protocol of availableProtocols; track protocol) {
<label class="label block mb-1.5">Token Type</label>
<div class="flex gap-3">
<label
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
[ngClass]="{
'bg-primary/10 border-primary': !newToken.organizationId,
'border-border': newToken.organizationId
}">
<input
type="radio"
name="tokenType"
[checked]="!newToken.organizationId"
(change)="newToken.organizationId = undefined"
class="sr-only"
/>
<div>
<div class="font-mono text-sm font-medium text-foreground">Personal</div>
<div class="font-mono text-xs text-muted-foreground">For your personal use</div>
</div>
</label>
@if (organizations().length > 0) {
<label
class="flex items-center gap-2 px-3 py-1.5 border cursor-pointer hover:bg-muted/30 transition-colors font-mono text-sm"
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
[ngClass]="{
'bg-primary/10 border-primary text-primary': newToken.protocols.includes(protocol),
'border-border text-foreground': !newToken.protocols.includes(protocol)
'bg-primary/10 border-primary': newToken.organizationId,
'border-border': !newToken.organizationId
}">
<input
type="checkbox"
[checked]="newToken.protocols.includes(protocol)"
(change)="toggleProtocol(protocol)"
type="radio"
name="tokenType"
[checked]="newToken.organizationId"
(change)="newToken.organizationId = organizations()[0]?.id"
class="sr-only"
/>
<span>{{ protocol }}</span>
<div>
<div class="font-mono text-sm font-medium text-foreground">Organization</div>
<div class="font-mono text-xs text-muted-foreground">Shared with org members</div>
</div>
</label>
}
</div>
@if (newToken.organizationId) {
<select [(ngModel)]="newToken.organizationId" class="input mt-2">
@for (org of organizations(); track org.id) {
<option [value]="org.id">{{ org.displayName || org.name }}</option>
}
</select>
}
</div>
<!-- Scopes -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="label">Scopes</label>
<button (click)="addScope()" class="btn-ghost btn-sm text-primary">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Scope
</button>
</div>
@if (newToken.scopes.length === 0) {
<div class="p-4 border border-dashed border-border text-center">
<p class="font-mono text-sm text-muted-foreground">No scopes defined. Add at least one scope.</p>
</div>
} @else {
<div class="space-y-3">
@for (scope of newToken.scopes; track $index; let i = $index) {
<div class="p-4 border border-border bg-muted/20">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-3">
<!-- Protocol -->
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Protocol</label>
<select [(ngModel)]="scope.protocol" class="input flex-1">
<option value="*">All Protocols</option>
@for (protocol of availableProtocols; track protocol) {
<option [value]="protocol">{{ protocol }}</option>
}
</select>
</div>
<!-- Actions -->
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Actions</label>
<div class="flex gap-4">
@for (action of availableActions; track action) {
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
[checked]="scope.actions.includes(action)"
(change)="toggleAction(scope, action)"
class="form-checkbox"
/>
<span class="font-mono text-sm text-foreground capitalize">{{ action }}</span>
</label>
}
</div>
</div>
<!-- Limit to Org -->
@if (organizations().length > 0 && !newToken.organizationId) {
<div class="flex items-center gap-4">
<label class="font-mono text-xs text-muted-foreground w-20">Limit to</label>
<select [(ngModel)]="scope.organizationId" class="input flex-1">
<option [ngValue]="undefined">Any Organization</option>
@for (org of organizations(); track org.id) {
<option [value]="org.id">{{ org.displayName || org.name }}</option>
}
</select>
</div>
}
</div>
<button (click)="removeScope(i)" class="btn-ghost btn-sm p-1 text-muted-foreground hover:text-destructive">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
}
</div>
}
</div>
<!-- Expiration -->
<div>
<label class="label block mb-1.5">Expiration (optional)</label>
<select [(ngModel)]="newToken.expiresInDays" class="input">
@@ -142,7 +267,10 @@ import { ToastService } from '../../core/services/toast.service';
</div>
<div class="card-footer flex justify-end gap-3">
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button>
<button (click)="createToken()" [disabled]="creating() || !newToken.name || newToken.protocols.length === 0" class="btn-primary btn-md">
<button
(click)="createToken()"
[disabled]="creating() || !newToken.name || newToken.scopes.length === 0 || !hasValidScopes()"
class="btn-primary btn-md">
@if (creating()) {
Creating...
} @else {
@@ -156,8 +284,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Token Created Modal -->
@if (createdToken()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div class="card w-full max-w-lg mx-4">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-lg mx-4 modal-content">
<div class="card-header">
<div class="section-header">
<div class="section-indicator bg-accent"></div>
@@ -196,54 +324,86 @@ export class TokensComponent implements OnInit {
private toastService = inject(ToastService);
tokens = signal<IToken[]>([]);
organizations = signal<IOrganization[]>([]);
loading = signal(true);
showCreateModal = signal(false);
creating = signal(false);
createdToken = signal<string | null>(null);
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
availableActions = ['read', 'write', 'delete'];
newToken = {
newToken: {
name: string;
organizationId?: string;
scopes: IScopeEntry[];
expiresInDays: number | null;
} = {
name: '',
protocols: [] as string[],
expiresInDays: null as number | null,
organizationId: undefined,
scopes: [],
expiresInDays: null,
};
ngOnInit(): void {
this.loadTokens();
this.loadData();
}
private async loadTokens(): Promise<void> {
private async loadData(): Promise<void> {
this.loading.set(true);
try {
const response = await this.apiService.getTokens().toPromise();
this.tokens.set(response?.tokens || []);
const [tokensRes, orgsRes] = await Promise.all([
this.apiService.getTokens().toPromise(),
this.apiService.getOrganizations().toPromise(),
]);
this.tokens.set(tokensRes?.tokens || []);
this.organizations.set(orgsRes?.organizations || []);
} catch (error) {
this.toastService.error('Failed to load tokens');
this.toastService.error('Failed to load data');
} finally {
this.loading.set(false);
}
}
toggleProtocol(protocol: string): void {
if (this.newToken.protocols.includes(protocol)) {
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol);
addScope(): void {
this.newToken.scopes = [
...this.newToken.scopes,
{ protocol: '*', actions: ['read', 'write'] },
];
}
removeScope(index: number): void {
this.newToken.scopes = this.newToken.scopes.filter((_, i) => i !== index);
}
toggleAction(scope: IScopeEntry, action: string): void {
if (scope.actions.includes(action)) {
scope.actions = scope.actions.filter((a) => a !== action);
} else {
this.newToken.protocols = [...this.newToken.protocols, protocol];
scope.actions = [...scope.actions, action];
}
}
hasValidScopes(): boolean {
return this.newToken.scopes.every((s) => s.protocol && s.actions.length > 0);
}
async createToken(): Promise<void> {
if (!this.newToken.name || this.newToken.protocols.length === 0) return;
if (!this.newToken.name || this.newToken.scopes.length === 0 || !this.hasValidScopes()) return;
this.creating.set(true);
try {
// Build protocols array from scopes
const protocols = [...new Set(this.newToken.scopes.map((s) => s.protocol))];
const response = await this.apiService.createToken({
name: this.newToken.name,
protocols: this.newToken.protocols,
scopes: this.newToken.protocols.map((p) => ({
protocol: p,
actions: ['read', 'write'],
organizationId: this.newToken.organizationId,
protocols,
scopes: this.newToken.scopes.map((s) => ({
protocol: s.protocol,
actions: s.actions,
organizationId: s.organizationId,
})),
expiresInDays: this.newToken.expiresInDays || undefined,
}).toPromise();
@@ -252,7 +412,7 @@ export class TokensComponent implements OnInit {
this.createdToken.set(response.token);
this.tokens.update((tokens) => [response, ...tokens]);
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
this.resetNewToken();
}
} catch (error) {
this.toastService.error('Failed to create token');
@@ -275,7 +435,16 @@ export class TokensComponent implements OnInit {
closeCreateModal(): void {
this.showCreateModal.set(false);
this.newToken = { name: '', protocols: [], expiresInDays: null };
this.resetNewToken();
}
private resetNewToken(): void {
this.newToken = {
name: '',
organizationId: undefined,
scopes: [],
expiresInDays: null,
};
}
copyToken(): void {
@@ -289,4 +458,9 @@ export class TokensComponent implements OnInit {
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatActions(actions: string[]): string {
if (actions.includes('*')) return 'full';
return actions.join(', ');
}
}

View File

@@ -1,7 +1,6 @@
import { Component, computed, inject } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
import { ToastService } from '../../../core/services/toast.service';
@Component({
selector: 'app-layout',
@@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service';
</svg>
Settings
</a>
<!-- Admin Section -->
@if (isAdmin()) {
<div class="pt-4 mt-4 border-t border-border">
<p class="px-3 mb-2 font-mono text-xs text-muted-foreground uppercase tracking-wider">Administration</p>
<a routerLink="/admin/auth" routerLinkActive="bg-primary/10 text-primary"
class="nav-link">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Authentication
</a>
</div>
}
</nav>
<!-- User section -->
@@ -108,6 +121,7 @@ export class LayoutComponent {
const name = this.authService.user()?.displayName || 'U';
return name.charAt(0).toUpperCase();
});
isAdmin = computed(() => this.authService.isAdmin());
logout(): void {
this.authService.logout();

Some files were not shown because too many files have changed in this diff Show More