13 Commits

Author SHA1 Message Date
aacf30e582 v1.5.0
Some checks failed
Release / build-and-release (push) Failing after 22s
2026-03-20 16:43:44 +00:00
d4f758ce0f feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend 2026-03-20 16:43:44 +00:00
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
181 changed files with 23088 additions and 13700 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 ""

15
.gitignore vendored
View File

@@ -4,11 +4,14 @@ node_modules/
# Build outputs
dist/
.angular/
out-tsc/
# tsdeno temporary files
package.json.bak
# Generated files
ts/embedded-ui.generated.ts
ts_bundled/
dist_ts_web/
# Deno
.deno/
@@ -45,18 +48,20 @@ coverage/
*.tmp
*.temp
# Playwright MCP
.playwright-mcp/
# Debug
.nogit/
# Claude
CLAUDE.md
.claude/
stories/
# Package manager locks (keep pnpm-lock.yaml)
package-lock.json
yarn.lock
# Angular cache
.angular/cache/
# TypeScript incremental compilation
*.tsbuildinfo

View File

@@ -1,24 +1,121 @@
# Changelog
## 2026-03-20 - 1.5.0 - feat(opsserver,web)
replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
- add a new OpsServer with TypedRequest handlers for auth, organizations, repositories, packages, tokens, audit, admin, OAuth, and user settings flows
- introduce shared TypedRequest contracts under ts_interfaces and wire the registry to serve POST /typedrequest requests
- replace the embedded Angular build pipeline with tsbundle/tswatch-based web bundling, static html entrypoint, and new ts_web app state and shell views
- remove the legacy Angular frontend, custom UI bundler script, reload websocket hot-reload path, and related build configuration
## 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
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated embedded UI file.
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server
restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and
compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated
embedded UI file.
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected clients for hot-reload.
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS and trigger page reloads with exponential reconnect.
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected
clients for hot-reload.
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at
/ws/reload.
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS
and trigger page reloads with exponential reconnect.
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to
index.html.
- Ignore generated embedded UI file (ts/embedded-ui.generated.ts) in .gitignore.
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and a release task to bundle + compile.
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and
a release task to bundle + compile.
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task
bundle-ui:watch).
## 2025-11-28 - 1.0.1 - fix(smartdata)
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
- Updated deno.json imports mapping for @push.rocks/smartdata from ^7.0.9 to ^7.0.13
## 2025-11-28 - 1.0.0 - Initial release
Release with core features, UI, and project scaffolding.
- Implemented account settings and API tokens management.
@@ -37,8 +134,10 @@ Release with core features, UI, and project scaffolding.
- Dependency updates and fixes.
## 2025-11-27 - 2025-11-28 - unknown -> 1.0.0 - housekeeping / duplicate commits
Minor housekeeping and duplicate commits consolidated into the 1.0.0 release.
- Added initial README with project overview, features, and setup instructions.
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown version) into the 1.0.0 release.
- Miscellaneous UI tweaks and dependency updates.
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown
version) into the 1.0.0 release.
- Miscellaneous UI tweaks and dependency updates.

View File

@@ -1,39 +1,45 @@
{
"name": "@stack.gallery/registry",
"version": "1.1.0",
"version": "1.5.0",
"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",
"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"
"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-ui": "npx tsbundle",
"watch": "npx tswatch",
"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/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.3",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.1.10",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^3.0.53",
"@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"

4464
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,38 @@
# Stack.Gallery Design System
Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange/green accent colors.
Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange/green accent
colors.
## Colors (HSL)
### Dark Theme (Default)
| Token | HSL | Hex | Usage |
|--------------------|---------------|---------|------------------------------------|
| background | 0 0% 0% | #000000 | Page background |
| foreground | 0 0% 100% | #FFFFFF | Primary text |
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
| primary-foreground | 0 0% 0% | #000000 | Text on primary buttons |
| accent | 142 71% 45% | #22C55E | Terminal green, success states |
| accent-foreground | 0 0% 0% | #000000 | Text on accent |
| muted | 0 0% 8% | #141414 | Subtle backgrounds |
| muted-foreground | 0 0% 55% | #8C8C8C | Secondary text, labels |
| card | 0 0% 4% | #0A0A0A | Card backgrounds |
| border | 0 0% 15% | #262626 | All borders, dividers |
| destructive | 0 84% 60% | #EF4444 | Errors, terminal red dots |
| Token | HSL | Hex | Usage |
| ------------------ | ----------- | ------- | ---------------------------------- |
| background | 0 0% 0% | #000000 | Page background |
| foreground | 0 0% 100% | #FFFFFF | Primary text |
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
| primary-foreground | 0 0% 0% | #000000 | Text on primary buttons |
| accent | 142 71% 45% | #22C55E | Terminal green, success states |
| accent-foreground | 0 0% 0% | #000000 | Text on accent |
| muted | 0 0% 8% | #141414 | Subtle backgrounds |
| muted-foreground | 0 0% 55% | #8C8C8C | Secondary text, labels |
| card | 0 0% 4% | #0A0A0A | Card backgrounds |
| border | 0 0% 15% | #262626 | All borders, dividers |
| destructive | 0 84% 60% | #EF4444 | Errors, terminal red dots |
### Light Theme
| Token | HSL | Hex |
|------------------|---------------|---------|
| background | 0 0% 100% | #FFFFFF |
| foreground | 0 0% 5% | #0D0D0D |
| primary | 33 100% 45% | #E67300 |
| accent | 142 71% 35% | #16A34A |
| muted | 0 0% 96% | #F5F5F5 |
| muted-foreground | 0 0% 40% | #666666 |
| card | 0 0% 98% | #FAFAFA |
| border | 0 0% 90% | #E5E5E5 |
| Token | HSL | Hex |
| ---------------- | ----------- | ------- |
| background | 0 0% 100% | #FFFFFF |
| foreground | 0 0% 5% | #0D0D0D |
| primary | 33 100% 45% | #E67300 |
| accent | 142 71% 35% | #16A34A |
| muted | 0 0% 96% | #F5F5F5 |
| muted-foreground | 0 0% 40% | #666666 |
| card | 0 0% 98% | #FAFAFA |
| border | 0 0% 90% | #E5E5E5 |
---
@@ -52,7 +53,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
### Font Sizes
| Element | Size | Weight | Letter Spacing |
|-----------------|------------------------|--------|-----------------|
| --------------- | ---------------------- | ------ | --------------- |
| H1 (Hero) | 3rem / 4rem (md) | 700 | -0.02em (tight) |
| H2 (Section) | 1.5rem / 1.875rem (md) | 700 | -0.02em |
| H3 (Card title) | 0.875rem | 600 | normal |
@@ -72,7 +73,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
### Border Radius
```css
--radius: 0px; /* All elements: sharp corners */
--radius: 0px; /* All elements: sharp corners */
```
### Container

33
html/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<title>Stack.Gallery Registry</title>
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
</head>
<body>
<noscript>
<p style="color: #fff; text-align: center; margin-top: 100px">
JavaScript is required to run the Stack.Gallery Registry.
</p>
</noscript>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

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

103
npmextra.json Normal file
View File

@@ -0,0 +1,103 @@
{
"@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": {},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
]
},
"@git.zone/tswatch": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
],
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
"command": "deno run --allow-all mod.ts server --ephemeral",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
}
}

View File

