Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aacf30e582 | |||
| d4f758ce0f | |||
| 0fc74ff995 | |||
| d71ae08645 | |||
| fe3cb75095 | |||
| f76778ce45 | |||
| 15ca1a67f4 | |||
| b05c53f967 | |||
| 4d561b3874 | |||
| d3fd40ce2f | |||
| 44e92d48f2 | |||
| 61324ba195 | |||
| dface47942 | |||
| 93ae998e3f | |||
| 5d9cd3ad85 |
212
.gitea/workflows/release.yml
Normal file
212
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,212 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --ignore-scripts
|
||||
|
||||
- name: Install UI dependencies
|
||||
run: cd ui && pnpm install
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Verify deno.json version matches tag
|
||||
run: |
|
||||
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "deno.json version: $DENO_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Angular UI
|
||||
run: cd ui && pnpm run build
|
||||
|
||||
- name: Bundle UI into TypeScript
|
||||
run: deno run --allow-all scripts/bundle-ui.ts
|
||||
|
||||
- name: Compile binaries for all platforms
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
cd ../..
|
||||
|
||||
- name: Extract changelog for this version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
if [ ! -f CHANGELOG.md ] && [ ! -f changelog.md ]; then
|
||||
echo "No changelog found, using default release notes"
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
## Stack.Gallery Registry $VERSION
|
||||
|
||||
Pre-compiled binaries for multiple platforms.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
|
||||
Or download the binary for your platform and make it executable.
|
||||
|
||||
### Supported Platforms
|
||||
- Linux x86_64 (x64)
|
||||
- Linux ARM64 (aarch64)
|
||||
- macOS x86_64 (Intel)
|
||||
- macOS ARM64 (Apple Silicon)
|
||||
|
||||
### Checksums
|
||||
SHA256 checksums are provided in SHA256SUMS.txt
|
||||
EOF
|
||||
else
|
||||
CHANGELOG_FILE=$([ -f CHANGELOG.md ] && echo "CHANGELOG.md" || echo "changelog.md")
|
||||
awk "/## \[$VERSION\]/,/## \[/" "$CHANGELOG_FILE" | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||
## Stack.Gallery Registry $VERSION
|
||||
|
||||
See changelog.md for full details.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Release notes:"
|
||||
cat /tmp/release_notes.md
|
||||
|
||||
- name: Delete existing release if it exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Checking for existing release $VERSION..."
|
||||
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/tags/$VERSION" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
|
||||
curl -X DELETE -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$EXISTING_RELEASE_ID"
|
||||
echo "Existing release deleted"
|
||||
sleep 2
|
||||
else
|
||||
echo "No existing release found, proceeding with creation"
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Creating release for $VERSION..."
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$VERSION\",
|
||||
\"name\": \"Stack.Gallery Registry $VERSION\",
|
||||
\"body\": $(jq -Rs . /tmp/release_notes.md),
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
for binary in dist/binaries/*; do
|
||||
filename=$(basename "$binary")
|
||||
echo "Uploading $filename..."
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$binary" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$RELEASE_ID/assets?name=$filename"
|
||||
done
|
||||
|
||||
echo "All assets uploaded successfully"
|
||||
|
||||
- name: Clean up old releases
|
||||
run: |
|
||||
echo "Cleaning up old releases (keeping only last 3)..."
|
||||
|
||||
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases" | \
|
||||
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||
|
||||
if [ -n "$RELEASES" ]; then
|
||||
echo "Found releases to delete:"
|
||||
for release_id in $RELEASES; do
|
||||
echo " Deleting release ID: $release_id"
|
||||
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/stack.gallery/registry/releases/$release_id"
|
||||
done
|
||||
echo "Old releases deleted successfully"
|
||||
else
|
||||
echo "No old releases to delete (less than 4 releases total)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
- name: Release Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " Release ${{ steps.version.outputs.version }} Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Binaries published:"
|
||||
ls -lh dist/binaries/
|
||||
echo ""
|
||||
echo "Release URL:"
|
||||
echo "https://code.foss.global/stack.gallery/registry/releases/tag/${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation command:"
|
||||
echo "curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -4,8 +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/
|
||||
@@ -42,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
|
||||
|
||||
116
changelog.md
116
changelog.md
@@ -1,11 +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.
|
||||
|
||||
- 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).
|
||||
|
||||
## 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.
|
||||
@@ -24,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.
|
||||
|
||||
46
deno.json
46
deno.json
@@ -1,31 +1,45 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
"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"
|
||||
|
||||
@@ -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
33
html/index.html
Normal 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>
|
||||
361
install.sh
Executable file
361
install.sh
Executable file
@@ -0,0 +1,361 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stack.Gallery Registry Installer Script
|
||||
# Downloads and installs pre-compiled Stack.Gallery Registry binary from Gitea releases
|
||||
#
|
||||
# Usage:
|
||||
# Direct piped installation (recommended):
|
||||
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
#
|
||||
# With version specification:
|
||||
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0
|
||||
#
|
||||
# Options:
|
||||
# -h, --help Show this help message
|
||||
# --version VERSION Install specific version (e.g., v1.0.0)
|
||||
# --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)
|
||||
# --setup-service Install and enable systemd service
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
SHOW_HELP=0
|
||||
SPECIFIED_VERSION=""
|
||||
INSTALL_DIR="/opt/stack-gallery-registry"
|
||||
SETUP_SERVICE=0
|
||||
GITEA_BASE_URL="https://code.foss.global"
|
||||
GITEA_REPO="stack.gallery/registry"
|
||||
BINARY_NAME="stack-gallery-registry"
|
||||
SERVICE_NAME="stack-gallery-registry"
|
||||
CONFIG_DIR="/etc/stack-gallery-registry"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
SPECIFIED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir)
|
||||
INSTALL_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--setup-service)
|
||||
SETUP_SERVICE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $SHOW_HELP -eq 1 ]; then
|
||||
echo "Stack.Gallery Registry Installer Script"
|
||||
echo "Downloads and installs pre-compiled Stack.Gallery Registry binary"
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install specific version (e.g., v1.0.0)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)"
|
||||
echo " --setup-service Install and enable systemd service"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Install latest version"
|
||||
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
echo " # Install specific version with systemd service"
|
||||
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0 --setup-service"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helper function to detect OS and architecture
|
||||
detect_platform() {
|
||||
local os=$(uname -s)
|
||||
local arch=$(uname -m)
|
||||
|
||||
# Map OS
|
||||
case "$os" in
|
||||
Linux)
|
||||
os_name="linux"
|
||||
;;
|
||||
Darwin)
|
||||
os_name="macos"
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
os_name="windows"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unsupported operating system: $os"
|
||||
echo "Supported: Linux, macOS"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Map architecture
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
arch_name="x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
arch_name="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Construct binary name
|
||||
echo "${BINARY_NAME}-${os_name}-${arch_name}"
|
||||
}
|
||||
|
||||
# Get latest release version from Gitea API
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version from Gitea..." >&2
|
||||
|
||||
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
|
||||
local response=$(curl -sSL "$api_url" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$response" ]; then
|
||||
echo "Error: Failed to fetch latest release information from Gitea API" >&2
|
||||
echo "URL: $api_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract tag_name from JSON response
|
||||
local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: Could not determine latest version from API response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
# Create systemd service file
|
||||
create_service_file() {
|
||||
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
||||
[Unit]
|
||||
Description=Stack.Gallery Registry
|
||||
Documentation=https://code.foss.global/stack.gallery/registry
|
||||
After=network.target mongodb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=${INSTALL_DIR}/${BINARY_NAME} server
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=${SERVICE_NAME}
|
||||
|
||||
# Environment variables (customize these)
|
||||
Environment=PORT=3000
|
||||
Environment=HOST=0.0.0.0
|
||||
# Environment=MONGODB_URL=mongodb://localhost:27017/stackgallery
|
||||
# Environment=S3_ENDPOINT=http://localhost:9000
|
||||
# Environment=S3_ACCESS_KEY=minioadmin
|
||||
# Environment=S3_SECRET_KEY=minioadmin
|
||||
# Environment=S3_BUCKET=registry
|
||||
# Environment=JWT_SECRET=your-secret-here
|
||||
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "Created systemd service file: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||
}
|
||||
|
||||
# Main installation process
|
||||
echo "================================================"
|
||||
echo " Stack.Gallery Registry Installation Script"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Detect platform
|
||||
PLATFORM_BINARY=$(detect_platform)
|
||||
echo "Detected platform: $PLATFORM_BINARY"
|
||||
echo ""
|
||||
|
||||
# Determine version to install
|
||||
if [ -n "$SPECIFIED_VERSION" ]; then
|
||||
VERSION="$SPECIFIED_VERSION"
|
||||
echo "Installing specified version: $VERSION"
|
||||
else
|
||||
VERSION=$(get_latest_version)
|
||||
echo "Installing latest version: $VERSION"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Construct download URL
|
||||
DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${PLATFORM_BINARY}"
|
||||
echo "Download URL: $DOWNLOAD_URL"
|
||||
echo ""
|
||||
|
||||
# Check if service is running and stop it
|
||||
SERVICE_WAS_RUNNING=0
|
||||
if systemctl is-enabled --quiet ${SERVICE_NAME} 2>/dev/null || systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=1
|
||||
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||
echo "Stopping ${SERVICE_NAME} service..."
|
||||
systemctl stop ${SERVICE_NAME}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean installation directory - ensure only binary exists
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Cleaning installation directory: $INSTALL_DIR"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Create fresh installation directory
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
if [ ! -d "$CONFIG_DIR" ]; then
|
||||
echo "Creating config directory: $CONFIG_DIR"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
fi
|
||||
|
||||
# Download binary
|
||||
echo "Downloading Stack.Gallery Registry binary..."
|
||||
TEMP_FILE="$INSTALL_DIR/${BINARY_NAME}.download"
|
||||
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to download binary from $DOWNLOAD_URL"
|
||||
echo ""
|
||||
echo "Please check:"
|
||||
echo " 1. Your internet connection"
|
||||
echo " 2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases"
|
||||
echo " 3. The platform binary is available for this release"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if download was successful (file exists and not empty)
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Error: Downloaded file is empty or does not exist"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Move to final location
|
||||
BINARY_PATH="$INSTALL_DIR/${BINARY_NAME}"
|
||||
mv "$TEMP_FILE" "$BINARY_PATH"
|
||||
|
||||
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
|
||||
echo "Error: Failed to move binary to $BINARY_PATH"
|
||||
rm -f "$TEMP_FILE" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make executable
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to make binary executable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Binary installed successfully to: $BINARY_PATH"
|
||||
echo ""
|
||||
|
||||
# Check if /usr/local/bin is in PATH
|
||||
if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then
|
||||
BIN_DIR="/usr/local/bin"
|
||||
else
|
||||
BIN_DIR="/usr/bin"
|
||||
fi
|
||||
|
||||
# Create symlink for global access
|
||||
ln -sf "$BINARY_PATH" "$BIN_DIR/${BINARY_NAME}"
|
||||
echo "Symlink created: $BIN_DIR/${BINARY_NAME} -> $BINARY_PATH"
|
||||
|
||||
echo ""
|
||||
|
||||
# Setup systemd service if requested
|
||||
if [ $SETUP_SERVICE -eq 1 ]; then
|
||||
echo "Setting up systemd service..."
|
||||
create_service_file
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}
|
||||
echo "Systemd service enabled: ${SERVICE_NAME}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Restart service if it was running before update
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "Restarting ${SERVICE_NAME} service..."
|
||||
systemctl restart ${SERVICE_NAME}
|
||||
echo "Service restarted successfully."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo " Stack.Gallery Registry Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation details:"
|
||||
echo " Binary location: $BINARY_PATH"
|
||||
echo " Symlink location: $BIN_DIR/${BINARY_NAME}"
|
||||
echo " Config directory: $CONFIG_DIR"
|
||||
echo " Version: $VERSION"
|
||||
echo ""
|
||||
|
||||
# Check if configuration exists
|
||||
if [ -f "${CONFIG_DIR}/config.json" ]; then
|
||||
echo "Configuration: ${CONFIG_DIR}/config.json (preserved)"
|
||||
echo ""
|
||||
echo "Your existing configuration has been preserved."
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "The service has been restarted with your current settings."
|
||||
else
|
||||
echo "Start the service with: sudo systemctl start ${SERVICE_NAME}"
|
||||
fi
|
||||
else
|
||||
echo "Get started:"
|
||||
echo " ${BINARY_NAME} --help"
|
||||
echo " ${BINARY_NAME} server # Start the registry server"
|
||||
echo ""
|
||||
echo "Configure environment variables:"
|
||||
echo " - MONGODB_URL: MongoDB connection string"
|
||||
echo " - S3_ENDPOINT: S3-compatible storage endpoint"
|
||||
echo " - S3_ACCESS_KEY: S3 access key"
|
||||
echo " - S3_SECRET_KEY: S3 secret key"
|
||||
echo " - S3_BUCKET: S3 bucket name"
|
||||
echo " - JWT_SECRET: Secret for JWT signing"
|
||||
echo " - PORT: Server port (default: 3000)"
|
||||
echo ""
|
||||
if [ $SETUP_SERVICE -eq 1 ]; then
|
||||
echo "Edit the service file to configure environment:"
|
||||
echo " sudo nano /etc/systemd/system/${SERVICE_NAME}.service"
|
||||
echo " sudo systemctl daemon-reload"
|
||||
echo " sudo systemctl start ${SERVICE_NAME}"
|
||||
else
|
||||
echo "To setup as a systemd service, re-run with --setup-service:"
|
||||
echo " curl -sSL ${GITEA_BASE_URL}/${GITEA_REPO}/raw/branch/main/install.sh | sudo bash -s -- --setup-service"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
15
mod.ts
15
mod.ts
@@ -7,5 +7,16 @@
|
||||
|
||||
import { runCli } from './ts/cli.ts';
|
||||
|
||||
// Run CLI
|
||||
await runCli();
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await runCli();
|
||||
} catch (error) {
|
||||
const debugMode = Deno.args.includes('--debug');
|
||||
if (debugMode) {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
103
npmextra.json
Normal file
103
npmextra.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
package.json
26
package.json
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.0.1",
|
||||
"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\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run 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
5193
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
overrides:
|
||||
'@push.rocks/smartdata': link:../../push.rocks/smartdata
|
||||
490
readme.md
490
readme.md
@@ -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.
|
||||
|
||||
48
test/docker-compose.test.yml
Normal file
48
test/docker-compose.test.yml
Normal 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
310
test/e2e/npm.e2e.test.ts
Normal 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
190
test/e2e/oci.e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal file
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal 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"
|
||||
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal file
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal 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!
|
||||
}
|
||||
```
|
||||
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal file
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal 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!");
|
||||
}
|
||||
}
|
||||
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal file
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal 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!
|
||||
```
|
||||
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal file
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal file
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal 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}!";
|
||||
}
|
||||
}
|
||||
36
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal file
36
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal 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>
|
||||
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal file
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal 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"));
|
||||
}
|
||||
}
|
||||
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal file
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal 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!
|
||||
```
|
||||
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal file
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal 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,
|
||||
};
|
||||
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal file
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal file
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal 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
6
test/fixtures/oci/Dockerfile.simple
vendored
Normal 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"]
|
||||
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal file
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal 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!
|
||||
```
|
||||
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal file
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal 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}!"
|
||||
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal file
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal 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 = ["."]
|
||||
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal file
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal 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!
|
||||
```
|
||||
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal file
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal 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
|
||||
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal file
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal 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
145
test/helpers/auth.helper.ts
Normal 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
106
test/helpers/db.helper.ts
Normal 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;
|
||||
}
|
||||
268
test/helpers/factory.helper.ts
Normal file
268
test/helpers/factory.helper.ts
Normal 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
116
test/helpers/http.helper.ts
Normal 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
85
test/helpers/index.ts
Normal 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';
|
||||
104
test/helpers/storage.helper.ts
Normal file
104
test/helpers/storage.helper.ts
Normal 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;
|
||||
}
|
||||
208
test/helpers/subprocess.helper.ts
Normal file
208
test/helpers/subprocess.helper.ts
Normal 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;
|
||||
}
|
||||
169
test/integration/auth.test.ts
Normal file
169
test/integration/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/integration/organization.test.ts
Normal file
228
test/integration/organization.test.ts
Normal 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
62
test/test.config.ts
Normal 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;
|
||||
}
|
||||
232
test/unit/models/apitoken.test.ts
Normal file
232
test/unit/models/apitoken.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
test/unit/models/organization.test.ts
Normal file
220
test/unit/models/organization.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
239
test/unit/models/package.test.ts
Normal file
239
test/unit/models/package.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
test/unit/models/repository.test.ts
Normal file
285
test/unit/models/repository.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
166
test/unit/models/session.test.ts
Normal file
166
test/unit/models/session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/unit/models/user.test.ts
Normal file
228
test/unit/models/user.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
test/unit/services/auth.service.test.ts
Normal file
224
test/unit/services/auth.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
269
test/unit/services/token.service.test.ts
Normal file
269
test/unit/services/token.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.0.1',
|
||||
version: '1.5.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
456
ts/api/handlers/admin.auth.api.ts
Normal file
456
ts/api/handlers/admin.auth.api.ts
Normal 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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
188
ts/api/handlers/oauth.api.ts
Normal file
188
ts/api/handlers/oauth.api.ts
Normal 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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' } };
|
||||
|
||||
@@ -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' } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
102
ts/api/router.ts
102
ts/api/router.ts
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
12
ts/cli.ts
12
ts/cli.ts
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
250
ts/models/auth.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
141
ts/models/external.identity.ts
Normal file
141
ts/models/external.identity.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
89
ts/models/platform.settings.ts
Normal file
89
ts/models/platform.settings.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
51
ts/opsserver/classes.opsserver.ts
Normal file
51
ts/opsserver/classes.opsserver.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
380
ts/opsserver/handlers/admin.handler.ts
Normal file
380
ts/opsserver/handlers/admin.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
ts/opsserver/handlers/audit.handler.ts
Normal file
105
ts/opsserver/handlers/audit.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
ts/opsserver/handlers/auth.handler.ts
Normal file
263
ts/opsserver/handlers/auth.handler.ts
Normal 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' },
|
||||
);
|
||||
}
|
||||
9
ts/opsserver/handlers/index.ts
Normal file
9
ts/opsserver/handlers/index.ts
Normal 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';
|
||||
160
ts/opsserver/handlers/oauth.handler.ts
Normal file
160
ts/opsserver/handlers/oauth.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
548
ts/opsserver/handlers/organization.handler.ts
Normal file
548
ts/opsserver/handlers/organization.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
ts/opsserver/handlers/package.handler.ts
Normal file
315
ts/opsserver/handlers/package.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
ts/opsserver/handlers/repository.handler.ts
Normal file
272
ts/opsserver/handlers/repository.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
ts/opsserver/handlers/token.handler.ts
Normal file
198
ts/opsserver/handlers/token.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
ts/opsserver/handlers/user.handler.ts
Normal file
263
ts/opsserver/handlers/user.handler.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
314
ts/registry.ts
314
ts/registry.ts
@@ -4,10 +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 { 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
|
||||
@@ -40,7 +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 opsServer: OpsServer | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
@@ -83,6 +119,7 @@ export class StackGalleryRegistry {
|
||||
// Initialize storage hooks
|
||||
this.storageHooks = new StackGalleryStorageHooks({
|
||||
bucket: this.smartBucket,
|
||||
bucketName: this.config.s3Bucket,
|
||||
basePath: this.config.storagePath!,
|
||||
});
|
||||
|
||||
@@ -92,23 +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 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');
|
||||
@@ -131,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}`);
|
||||
@@ -149,106 +193,185 @@ 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Serve static UI files
|
||||
return await this.serveStaticFile(path);
|
||||
return this.serveStaticFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from UI dist
|
||||
* Convert a Deno Request to smartregistry IRequestContext
|
||||
*/
|
||||
private async serveStaticFile(path: string): Promise<Response> {
|
||||
const uiDistPath = './ui/dist/registry-ui/browser';
|
||||
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;
|
||||
});
|
||||
|
||||
// Map path to file
|
||||
let filePath = path === '/' ? '/index.html' : path;
|
||||
|
||||
// Content type mapping
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
try {
|
||||
const fullPath = `${uiDistPath}${filePath}`;
|
||||
const file = await Deno.readFile(fullPath);
|
||||
const ext = filePath.substring(filePath.lastIndexOf('.'));
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
|
||||
return new Response(file, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
} catch {
|
||||
// For SPA routing, serve index.html for unknown paths
|
||||
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 indexFile = await Deno.readFile(`${uiDistPath}/index.html`);
|
||||
return new Response(indexFile, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
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 {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests
|
||||
* Convert smartregistry IResponse to Deno Response
|
||||
*/
|
||||
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 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 await this.apiRouter.handle(request);
|
||||
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 bundled file
|
||||
const file = bundledFileMap.get(filePath);
|
||||
if (file) {
|
||||
return new Response(file.data, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': file.contentType },
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for unknown paths
|
||||
const indexFile = bundledFileMap.get('/index.html');
|
||||
if (indexFile) {
|
||||
return new Response(indexFile.data, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TypedRequest calls
|
||||
*/
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,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');
|
||||
@@ -346,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',
|
||||
@@ -366,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
Reference in New Issue
Block a user