@@ -1,15 +1,17 @@
{
"name": "@stack.gallery/registry",
"version": "1.1.0",
"version": "1.5.0",
"private": true,
"description": "Enterprise-grade multi-protocol package registry",
"type": "module",
"scripts": {
"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"
"watch": "tswatch",
"build": "deno task check",
"test": "deno task test",
"lint": "deno task lint",
"format": "deno task fmt"
},
"keywords": [
"registry",
@@ -24,11 +26,19 @@
],
"author": "Stack.Gallery",
"license": "MIT",
"devDependencies": {
"concurrently": "^9.1.2"
},
"dependencies": {
"@push.rocks/smartdata": "link:../../push.rocks/smartdata"
"@api.global/typedrequest": "^3.1.10",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.2",
"@design.estate/dees-catalog": "^3.43.0",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/smartguard": "^3.1.0",
"@stack.gallery/catalog": "file:../catalog"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsdeno": "^1.2.0",
"@git.zone/tswatch": "^3.1.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}

5193
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

490
readme.md
View File

@@ -1,232 +1,404 @@
# @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
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
For reporting bugs, issues, or security vulnerabilities, please visit
[community.foss.global/](https://community.foss.global/). This is the central community hub for all
issue reporting. Developers who sign and comply with our contribution agreement and go through
identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull
Requests directly.
## ✨ 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.4.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
├── ts_interfaces/ # Shared API contract (TypedRequest interfaces)
│ ├── data/ # Data types (auth, org, repo, package, token, audit, admin)
│ └── requests/ # Request/response interfaces for all API endpoints
└── 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 |
| Component | Technology |
| ----------------- | ------------------------------------------------------------------------------------ |
| **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.
**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
Task Venture Capital GmbH 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.
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

@@ -1,214 +0,0 @@
#!/usr/bin/env -S deno run --allow-all
/**
* UI Bundler Script
* Encodes all files from ui/dist/registry-ui/browser/ as base64
* and generates ts/embedded-ui.generated.ts
*
* Usage:
* deno task bundle-ui # One-time bundle
* deno task bundle-ui:watch # Watch mode for development
*/
import { walk } from 'jsr:@std/fs@1/walk';
import { extname, relative } from 'jsr:@std/path@1';
import { encodeBase64 } from 'jsr:@std/encoding@1/base64';
const UI_DIST_PATH = './ui/dist/registry-ui/browser';
const OUTPUT_PATH = './ts/embedded-ui.generated.ts';
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'font/otf',
'.map': 'application/json',
'.txt': 'text/plain',
'.xml': 'application/xml',
'.webp': 'image/webp',
'.webmanifest': 'application/manifest+json',
};
interface IEmbeddedFile {
path: string;
base64: string;
contentType: string;
size: number;
}
async function bundleUI(): Promise<void> {
console.log('[bundle-ui] Starting UI bundling...');
console.log(`[bundle-ui] Source: ${UI_DIST_PATH}`);
console.log(`[bundle-ui] Output: ${OUTPUT_PATH}`);
// Check if UI dist exists
try {
await Deno.stat(UI_DIST_PATH);
} catch {
console.error(`[bundle-ui] ERROR: UI dist not found at ${UI_DIST_PATH}`);
console.error('[bundle-ui] Run "deno task build" first to build the UI');
Deno.exit(1);
}
const files: IEmbeddedFile[] = [];
let totalSize = 0;
// Walk through all files in the dist directory
for await (const entry of walk(UI_DIST_PATH, { includeFiles: true, includeDirs: false })) {
const relativePath = '/' + relative(UI_DIST_PATH, entry.path).replace(/\\/g, '/');
const ext = extname(entry.path).toLowerCase();
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
// Read file and encode as base64
const content = await Deno.readFile(entry.path);
const base64 = encodeBase64(content);
files.push({
path: relativePath,
base64,
contentType,
size: content.length,
});
totalSize += content.length;
console.log(`[bundle-ui] Encoded: ${relativePath} (${formatSize(content.length)})`);
}
// Sort files for consistent output
files.sort((a, b) => a.path.localeCompare(b.path));
// Generate TypeScript module
const tsContent = generateTypeScript(files, totalSize);
// Write output file
await Deno.writeTextFile(OUTPUT_PATH, tsContent);
console.log(`[bundle-ui] Generated ${OUTPUT_PATH}`);
console.log(`[bundle-ui] Total files: ${files.length}`);
console.log(`[bundle-ui] Total size: ${formatSize(totalSize)}`);
console.log(`[bundle-ui] Bundling complete!`);
}
function generateTypeScript(files: IEmbeddedFile[], totalSize: number): string {
const fileEntries = files
.map(
(f) =>
` ['${f.path}', { base64: '${f.base64}', contentType: '${f.contentType}' }]`
)
.join(',\n');
return `// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by scripts/bundle-ui.ts
// Total files: ${files.length}
// Total size: ${formatSize(totalSize)}
// Generated at: ${new Date().toISOString()}
interface IEmbeddedFile {
base64: string;
contentType: string;
}
const EMBEDDED_FILES: Map<string, IEmbeddedFile> = new Map([
${fileEntries}
]);
/**
* Get an embedded file by path
* @param path - The file path (e.g., '/index.html')
* @returns The file data and content type, or null if not found
*/
export function getEmbeddedFile(path: string): { data: Uint8Array; contentType: string } | null {
const file = EMBEDDED_FILES.get(path);
if (!file) return null;
// Decode base64 to Uint8Array
const binaryString = atob(file.base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return { data: bytes, contentType: file.contentType };
}
/**
* Check if an embedded file exists
* @param path - The file path to check
*/
export function hasEmbeddedFile(path: string): boolean {
return EMBEDDED_FILES.has(path);
}
/**
* List all embedded file paths
*/
export function listEmbeddedFiles(): string[] {
return Array.from(EMBEDDED_FILES.keys());
}
/**
* Get the total number of embedded files
*/
export function getEmbeddedFileCount(): number {
return EMBEDDED_FILES.size;
}
`;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
async function watchMode(): Promise<void> {
console.log('[bundle-ui] Starting watch mode...');
console.log(`[bundle-ui] Watching: ${UI_DIST_PATH}`);
console.log('[bundle-ui] Press Ctrl+C to stop');
console.log('');
// Initial bundle
await bundleUI();
// Watch for changes
const watcher = Deno.watchFs(UI_DIST_PATH);
let debounceTimer: number | null = null;
for await (const event of watcher) {
if (event.kind === 'modify' || event.kind === 'create' || event.kind === 'remove') {
// Debounce - wait 500ms after last change
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(async () => {
console.log('');
console.log(`[bundle-ui] Change detected: ${event.kind}`);
try {
await bundleUI();
} catch (error) {
console.error('[bundle-ui] Error during rebundle:', error);
}
}, 500);
}
}
}
// Main entry point
const args = Deno.args;
const isWatch = args.includes('--watch') || args.includes('-w');
if (isWatch) {
await watchMode();
} else {
await bundleUI();
}

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

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

@@ -0,0 +1,310 @@
/**
* 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
cleanupTestDb,
clients,
createOrgWithOwner,
createTestApiToken,
createTestRepository,
createTestUser,
runCommand,
setupTestDb,
skipIfMissing,
teardownTestDb,
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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
cleanupTestDb,
clients,
createOrgWithOwner,
createTestApiToken,
createTestRepository,
createTestUser,
setupTestDb,
skipIfMissing,
teardownTestDb,
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,36 @@
<?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

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

@@ -0,0 +1,145 @@
/**
* 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 {
ITokenScope,
TRegistryProtocol,
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 { closeDb, initDb } 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,
TRegistryProtocol,
TRepositoryRole,
TRepositoryVisibility,
TTeamRole,
} 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 {
cleanupTestDb,
clearCollections,
getTestDb,
getTestDbName,
setupTestDb,
teardownTestDb,
} from './db.helper.ts';
// Auth helpers
export {
createAdminUser,
createAuthHeader,
createBasicAuthHeader,
createTestApiToken,
createTestUser,
getTestPassword,
type ICreateTestApiTokenOptions,
type ICreateTestUserOptions,
loginUser,
} from './auth.helper.ts';
// Factory helpers
export {
addOrgMember,
addTeamMember,
createFullTestScenario,
createOrgWithOwner,
createTestOrganization,
createTestPackage,
createTestRepository,
createTestTeam,
grantRepoPermission,
type ICreateTestOrganizationOptions,
type ICreateTestPackageOptions,
type ICreateTestRepositoryOptions,
type ICreateTestTeamOptions,
type IGrantRepoPermissionOptions,
} from './factory.helper.ts';
// HTTP helpers
export {
assertBodyHas,
assertError,
assertStatus,
assertSuccess,
del,
get,
type ITestRequest,
type ITestResponse,
patch,
post,
put,
testRequest,
} from './http.helper.ts';
// Subprocess helpers
export {
clients,
commandExists,
type ICommandOptions,
type ICommandResult,
runCommand,
skipIfMissing,
} from './subprocess.helper.ts';
// Storage helpers
export {
checkStorageAvailable,
cleanupTestStorage,
deleteObject,
deletePrefix,
isStorageAvailable,
listObjects,
objectExists,
setupTestStorage,
} from './storage.helper.ts';
// Re-export test config
export { getTestConfig, testConfig } 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
assertStatus,
cleanupTestDb,
createAuthHeader,
createTestUser,
get,
post,
setupTestDb,
teardownTestDb,
} 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
assertStatus,
cleanupTestDb,
createAuthHeader,
createTestUser,
del,
get,
loginUser,
post,
put,
setupTestDb,
teardownTestDb,
} 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
cleanupTestDb,
createOrgWithOwner,
createTestRepository,
createTestUser,
setupTestDb,
teardownTestDb,
} 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
cleanupTestDb,
createOrgWithOwner,
createTestUser,
setupTestDb,
teardownTestDb,
} 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,166 @@
/**
* Session model unit tests
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, setupTestDb, teardownTestDb } 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 { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } 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,269 @@
/**
* TokenService unit tests
*/
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } 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_ + 64 hex chars
assertMatch(result.rawToken, /^srg_[a-f0-9]{64}$/);
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.valid, false);
assertEquals(validation.errorCode, 'INVALID_TOKEN_FORMAT');
});
it('should reject non-existent token', async () => {
// Must match srg_ prefix + 64 hex chars = 68 total
const validation = await tokenService.validateToken(
'srg_0000000000000000000000000000000000000000000000000000000000000000',
'127.0.0.1',
);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
});
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.valid, false);
// findByHash excludes revoked tokens, so the token is not found
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
});
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.valid, false);
assertEquals(validation.errorCode, 'TOKEN_EXPIRED');
});
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.5.0',
description: 'Enterprise-grade multi-protocol package registry'
}

View File

@@ -0,0 +1,456 @@
/**
* 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

@@ -26,7 +26,9 @@ export class AuditApi {
// Parse query parameters
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
const resourceType = ctx.url.searchParams.get('resourceType') as
| TAuditResourceType
| undefined;
const actionsParam = ctx.url.searchParams.get('actions');
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
const success = ctx.url.searchParams.has('success')
@@ -54,7 +56,7 @@ export class AuditApi {
// Check if user can manage this org
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
organizationId
organizationId,
);
if (!canManage) {
// User can only see their own actions in this org

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,21 @@ 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 +252,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 +261,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 +289,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 +298,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,16 +319,16 @@ export class OrganizationApi {
return {
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
addedAt: m.joinedAt,
user: user
? {
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
: null,
};
})
}),
);
return {
@@ -316,6 +343,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 +352,21 @@ 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 +385,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 +393,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 +421,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 +430,21 @@ 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 +452,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 +484,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 +493,31 @@ 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 +527,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,
@@ -50,7 +50,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (canAccess) {
accessiblePackages.push(pkg);
@@ -106,7 +106,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (!canAccess) {
@@ -161,7 +161,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (!canAccess) {
@@ -174,7 +174,7 @@ export class PackageApi {
publishedAt: data.publishedAt,
size: data.size,
downloads: data.downloads,
checksum: data.checksum,
checksum: data.metadata?.checksum,
}));
return {
@@ -213,7 +213,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {
@@ -267,7 +267,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {

View File

@@ -5,8 +5,8 @@
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 { Organization, Repository } from '../../models/index.ts';
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
export class RepositoryApi {
private permissionService: PermissionService;
@@ -26,10 +26,9 @@ export class RepositoryApi {
const { orgId } = ctx.params;
try {
// Get accessible repositories
const repositories = await this.permissionService.getAccessibleRepositories(
ctx.actor.userId,
orgId
orgId,
);
return {
@@ -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,25 @@ 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 +144,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 +168,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,
},
@@ -210,20 +201,20 @@ export class RepositoryApi {
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
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 +223,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) {
@@ -265,7 +255,7 @@ export class RepositoryApi {
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };

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,23 @@ 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 +54,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 +71,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 +85,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 +120,21 @@ 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 +151,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 +165,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 +175,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();
}
@@ -98,24 +104,56 @@ export class ApiRouter {
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
this.addRoute(
'GET',
'/api/v1/organizations/:id/members',
(ctx) => this.organizationApi.listMembers(ctx),
);
this.addRoute(
'POST',
'/api/v1/organizations/:id/members',
(ctx) => this.organizationApi.addMember(ctx),
);
this.addRoute(
'PUT',
'/api/v1/organizations/:id/members/:userId',
(ctx) => this.organizationApi.updateMember(ctx),
);
this.addRoute(
'DELETE',
'/api/v1/organizations/:id/members/:userId',
(ctx) => this.organizationApi.removeMember(ctx),
);
// Repository routes
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
this.addRoute(
'GET',
'/api/v1/organizations/:orgId/repositories',
(ctx) => this.repositoryApi.list(ctx),
);
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
this.addRoute(
'POST',
'/api/v1/organizations/:orgId/repositories',
(ctx) => this.repositoryApi.create(ctx),
);
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
// Package routes
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
this.addRoute(
'GET',
'/api/v1/packages/:id/versions',
(ctx) => this.packageApi.listVersions(ctx),
);
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
this.addRoute(
'DELETE',
'/api/v1/packages/:id/versions/:version',
(ctx) => this.packageApi.deleteVersion(ctx),
);
// Token routes
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
@@ -124,6 +162,54 @@ 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

@@ -3,9 +3,13 @@
*/
import * as plugins from './plugins.ts';
import { StackGalleryRegistry, createRegistryFromEnv, createRegistryFromEnvFile } from './registry.ts';
import {
createRegistryFromEnv,
createRegistryFromEnvFile,
StackGalleryRegistry,
} from './registry.ts';
import { initDb } from './models/db.ts';
import { User, Organization, OrganizationMember, Repository } from './models/index.ts';
import { Organization, OrganizationMember, Repository, User } from './models/index.ts';
import { AuthService } from './services/auth.service.ts';
export async function runCli(): Promise<void> {
@@ -21,9 +25,7 @@ export async function runCli(): Promise<void> {
}
// Use env file in ephemeral/dev mode, otherwise use environment variables
const registry = isEphemeral
? await createRegistryFromEnvFile()
: createRegistryFromEnv();
const registry = isEphemeral ? await createRegistryFromEnvFile() : createRegistryFromEnv();
await registry.start();
// Handle shutdown gracefully

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;
@@ -100,7 +103,14 @@ export interface ITeamMember {
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
export type TRegistryProtocol =
| 'oci'
| 'npm'
| 'maven'
| 'cargo'
| 'composer'
| 'pypi'
| 'rubygems';
export interface IRepository {
id: string;
@@ -143,6 +153,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 +288,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

@@ -7,7 +7,8 @@ import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/au
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken> implements IApiToken {
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
implements IApiToken {
@plugins.smartdata.unI()
public id: string = '';
@@ -16,7 +17,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 +98,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)
*/
@@ -133,7 +151,7 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
protocol: TRegistryProtocol,
organizationId?: string,
repositoryId?: string,
action?: string
action?: string,
): boolean {
for (const scope of this.scopes) {
// Check protocol
@@ -146,7 +164,9 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
// Check action
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) {
continue;
}
return true;
}

View File

@@ -3,11 +3,16 @@
*/
import * as plugins from '../plugins.ts';
import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
import type {
IAuditLog,
TAuditAction,
TAuditResourceType,
} from '../interfaces/audit.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog> implements IAuditLog {
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog>
implements IAuditLog {
@plugins.smartdata.unI()
public id: string = '';

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

@@ -0,0 +1,250 @@
/**
* Authentication Provider model for Stack.Gallery Registry
* Stores OAuth/OIDC and LDAP provider configurations
*/
import * as plugins from '../plugins.ts';
import type {
IAttributeMapping,
IAuthProvider,
ILdapConfig,
IOAuthConfig,
IProvisioningSettings,
TAuthProviderStatus,
TAuthProviderType,
} 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

@@ -20,7 +20,7 @@ let isInitialized = false;
*/
export async function initDb(
mongoDbUrl: string,
mongoDbName?: string
mongoDbName?: string,
): Promise<plugins.smartdata.SmartdataDb> {
if (isInitialized && db) {
return db;

View File

@@ -0,0 +1,141 @@
/**
* 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

@@ -2,7 +2,7 @@
* Model exports
*/
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
export { closeDb, getDb, initDb, isDbConnected } from './db.ts';
export { User } from './user.ts';
export { Organization } from './organization.ts';
export { OrganizationMember } from './organization.member.ts';
@@ -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

@@ -7,7 +7,9 @@ import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember> implements IOrganizationMember {
export class OrganizationMember
extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember>
implements IOrganizationMember {
@plugins.smartdata.unI()
public id: string = '';
@@ -69,7 +71,7 @@ export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<Organiz
*/
public static async findMembership(
organizationId: string,
userId: string
userId: string,
): Promise<OrganizationMember | null> {
return await OrganizationMember.getInstance({
organizationId,

View File

@@ -18,14 +18,15 @@ const DEFAULT_SETTINGS: IOrganizationSettings = {
};
@plugins.smartdata.Collection(() => db)
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization> implements IOrganization {
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
implements IOrganization {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public name: string = ''; // URL-safe slug
public override name: string = ''; // URL-safe slug
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@@ -37,6 +38,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 +89,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 +110,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

@@ -12,7 +12,8 @@ import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package> implements IPackage {
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
implements IPackage {
@plugins.smartdata.unI()
public id: string = ''; // {protocol}:{org}:{name}
@@ -31,7 +32,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()
@@ -94,7 +95,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
public static async findByName(
protocol: TRegistryProtocol,
orgName: string,
name: string
name: string,
): Promise<Package | null> {
const id = Package.generateId(protocol, orgName, name);
return await Package.findById(id);
@@ -110,7 +111,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;
@@ -118,7 +119,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
isPrivate?: boolean;
limit?: number;
offset?: number;
}
},
): Promise<Package[]> {
const filter: Record<string, unknown> = {};
if (options?.protocol) filter.protocol = options.protocol;
@@ -133,7 +134,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
const filtered = allPackages.filter(
(pkg) =>
pkg.name.toLowerCase().includes(lowerQuery) ||
pkg.description?.toLowerCase().includes(lowerQuery)
pkg.description?.toLowerCase().includes(lowerQuery),
);
// Apply pagination

View File

@@ -0,0 +1,89 @@
/**
* Platform Settings model for Stack.Gallery Registry
* Singleton model storing global platform configuration
*/
import * as plugins from '../plugins.ts';
import type { IPlatformAuthSettings, IPlatformSettings } 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

@@ -7,7 +7,9 @@ import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission> implements IRepositoryPermission {
export class RepositoryPermission
extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission>
implements IRepositoryPermission {
@plugins.smartdata.unI()
public id: string = '';
@@ -99,12 +101,22 @@ 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
*/
public static async getUserPermission(
repositoryId: string,
userId: string
userId: string,
): Promise<RepositoryPermission | null> {
return await RepositoryPermission.getInstance({
repositoryId,
@@ -117,7 +129,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
*/
public static async getTeamPermission(
repositoryId: string,
teamId: string
teamId: string,
): Promise<RepositoryPermission | null> {
return await RepositoryPermission.getInstance({
repositoryId,
@@ -139,7 +151,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
*/
public static async getTeamPermissionsForRepo(
repositoryId: string,
teamIds: string[]
teamIds: string[],
): Promise<RepositoryPermission[]> {
if (teamIds.length === 0) return [];
return await RepositoryPermission.getInstances({

View File

@@ -3,11 +3,16 @@
*/
import * as plugins from '../plugins.ts';
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import type {
IRepository,
TRegistryProtocol,
TRepositoryVisibility,
} from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository> implements IRepository {
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
implements IRepository {
@plugins.smartdata.unI()
public id: string = '';
@@ -17,7 +22,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 +44,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();
@@ -64,7 +75,9 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
// Validate name
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name.toLowerCase())) {
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
throw new Error(
'Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
);
}
// Check for duplicate name in org + protocol
@@ -99,7 +112,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
public static async findByName(
organizationId: string,
name: string,
protocol: TRegistryProtocol
protocol: TRegistryProtocol,
): Promise<Repository | null> {
return await Repository.getInstance({
organizationId,
@@ -128,6 +141,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

@@ -7,7 +7,8 @@ import type { ISession } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session> implements ISession {
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
implements ISession {
@plugins.smartdata.unI()
public id: string = '';
@@ -94,7 +95,7 @@ export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
*/
public static async invalidateAllUserSessions(
userId: string,
reason: string = 'logout_all'
reason: string = 'logout_all',
): Promise<number> {
const sessions = await Session.getUserSessions(userId);
for (const session of sessions) {

View File

@@ -7,7 +7,8 @@ import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember> implements ITeamMember {
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember>
implements ITeamMember {
@plugins.smartdata.unI()
public id: string = '';

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

@@ -0,0 +1,51 @@
import * as plugins from '../plugins.ts';
import type { StackGalleryRegistry } from '../registry.ts';
import * as handlers from './handlers/index.ts';
export class OpsServer {
public registryRef: StackGalleryRegistry;
public typedrouter = new plugins.typedrequest.TypedRouter();
// Handler instances
public authHandler!: handlers.AuthHandler;
public organizationHandler!: handlers.OrganizationHandler;
public repositoryHandler!: handlers.RepositoryHandler;
public packageHandler!: handlers.PackageHandler;
public tokenHandler!: handlers.TokenHandler;
public auditHandler!: handlers.AuditHandler;
public adminHandler!: handlers.AdminHandler;
public oauthHandler!: handlers.OAuthHandler;
public userHandler!: handlers.UserHandler;
constructor(registryRef: StackGalleryRegistry) {
this.registryRef = registryRef;
}
/**
* Initialize all handlers. Must be called before routing requests.
*/
public async start(): Promise<void> {
// AuthHandler must be initialized first (other handlers depend on its guards)
this.authHandler = new handlers.AuthHandler(this);
await this.authHandler.initialize();
// All other handlers self-register in their constructors
this.organizationHandler = new handlers.OrganizationHandler(this);
this.repositoryHandler = new handlers.RepositoryHandler(this);
this.packageHandler = new handlers.PackageHandler(this);
this.tokenHandler = new handlers.TokenHandler(this);
this.auditHandler = new handlers.AuditHandler(this);
this.adminHandler = new handlers.AdminHandler(this);
this.oauthHandler = new handlers.OAuthHandler(this);
this.userHandler = new handlers.UserHandler(this);
console.log('[OpsServer] TypedRequest handlers initialized');
}
/**
* Cleanup resources
*/
public async stop(): Promise<void> {
console.log('[OpsServer] Stopped');
}
}

View File

@@ -0,0 +1,380 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireAdminIdentity } from '../helpers/guards.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';
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get Admin Providers
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProviders>(
'getAdminProviders',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const providers = await AuthProvider.getAllProviders();
return {
providers: providers.map((p) => p.toAdminInfo()),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list providers');
}
},
),
);
// Create Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateAdminProvider>(
'createAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { name, displayName, type, oauthConfig, ldapConfig, attributeMapping, provisioning } = dataArg;
// Validate required fields
if (!name || !displayName || !type) {
throw new plugins.typedrequest.TypedResponseError(
'name, displayName, and type are required',
);
}
// Check name uniqueness
const existing = await AuthProvider.findByName(name);
if (existing) {
throw new plugins.typedrequest.TypedResponseError('Provider name already exists');
}
// Validate type-specific config
if (type === 'oidc' && !oauthConfig) {
throw new plugins.typedrequest.TypedResponseError(
'oauthConfig is required for OIDC provider',
);
}
if (type === 'ldap' && !ldapConfig) {
throw new plugins.typedrequest.TypedResponseError(
'ldapConfig is required for LDAP provider',
);
}
let provider: AuthProvider;
if (type === 'oidc' && oauthConfig) {
// Encrypt client secret
const encryptedSecret = await cryptoService.encrypt(
oauthConfig.clientSecretEncrypted,
);
provider = await AuthProvider.createOAuthProvider({
name,
displayName,
oauthConfig: {
...oauthConfig,
clientSecretEncrypted: encryptedSecret,
},
attributeMapping,
provisioning,
createdById: dataArg.identity.userId,
});
} else if (type === 'ldap' && ldapConfig) {
// Encrypt bind password
const encryptedPassword = await cryptoService.encrypt(
ldapConfig.bindPasswordEncrypted,
);
provider = await AuthProvider.createLdapProvider({
name,
displayName,
ldapConfig: {
...ldapConfig,
bindPasswordEncrypted: encryptedPassword,
},
attributeMapping,
provisioning,
createdById: dataArg.identity.userId,
});
} else {
throw new plugins.typedrequest.TypedResponseError('Invalid provider type');
}
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: {
providerName: provider.name,
providerType: provider.type,
},
});
return { provider: provider.toAdminInfo() };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
}
},
),
);
// Get Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProvider>(
'getAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const provider = await AuthProvider.findById(dataArg.providerId);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
return { provider: provider.toAdminInfo() };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
}
},
),
);
// Update Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAdminProvider>(
'updateAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const provider = await AuthProvider.findById(dataArg.providerId);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
// Update basic fields
if (dataArg.displayName !== undefined) provider.displayName = dataArg.displayName;
if (dataArg.status !== undefined) provider.status = dataArg.status as any;
if (dataArg.priority !== undefined) provider.priority = dataArg.priority;
// Update OAuth config
if (dataArg.oauthConfig && provider.oauthConfig) {
const newOAuthConfig = { ...provider.oauthConfig, ...dataArg.oauthConfig };
// Encrypt new client secret if provided and not already encrypted
if (
dataArg.oauthConfig.clientSecretEncrypted &&
!cryptoService.isEncrypted(dataArg.oauthConfig.clientSecretEncrypted)
) {
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
dataArg.oauthConfig.clientSecretEncrypted,
);
}
provider.oauthConfig = newOAuthConfig;
}
// Update LDAP config
if (dataArg.ldapConfig && provider.ldapConfig) {
const newLdapConfig = { ...provider.ldapConfig, ...dataArg.ldapConfig };
// Encrypt new bind password if provided and not already encrypted
if (
dataArg.ldapConfig.bindPasswordEncrypted &&
!cryptoService.isEncrypted(dataArg.ldapConfig.bindPasswordEncrypted)
) {
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
dataArg.ldapConfig.bindPasswordEncrypted,
);
}
provider.ldapConfig = newLdapConfig;
}
// Update attribute mapping
if (dataArg.attributeMapping) {
provider.attributeMapping = {
...provider.attributeMapping,
...dataArg.attributeMapping,
} as any;
}
// Update provisioning settings
if (dataArg.provisioning) {
provider.provisioning = {
...provider.provisioning,
...dataArg.provisioning,
} as any;
}
await provider.save();
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: { providerName: provider.name },
});
return { provider: provider.toAdminInfo() };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');
}
},
),
);
// Delete Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAdminProvider>(
'deleteAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const provider = await AuthProvider.findById(dataArg.providerId);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
// Soft delete - disable instead of removing
provider.status = 'disabled';
await provider.save();
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: { providerName: provider.name },
});
return { message: 'Provider disabled' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete provider');
}
},
),
);
// Test Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestAdminProvider>(
'testAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const result = await externalAuthService.testConnection(dataArg.providerId);
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
resourceId: dataArg.providerId,
success: result.success,
metadata: {
result: result.success ? 'success' : 'failure',
latencyMs: result.latencyMs,
error: result.error,
},
});
return { result };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to test provider');
}
},
),
);
// Get Platform Settings
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformSettings>(
'getPlatformSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const settings = await PlatformSettings.get();
return {
settings: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
updatedById: settings.updatedById,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get settings');
}
},
),
);
// Update Platform Settings
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdatePlatformSettings>(
'updatePlatformSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const settings = await PlatformSettings.get();
if (dataArg.auth) {
await settings.updateAuthSettings(dataArg.auth as any, dataArg.identity.userId);
}
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
resourceId: 'platform-settings',
success: true,
});
return {
settings: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
updatedById: settings.updatedById,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update settings');
}
},
),
);
}
}

View File

@@ -0,0 +1,105 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { AuditLog } from '../../models/auditlog.ts';
import { PermissionService } from '../../services/permission.service.ts';
export class AuditHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private permissionService = new PermissionService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Query Audit Logs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_QueryAudit>(
'queryAudit',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const {
organizationId,
repositoryId,
resourceType,
actions,
success,
startDate: startDateStr,
endDate: endDateStr,
limit: limitParam,
offset: offsetParam,
} = dataArg;
const limit = limitParam || 100;
const offset = offsetParam || 0;
const startDate = startDateStr ? new Date(startDateStr) : undefined;
const endDate = endDateStr ? new Date(endDateStr) : undefined;
// Determine actor filter based on permissions
let actorId: string | undefined;
if (dataArg.identity.isSystemAdmin) {
// System admins can see all
actorId = dataArg.actorId;
} else if (organizationId) {
// Check if user can manage this org
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
organizationId,
);
if (!canManage) {
// User can only see their own actions in this org
actorId = dataArg.identity.userId;
}
} else {
// Non-admins without org filter can only see their own actions
actorId = dataArg.identity.userId;
}
const result = await AuditLog.query({
actorId,
organizationId,
repositoryId,
resourceType: resourceType as any,
action: actions as any[],
success,
startDate,
endDate,
limit,
offset,
});
return {
logs: result.logs.map((log) => ({
id: log.id,
actorId: log.actorId,
actorType: log.actorType as interfaces.data.IAuditEntry['actorType'],
action: log.action as interfaces.data.TAuditAction,
resourceType: log.resourceType as interfaces.data.TAuditResourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
organizationId: log.organizationId,
repositoryId: log.repositoryId,
success: log.success,
errorCode: log.errorCode,
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : String(log.timestamp),
metadata: log.metadata || {},
})),
total: result.total,
limit,
offset,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to query audit logs');
}
},
),
);
}
}

View File

@@ -0,0 +1,263 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { AuthService } from '../../services/auth.service.ts';
import { User } from '../../models/user.ts';
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
export class AuthHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private authService: AuthService;
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.authService = new AuthService();
}
/**
* Initialize auth handler - must be called after construction
*/
public async initialize(): Promise<void> {
this.registerHandlers();
}
private registerHandlers(): void {
// Login
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Login>(
'login',
async (dataArg) => {
try {
const { email, password } = dataArg;
if (!email || !password) {
throw new plugins.typedrequest.TypedResponseError('Email and password are required');
}
const result = await this.authService.login(email, password);
if (!result.success || !result.user || !result.accessToken || !result.refreshToken) {
return {
errorCode: result.errorCode,
errorMessage: result.errorMessage,
};
}
const user = result.user;
const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes
const identity: interfaces.data.IIdentity = {
jwt: result.accessToken,
refreshJwt: result.refreshToken,
userId: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
isSystemAdmin: user.isSystemAdmin,
expiresAt,
sessionId: result.sessionId!,
};
return {
identity,
user: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt instanceof Date
? user.createdAt.toISOString()
: String(user.createdAt),
lastLoginAt: user.lastLoginAt instanceof Date
? user.lastLoginAt.toISOString()
: user.lastLoginAt
? String(user.lastLoginAt)
: undefined,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Login failed');
}
},
),
);
// Refresh Token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshToken>(
'refreshToken',
async (dataArg) => {
try {
if (!dataArg.identity?.refreshJwt) {
throw new plugins.typedrequest.TypedResponseError('Refresh token is required');
}
const result = await this.authService.refresh(dataArg.identity.refreshJwt);
if (!result.success || !result.user || !result.accessToken) {
throw new plugins.typedrequest.TypedResponseError(
result.errorMessage || 'Token refresh failed',
);
}
const user = result.user;
const expiresAt = Date.now() + 15 * 60 * 1000;
return {
identity: {
jwt: result.accessToken,
refreshJwt: dataArg.identity.refreshJwt,
userId: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
isSystemAdmin: user.isSystemAdmin,
expiresAt,
sessionId: result.sessionId || dataArg.identity.sessionId,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Token refresh failed');
}
},
),
);
// Logout
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Logout>(
'logout',
async (dataArg) => {
try {
if (!dataArg.identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('Identity required');
}
if (dataArg.all) {
const count = await this.authService.logoutAll(dataArg.identity.userId);
return { message: `Logged out from ${count} sessions` };
}
const sessionId = dataArg.sessionId || dataArg.identity.sessionId;
if (sessionId) {
await this.authService.logout(sessionId, {
userId: dataArg.identity.userId,
});
}
return { message: 'Logged out successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Logout failed');
}
},
),
);
// Get Me
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMe>(
'getMe',
async (dataArg) => {
try {
if (!dataArg.identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('Identity required');
}
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
if (!validated) {
throw new plugins.typedrequest.TypedResponseError('Invalid or expired token');
}
const user = validated.user;
return {
user: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt instanceof Date
? user.createdAt.toISOString()
: String(user.createdAt),
lastLoginAt: user.lastLoginAt instanceof Date
? user.lastLoginAt.toISOString()
: user.lastLoginAt
? String(user.lastLoginAt)
: undefined,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get user info');
}
},
),
);
// Get Auth Providers (public)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAuthProviders>(
'getAuthProviders',
async (_dataArg) => {
try {
const settings = await PlatformSettings.get();
const providers = await AuthProvider.getActiveProviders();
return {
providers: providers.map((p) => p.toPublicInfo()),
localAuthEnabled: settings.auth.localAuthEnabled,
defaultProviderId: settings.auth.defaultProviderId,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get auth providers');
}
},
),
);
}
// Guard for valid identity - validates JWT via AuthService
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) return false;
try {
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
if (!validated) return false;
// Verify the userId matches the identity claim
if (dataArg.identity.userId !== validated.user.id) return false;
return true;
} catch {
return false;
}
},
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
);
// Guard for admin identity - validates JWT + checks isSystemAdmin
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) return false;
// Check isSystemAdmin claim from the identity
if (!dataArg.identity.isSystemAdmin) return false;
// Double-check from database
const user = await User.findById(dataArg.identity.userId);
if (!user || !user.isSystemAdmin) return false;
return true;
},
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
);
}

View File

@@ -0,0 +1,9 @@
export * from './auth.handler.ts';
export * from './organization.handler.ts';
export * from './repository.handler.ts';
export * from './package.handler.ts';
export * from './token.handler.ts';
export * from './audit.handler.ts';
export * from './admin.handler.ts';
export * from './oauth.handler.ts';
export * from './user.handler.ts';

View File

@@ -0,0 +1,160 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { externalAuthService } from '../../services/external.auth.service.ts';
export class OAuthHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// OAuth Authorize - initiate OAuth flow
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthAuthorize>(
'oauthAuthorize',
async (dataArg) => {
try {
const { providerId, returnUrl } = dataArg;
const { authUrl } = await externalAuthService.initiateOAuth(providerId, returnUrl);
return { redirectUrl: authUrl };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
throw new plugins.typedrequest.TypedResponseError(errorMessage);
}
},
),
);
// OAuth Callback - handle provider callback
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthCallback>(
'oauthCallback',
async (dataArg) => {
try {
const { code, state } = dataArg;
if (!code || !state) {
return {
errorCode: 'MISSING_PARAMETERS',
errorMessage: 'Code and state are required',
};
}
const result = await externalAuthService.handleOAuthCallback(
{ code, state },
{},
);
if (!result.success) {
return {
errorCode: result.errorCode,
errorMessage: result.errorMessage,
};
}
const user = result.user!;
const expiresAt = Date.now() + 15 * 60 * 1000;
return {
identity: {
jwt: result.accessToken!,
refreshJwt: result.refreshToken!,
userId: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
isSystemAdmin: user.isSystemAdmin,
expiresAt,
sessionId: result.sessionId!,
},
user: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('OAuth callback failed');
}
},
),
);
// LDAP Login
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_LdapLogin>(
'ldapLogin',
async (dataArg) => {
try {
const { providerId, username, password } = dataArg;
if (!username || !password) {
throw new plugins.typedrequest.TypedResponseError(
'Username and password are required',
);
}
const result = await externalAuthService.authenticateLdap(
providerId,
username,
password,
{},
);
if (!result.success) {
return {
errorCode: result.errorCode,
errorMessage: result.errorMessage,
};
}
const user = result.user!;
const expiresAt = Date.now() + 15 * 60 * 1000;
return {
identity: {
jwt: result.accessToken!,
refreshJwt: result.refreshToken!,
userId: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
isSystemAdmin: user.isSystemAdmin,
expiresAt,
sessionId: result.sessionId!,
},
user: {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('LDAP login failed');
}
},
),
);
}
}

View File

@@ -0,0 +1,548 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { Organization, OrganizationMember, User } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
export class OrganizationHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private permissionService = new PermissionService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* 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);
}
private registerHandlers(): void {
// Get Organizations
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizations>(
'getOrganizations',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const userId = dataArg.identity.userId;
let organizations: Organization[];
if (dataArg.identity.isSystemAdmin) {
organizations = await Organization.getInstances({});
} else {
const memberships = await OrganizationMember.getUserOrganizations(userId);
const orgs: Organization[] = [];
for (const m of memberships) {
const org = await Organization.findById(m.organizationId);
if (org) orgs.push(org);
}
organizations = orgs;
}
return {
organizations: organizations.map((org) => ({
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
memberCount: org.memberCount,
plan: (org as any).plan || 'free',
usedStorageBytes: org.usedStorageBytes || 0,
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
createdAt: org.createdAt instanceof Date
? org.createdAt.toISOString()
: String(org.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list organizations');
}
},
),
);
// Get Organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganization>(
'getOrganization',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Check access - public orgs visible to all, private requires membership
if (!org.isPublic) {
const isMember = await OrganizationMember.findMembership(
org.id,
dataArg.identity.userId,
);
if (!isMember && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
}
const orgData: interfaces.data.IOrganizationDetail = {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
memberCount: org.memberCount,
plan: (org as any).plan || 'free',
usedStorageBytes: org.usedStorageBytes || 0,
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
createdAt: org.createdAt instanceof Date
? org.createdAt.toISOString()
: String(org.createdAt),
};
// Include settings for admins
if (dataArg.identity.isSystemAdmin && org.settings) {
orgData.settings = org.settings as any;
}
return { organization: orgData };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get organization');
}
},
),
);
// Create Organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateOrganization>(
'createOrganization',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { name, displayName, description, isPublic } = dataArg;
if (!name) {
throw new plugins.typedrequest.TypedResponseError('Organization name is required');
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
throw new plugins.typedrequest.TypedResponseError(
'Name must be lowercase alphanumeric with optional hyphens and dots',
);
}
// Check uniqueness
const existing = await Organization.findByName(name);
if (existing) {
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
}
// Create organization
const org = new Organization();
org.id = await Organization.getNewId();
org.name = name;
org.displayName = displayName || name;
org.description = description;
org.isPublic = isPublic ?? false;
org.memberCount = 1;
org.createdAt = new Date();
org.createdById = dataArg.identity.userId;
await org.save();
// Add creator as owner
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = org.id;
membership.userId = dataArg.identity.userId;
membership.role = 'owner';
membership.invitedBy = dataArg.identity.userId;
membership.joinedAt = new Date();
await membership.save();
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).logOrganizationCreated(org.id, org.name);
return {
organization: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
isPublic: org.isPublic,
memberCount: org.memberCount,
plan: (org as any).plan || 'free',
usedStorageBytes: org.usedStorageBytes || 0,
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
createdAt: org.createdAt instanceof Date
? org.createdAt.toISOString()
: String(org.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create organization');
}
},
),
);
// Update Organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganization>(
'updateOrganization',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
org.id,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
if (dataArg.description !== undefined) org.description = dataArg.description;
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
if (dataArg.website !== undefined) org.website = dataArg.website;
if (dataArg.isPublic !== undefined) org.isPublic = dataArg.isPublic;
// Only system admins can change settings
if (dataArg.settings && dataArg.identity.isSystemAdmin) {
org.settings = { ...org.settings, ...dataArg.settings } as any;
}
await org.save();
return {
organization: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
memberCount: org.memberCount,
plan: (org as any).plan || 'free',
usedStorageBytes: org.usedStorageBytes || 0,
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
createdAt: org.createdAt instanceof Date
? org.createdAt.toISOString()
: String(org.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update organization');
}
},
),
);
// Delete Organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrganization>(
'deleteOrganization',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Only owners and system admins can delete
const membership = await OrganizationMember.findMembership(
org.id,
dataArg.identity.userId,
);
if (membership?.role !== 'owner' && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Owner access required');
}
await org.delete();
return { message: 'Organization deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete organization');
}
},
),
);
// Get Organization Members
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizationMembers>(
'getOrganizationMembers',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Check membership
const isMember = await OrganizationMember.findMembership(
org.id,
dataArg.identity.userId,
);
if (!isMember && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
const members = await OrganizationMember.getOrgMembers(org.id);
const membersWithUsers = await Promise.all(
members.map(async (m) => {
const user = await User.findById(m.userId);
return {
userId: m.userId,
role: m.role as interfaces.data.TOrganizationRole,
addedAt: m.joinedAt instanceof Date
? m.joinedAt.toISOString()
: String(m.joinedAt),
user: user
? {
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
: null,
};
}),
);
return { members: membersWithUsers };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list members');
}
},
),
);
// Add Organization Member
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddOrganizationMember>(
'addOrganizationMember',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
org.id,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
const { userId, role } = dataArg;
if (!userId || !role) {
throw new plugins.typedrequest.TypedResponseError('userId and role are required');
}
if (!['owner', 'admin', 'member'].includes(role)) {
throw new plugins.typedrequest.TypedResponseError('Invalid role');
}
// Check user exists
const user = await User.findById(userId);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
// Check if already a member
const existing = await OrganizationMember.findMembership(org.id, userId);
if (existing) {
throw new plugins.typedrequest.TypedResponseError('User is already a member');
}
// Add member
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = org.id;
membership.userId = userId;
membership.role = role;
membership.invitedBy = dataArg.identity.userId;
membership.joinedAt = new Date();
await membership.save();
// Update member count
org.memberCount += 1;
await org.save();
return {
member: {
userId: membership.userId,
role: membership.role as interfaces.data.TOrganizationRole,
addedAt: membership.joinedAt instanceof Date
? membership.joinedAt.toISOString()
: String(membership.joinedAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to add member');
}
},
),
);
// Update Organization Member
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganizationMember>(
'updateOrganizationMember',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
org.id,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
const { userId, role } = dataArg;
if (!role || !['owner', 'admin', 'member'].includes(role)) {
throw new plugins.typedrequest.TypedResponseError('Valid role is required');
}
const membership = await OrganizationMember.findMembership(org.id, userId);
if (!membership) {
throw new plugins.typedrequest.TypedResponseError('Member not found');
}
// Cannot change last owner
if (membership.role === 'owner' && role !== 'owner') {
const members = await OrganizationMember.getOrgMembers(org.id);
const ownerCount = members.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
}
}
membership.role = role;
await membership.save();
return {
member: {
userId: membership.userId,
role: membership.role as interfaces.data.TOrganizationRole,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update member');
}
},
),
);
// Remove Organization Member
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveOrganizationMember>(
'removeOrganizationMember',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Users can remove themselves, admins can remove others
if (dataArg.userId !== dataArg.identity.userId) {
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
org.id,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
}
const membership = await OrganizationMember.findMembership(org.id, dataArg.userId);
if (!membership) {
throw new plugins.typedrequest.TypedResponseError('Member not found');
}
// Cannot remove last owner
if (membership.role === 'owner') {
const members = await OrganizationMember.getOrgMembers(org.id);
const ownerCount = members.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
}
}
await membership.delete();
// Update member count
org.memberCount = Math.max(0, org.memberCount - 1);
await org.save();
return { message: 'Member removed successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to remove member');
}
},
),
);
}
}

View File

@@ -0,0 +1,315 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { Package, Repository } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts';
export class PackageHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private permissionService = new PermissionService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Search Packages
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SearchPackages>(
'searchPackages',
async (dataArg) => {
try {
const query = dataArg.query || '';
const protocol = dataArg.protocol;
const organizationId = dataArg.organizationId;
const limit = dataArg.limit || 50;
const offset = dataArg.offset || 0;
// Determine visibility: anonymous users see only public packages
const hasIdentity = !!dataArg.identity?.jwt;
const isPrivate = hasIdentity ? undefined : false;
const packages = await Package.searchPackages(query, {
protocol,
organizationId,
isPrivate,
limit,
offset,
});
// Filter out packages user doesn't have access to
const accessiblePackages: typeof packages = [];
for (const pkg of packages) {
if (!pkg.isPrivate) {
accessiblePackages.push(pkg);
continue;
}
if (hasIdentity && dataArg.identity) {
const canAccess = await this.permissionService.canAccessPackage(
dataArg.identity.userId,
pkg.organizationId,
pkg.repositoryId,
'read',
);
if (canAccess) {
accessiblePackages.push(pkg);
}
}
}
return {
packages: accessiblePackages.map((pkg) => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
organizationId: pkg.organizationId,
repositoryId: pkg.repositoryId,
latestVersion: pkg.distTags?.['latest'],
isPrivate: pkg.isPrivate,
downloadCount: pkg.downloadCount || 0,
starCount: pkg.starCount || 0,
storageBytes: pkg.storageBytes || 0,
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
})),
total: accessiblePackages.length,
limit,
offset,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to search packages');
}
},
),
);
// Get Package
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackage>(
'getPackage',
async (dataArg) => {
try {
const pkg = await Package.findById(dataArg.packageId);
if (!pkg) {
throw new plugins.typedrequest.TypedResponseError('Package not found');
}
// Check access for private packages
if (pkg.isPrivate) {
if (!dataArg.identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('Authentication required');
}
const canAccess = await this.permissionService.canAccessPackage(
dataArg.identity.userId,
pkg.organizationId,
pkg.repositoryId,
'read',
);
if (!canAccess) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
}
return {
package: {
id: pkg.id,
name: pkg.name,
description: pkg.description,
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
organizationId: pkg.organizationId,
repositoryId: pkg.repositoryId,
latestVersion: pkg.distTags?.['latest'],
isPrivate: pkg.isPrivate,
downloadCount: pkg.downloadCount || 0,
starCount: pkg.starCount || 0,
storageBytes: pkg.storageBytes || 0,
distTags: pkg.distTags || {},
versions: Object.keys(pkg.versions || {}),
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get package');
}
},
),
);
// Get Package Versions
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackageVersions>(
'getPackageVersions',
async (dataArg) => {
try {
const pkg = await Package.findById(dataArg.packageId);
if (!pkg) {
throw new plugins.typedrequest.TypedResponseError('Package not found');
}
// Check access for private packages
if (pkg.isPrivate) {
if (!dataArg.identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('Authentication required');
}
const canAccess = await this.permissionService.canAccessPackage(
dataArg.identity.userId,
pkg.organizationId,
pkg.repositoryId,
'read',
);
if (!canAccess) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
}
const versions = Object.entries(pkg.versions || {}).map(([version, data]) => ({
version,
publishedAt: data.publishedAt instanceof Date ? data.publishedAt.toISOString() : String(data.publishedAt || ''),
size: data.size || 0,
downloads: data.downloads || 0,
checksum: data.metadata?.checksum as interfaces.data.IPackageVersion['checksum'],
}));
return {
packageId: pkg.id,
packageName: pkg.name,
distTags: pkg.distTags || {},
versions,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list versions');
}
},
),
);
// Delete Package
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackage>(
'deletePackage',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const pkg = await Package.findById(dataArg.packageId);
if (!pkg) {
throw new plugins.typedrequest.TypedResponseError('Package not found');
}
// Check delete permission
const canDelete = await this.permissionService.canAccessPackage(
dataArg.identity.userId,
pkg.organizationId,
pkg.repositoryId,
'delete',
);
if (!canDelete) {
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
}
// Update repository counts before deleting
const repo = await Repository.findById(pkg.repositoryId);
if (repo) {
repo.packageCount = Math.max(0, repo.packageCount - 1);
repo.storageBytes -= pkg.storageBytes;
await repo.save();
}
await pkg.delete();
return { message: 'Package deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete package');
}
},
),
);
// Delete Package Version
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackageVersion>(
'deletePackageVersion',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const pkg = await Package.findById(dataArg.packageId);
if (!pkg) {
throw new plugins.typedrequest.TypedResponseError('Package not found');
}
const versionData = pkg.versions?.[dataArg.version];
if (!versionData) {
throw new plugins.typedrequest.TypedResponseError('Version not found');
}
// Check delete permission
const canDelete = await this.permissionService.canAccessPackage(
dataArg.identity.userId,
pkg.organizationId,
pkg.repositoryId,
'delete',
);
if (!canDelete) {
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
}
// Check if this is the only version
if (Object.keys(pkg.versions).length === 1) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot delete the only version. Delete the entire package instead.',
);
}
// Remove version
const sizeReduction = versionData.size || 0;
delete pkg.versions[dataArg.version];
pkg.storageBytes -= sizeReduction;
// Update dist tags
for (const [tag, tagVersion] of Object.entries(pkg.distTags || {})) {
if (tagVersion === dataArg.version) {
delete pkg.distTags[tag];
}
}
// Set new latest if needed
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
const versions = Object.keys(pkg.versions).sort();
pkg.distTags['latest'] = versions[versions.length - 1];
}
await pkg.save();
// Update repository storage
const repo = await Repository.findById(pkg.repositoryId);
if (repo) {
repo.storageBytes -= sizeReduction;
await repo.save();
}
return { message: 'Version deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete version');
}
},
),
);
}
}

View File

@@ -0,0 +1,272 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { Organization, Repository } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
export class RepositoryHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private permissionService = new PermissionService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get Repositories
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepositories>(
'getRepositories',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const repositories = await this.permissionService.getAccessibleRepositories(
dataArg.identity.userId,
dataArg.organizationId,
);
return {
repositories: repositories.map((repo) => ({
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
description: repo.description,
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes || 0,
downloadCount: (repo as any).downloadCount || 0,
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list repositories');
}
},
),
);
// Get Repository
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepository>(
'getRepository',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const repo = await Repository.findById(dataArg.repositoryId);
if (!repo) {
throw new plugins.typedrequest.TypedResponseError('Repository not found');
}
// Check access
if (!repo.isPublic) {
const permissions = await this.permissionService.resolvePermissions({
userId: dataArg.identity.userId,
organizationId: repo.organizationId,
repositoryId: repo.id,
});
if (!permissions.canRead) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
}
return {
repository: {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
description: repo.description,
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes || 0,
downloadCount: (repo as any).downloadCount || 0,
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get repository');
}
},
),
);
// Create Repository
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRepository>(
'createRepository',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { organizationId, name, description, protocol, visibility } = dataArg;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
organizationId,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
if (!name) {
throw new plugins.typedrequest.TypedResponseError('Repository name is required');
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
throw new plugins.typedrequest.TypedResponseError(
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
);
}
// Check org exists
const org = await Organization.findById(organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
// Create repository
const repo = await Repository.createRepository({
organizationId,
name,
description,
protocol: protocol || 'npm',
visibility: visibility || 'private',
createdById: dataArg.identity.userId,
});
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
organizationId,
}).logRepositoryCreated(repo.id, repo.name, organizationId);
return {
repository: {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
description: repo.description,
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes || 0,
downloadCount: 0,
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create repository');
}
},
),
);
// Update Repository
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRepository>(
'updateRepository',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const repo = await Repository.findById(dataArg.repositoryId);
if (!repo) {
throw new plugins.typedrequest.TypedResponseError('Repository not found');
}
// Check admin permission
const canManage = await this.permissionService.canManageRepository(
dataArg.identity.userId,
repo.organizationId,
dataArg.repositoryId,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
if (dataArg.description !== undefined) repo.description = dataArg.description;
if (dataArg.visibility !== undefined) repo.visibility = dataArg.visibility as any;
await repo.save();
return {
repository: {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
description: repo.description,
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes || 0,
downloadCount: (repo as any).downloadCount || 0,
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update repository');
}
},
),
);
// Delete Repository
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRepository>(
'deleteRepository',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const repo = await Repository.findById(dataArg.repositoryId);
if (!repo) {
throw new plugins.typedrequest.TypedResponseError('Repository not found');
}
// Check admin permission
const canManage = await this.permissionService.canManageRepository(
dataArg.identity.userId,
repo.organizationId,
dataArg.repositoryId,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
// Check for packages
if (repo.packageCount > 0) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot delete repository with packages. Remove all packages first.',
);
}
await repo.delete();
return { message: 'Repository deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete repository');
}
},
),
);
}
}

View File

@@ -0,0 +1,198 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { ApiToken } from '../../models/index.ts';
import { TokenService } from '../../services/token.service.ts';
import { PermissionService } from '../../services/permission.service.ts';
export class TokenHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private tokenService = new TokenService();
private permissionService = new PermissionService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get Tokens
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTokens>(
'getTokens',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
let tokens;
if (dataArg.organizationId) {
// Check if user can manage org
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
dataArg.organizationId,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError(
'Not authorized to view organization tokens',
);
}
tokens = await this.tokenService.getOrgTokens(dataArg.organizationId);
} else {
tokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
}
return {
tokens: tokens.map((t) => ({
id: t.id,
name: t.name,
tokenPrefix: t.tokenPrefix,
protocols: t.protocols as interfaces.data.TRegistryProtocol[],
scopes: t.scopes as interfaces.data.ITokenScope[],
organizationId: t.organizationId,
createdById: t.createdById,
expiresAt: t.expiresAt instanceof Date ? t.expiresAt.toISOString() : t.expiresAt ? String(t.expiresAt) : undefined,
lastUsedAt: t.lastUsedAt instanceof Date ? t.lastUsedAt.toISOString() : t.lastUsedAt ? String(t.lastUsedAt) : undefined,
usageCount: t.usageCount,
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list tokens');
}
},
),
);
// Create Token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateToken>(
'createToken',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { name, organizationId, protocols, scopes, expiresInDays } = dataArg;
if (!name) {
throw new plugins.typedrequest.TypedResponseError('Token name is required');
}
if (!protocols || protocols.length === 0) {
throw new plugins.typedrequest.TypedResponseError('At least one protocol is required');
}
if (!scopes || scopes.length === 0) {
throw new plugins.typedrequest.TypedResponseError('At least one scope is required');
}
// Validate protocols
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
for (const protocol of protocols) {
if (!validProtocols.includes(protocol)) {
throw new plugins.typedrequest.TypedResponseError(`Invalid protocol: ${protocol}`);
}
}
// Validate scopes
for (const scope of scopes) {
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
throw new plugins.typedrequest.TypedResponseError('Invalid scope configuration');
}
}
// If creating org token, verify permission
if (organizationId) {
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
organizationId,
);
if (!canManage) {
throw new plugins.typedrequest.TypedResponseError(
'Not authorized to create organization tokens',
);
}
}
const result = await this.tokenService.createToken({
userId: dataArg.identity.userId,
organizationId,
createdById: dataArg.identity.userId,
name,
protocols: protocols as any[],
scopes: scopes as any[],
expiresInDays,
});
return {
token: {
id: result.token.id,
name: result.token.name,
token: result.rawToken,
tokenPrefix: result.token.tokenPrefix,
protocols: result.token.protocols as interfaces.data.TRegistryProtocol[],
scopes: result.token.scopes as interfaces.data.ITokenScope[],
organizationId: result.token.organizationId,
createdById: result.token.createdById,
expiresAt: result.token.expiresAt instanceof Date ? result.token.expiresAt.toISOString() : result.token.expiresAt ? String(result.token.expiresAt) : undefined,
usageCount: result.token.usageCount,
createdAt: result.token.createdAt instanceof Date ? result.token.createdAt.toISOString() : String(result.token.createdAt),
warning: 'Store this token securely. It will not be shown again.',
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create token');
}
},
),
);
// Revoke Token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeToken>(
'revokeToken',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { tokenId } = dataArg;
// Check if it's a personal token
const userTokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
let token = userTokens.find((t) => t.id === tokenId);
if (!token) {
// Check if it's an org token the user can manage
const anyToken = await ApiToken.getInstance({ id: tokenId, isRevoked: false });
if (anyToken?.organizationId) {
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
anyToken.organizationId,
);
if (canManage) {
token = anyToken;
}
}
}
if (!token) {
throw new plugins.typedrequest.TypedResponseError('Token not found');
}
const success = await this.tokenService.revokeToken(tokenId, 'user_revoked');
if (!success) {
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
}
return { message: 'Token revoked successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
}
},
),
);
}
}

View File

@@ -0,0 +1,263 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity, requireAdminIdentity } from '../helpers/guards.ts';
import { User } from '../../models/user.ts';
import { Session } from '../../models/session.ts';
import { AuthService } from '../../services/auth.service.ts';
export class UserHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private authService = new AuthService();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Helper to format user to IUser interface
*/
private formatUser(user: User): interfaces.data.IUser {
return {
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isSystemAdmin: user.isSystemAdmin,
isActive: user.isActive,
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
};
}
private registerHandlers(): void {
// Get Users (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUsers>(
'getUsers',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const users = await User.getInstances({});
return {
users: users.map((u) => this.formatUser(u)),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to list users');
}
},
),
);
// Get User
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUser>(
'getUser',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { userId } = dataArg;
// Users can view their own profile, admins can view any
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
const user = await User.findById(userId);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
return { user: this.formatUser(user) };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get user');
}
},
),
);
// Update User
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateUser>(
'updateUser',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { userId, displayName, avatarUrl, password, isActive, isSystemAdmin } = dataArg;
// Users can update their own profile, admins can update any
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Access denied');
}
const user = await User.findById(userId);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
if (displayName !== undefined) user.displayName = displayName;
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
// Only admins can change these
if (dataArg.identity.isSystemAdmin) {
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
}
// Password change
if (password) {
user.passwordHash = await AuthService.hashPassword(password);
}
await user.save();
return { user: this.formatUser(user) };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update user');
}
},
),
);
// Get User Sessions
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUserSessions>(
'getUserSessions',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const sessions = await Session.getUserSessions(dataArg.identity.userId);
return {
sessions: sessions.map((s) => ({
id: s.id,
userId: s.userId,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
isValid: s.isValid,
lastActivityAt: s.lastActivityAt instanceof Date ? s.lastActivityAt.toISOString() : String(s.lastActivityAt),
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : String(s.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get sessions');
}
},
),
);
// Revoke Session
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeSession>(
'revokeSession',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
await this.authService.logout(dataArg.sessionId, {
userId: dataArg.identity.userId,
});
return { message: 'Session revoked successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to revoke session');
}
},
),
);
// Change Password
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
'changePassword',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { currentPassword, newPassword } = dataArg;
if (!currentPassword || !newPassword) {
throw new plugins.typedrequest.TypedResponseError(
'Current password and new password are required',
);
}
const user = await User.findById(dataArg.identity.userId);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
// Verify current password
const isValid = await user.verifyPassword(currentPassword);
if (!isValid) {
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
}
// Hash and set new password
user.passwordHash = await AuthService.hashPassword(newPassword);
await user.save();
return { message: 'Password changed successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to change password');
}
},
),
);
// Delete Account
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAccount>(
'deleteAccount',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const { password } = dataArg;
if (!password) {
throw new plugins.typedrequest.TypedResponseError(
'Password is required to delete account',
);
}
const user = await User.findById(dataArg.identity.userId);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
// Verify password
const isValid = await user.verifyPassword(password);
if (!isValid) {
throw new plugins.typedrequest.TypedResponseError('Password is incorrect');
}
// Soft delete - deactivate instead of removing
user.status = 'suspended';
await user.save();
// Invalidate all sessions
await Session.invalidateAllUserSessions(user.id, 'account_deleted');
return { message: 'Account deactivated successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete account');
}
},
),
);
}
}

View File

@@ -0,0 +1,29 @@
import * as plugins from '../../plugins.ts';
import type { AuthHandler } from '../handlers/auth.handler.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
authHandler: AuthHandler,
dataArg: T,
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await authHandler.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
}
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
authHandler: AuthHandler,
dataArg: T,
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await authHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
}

View File

@@ -18,6 +18,10 @@ import * as smartunique from '@push.rocks/smartunique';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartrx from '@push.rocks/smartrx';
import * as smartcli from '@push.rocks/smartcli';
import * as smartguard from '@push.rocks/smartguard';
// api.global packages
import * as typedrequest from '@api.global/typedrequest';
// tsclass types
import * as tsclass from '@tsclass/tsclass';
@@ -28,25 +32,28 @@ import * as fs from '@std/fs';
import * as http from '@std/http';
export {
// Push.rocks
smartregistry,
smartdata,
smartbucket,
smartlog,
smartenv,
smartpath,
smartpromise,
smartstring,
smartcrypto,
smartjwt,
smartunique,
smartdelay,
smartrx,
smartcli,
// tsclass
tsclass,
// Deno std
path,
fs,
http,
// Deno std
path,
smartbucket,
smartcli,
smartcrypto,
smartdata,
smartdelay,
smartenv,
smartguard,
smartjwt,
smartlog,
smartpath,
smartpromise,
// Push.rocks
smartregistry,
smartrx,
smartstring,
smartunique,
// tsclass
tsclass,
// api.global
typedrequest,
};

View File

@@ -46,206 +46,113 @@ 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

@@ -2,5 +2,5 @@
* Provider exports
*/
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
export { type IStackGalleryActor, StackGalleryAuthProvider } from './auth.provider.ts';
export { type IStorageConfig, StackGalleryStorageHooks } from './storage.provider.ts';

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,
});
}
}
/**
@@ -246,7 +216,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
organizationName: string,
packageName: string,
version: string,
filename: string
filename: string,
): string {
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
}
@@ -257,13 +227,12 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
public async storeArtifact(
path: string,
data: Uint8Array,
contentType?: string
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

@@ -4,12 +4,46 @@
*/
import * as plugins from './plugins.ts';
import { initDb, closeDb, isDbConnected } from './models/db.ts';
import { closeDb, initDb, isDbConnected } from './models/db.ts';
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
import { ApiRouter } from './api/router.ts';
import { getEmbeddedFile } from './embedded-ui.generated.ts';
import { ReloadSocketManager } from './reload-socket.ts';
import { OpsServer } from './opsserver/classes.opsserver.ts';
// Bundled UI files (generated by tsbundle with base64ts output mode)
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
try {
// @ts-ignore - generated file may not exist yet
const { files } = await import('../ts_bundled/bundle.ts');
bundledFileMap = new Map();
for (const file of files as Array<{ path: string; contentBase64: string }>) {
const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
const ext = file.path.split('.').pop() || '';
bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) });
}
} catch {
console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)');
}
function getContentType(ext: string): string {
const types: Record<string, string> = {
html: 'text/html',
js: 'application/javascript',
css: 'text/css',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
eot: 'application/vnd.ms-fontobject',
map: 'application/json',
};
return types[ext] || 'application/octet-stream';
}
export interface IRegistryConfig {
// MongoDB configuration
@@ -42,8 +76,7 @@ export class StackGalleryRegistry {
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
private authProvider: StackGalleryAuthProvider | null = null;
private storageHooks: StackGalleryStorageHooks | null = null;
private apiRouter: ApiRouter | null = null;
private reloadSocket: ReloadSocketManager | null = null;
private opsServer: OpsServer | null = null;
private isInitialized = false;
constructor(config: IRegistryConfig) {
@@ -86,6 +119,7 @@ export class StackGalleryRegistry {
// Initialize storage hooks
this.storageHooks = new StackGalleryStorageHooks({
bucket: this.smartBucket,
bucketName: this.config.s3Bucket,
basePath: this.config.storagePath!,
});
@@ -95,26 +129,30 @@ 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');
// Initialize API router
console.log('[StackGalleryRegistry] Initializing API router...');
this.apiRouter = new ApiRouter();
console.log('[StackGalleryRegistry] API router initialized');
// Initialize reload socket for hot reload
this.reloadSocket = new ReloadSocketManager();
// Initialize OpsServer (TypedRequest handlers)
console.log('[StackGalleryRegistry] Initializing OpsServer...');
this.opsServer = new OpsServer(this);
await this.opsServer.start();
console.log('[StackGalleryRegistry] OpsServer initialized');
this.isInitialized = true;
console.log('[StackGalleryRegistry] Initialization complete');
@@ -137,7 +175,7 @@ export class StackGalleryRegistry {
{ port, hostname: host },
async (request: Request): Promise<Response> => {
return await this.handleRequest(request);
}
},
);
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
@@ -155,65 +193,146 @@ export class StackGalleryRegistry {
return this.healthCheck();
}
// API endpoints (handled by REST API layer)
if (path.startsWith('/api/')) {
return await this.handleApiRequest(request);
// TypedRequest endpoint (handled by OpsServer TypedRouter)
if (path === '/typedrequest' && request.method === 'POST') {
return await this.handleTypedRequest(request);
}
// Legacy REST API endpoints (keep for backwards compatibility during migration)
// TODO: Remove once frontend is fully migrated to TypedRequest
// 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' },
});
}
}
// WebSocket upgrade for hot reload
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
return this.reloadSocket!.handleUpgrade(request);
}
// Serve static UI files
return this.serveStaticFile(path);
}
/**
* Serve static files from embedded UI
* 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 bundled UI
*/
private serveStaticFile(path: string): Response {
if (!bundledFileMap) {
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
}
const filePath = path === '/' ? '/index.html' : path;
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data, {
// Get bundled file
const file = bundledFileMap.get(filePath);
if (file) {
return new Response(file.data, {
status: 200,
headers: { 'Content-Type': embeddedFile.contentType },
headers: { 'Content-Type': file.contentType },
});
}
// SPA fallback: serve index.html for unknown paths
const indexFile = getEmbeddedFile('/index.html');
const indexFile = bundledFileMap.get('/index.html');
if (indexFile) {
return new Response(indexFile.data, {
status: 200,
@@ -225,20 +344,34 @@ export class StackGalleryRegistry {
}
/**
* Handle API requests
* Handle TypedRequest calls
*/
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' },
}
);
private async handleTypedRequest(request: Request): Promise<Response> {
if (!this.opsServer) {
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return await this.apiRouter.handle(request);
try {
const body = await request.json();
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('[StackGalleryRegistry] TypedRequest error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(
JSON.stringify({ error: message }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
}
/**
@@ -268,6 +401,9 @@ export class StackGalleryRegistry {
*/
public async stop(): Promise<void> {
console.log('[StackGalleryRegistry] Shutting down...');
if (this.opsServer) {
await this.opsServer.stop();
}
await closeDb();
this.isInitialized = false;
console.log('[StackGalleryRegistry] Shutdown complete');
@@ -336,7 +472,10 @@ 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 +495,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();
}

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