8 Commits

Author SHA1 Message Date
392060bf23 v1.7.0
Some checks failed
Release / build-and-release (push) Failing after 7s
2026-03-20 17:07:12 +00:00
8cb5e4fa96 feat(organization): add organization rename redirects and redirect management endpoints 2026-03-20 17:07:12 +00:00
c60a0ed536 v1.6.0
Some checks failed
Release / build-and-release (push) Failing after 23s
2026-03-20 16:48:04 +00:00
087b8c0bb3 feat(web-organizations): add organization detail editing and isolate detail view state from global navigation 2026-03-20 16:48:04 +00:00
ffe7ffbde9 v1.5.1
Some checks failed
Release / build-and-release (push) Failing after 26s
2026-03-20 16:44:44 +00:00
b9a3d79b5f fix(web-app): update dashboard navigation to use the router directly and refresh admin tabs on login changes 2026-03-20 16:44:44 +00:00
aacf30e582 v1.5.0
Some checks failed
Release / build-and-release (push) Failing after 22s
2026-03-20 16:43:44 +00:00
d4f758ce0f feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend 2026-03-20 16:43:44 +00:00
160 changed files with 12743 additions and 14862 deletions

8
.gitignore vendored
View File

@@ -4,15 +4,14 @@ node_modules/
# Build outputs
dist/
ui/dist/
.angular/
out-tsc/
# tsdeno temporary files
package.json.bak
# Generated files
ts/embedded-ui.generated.ts
ts_bundled/
dist_ts_web/
# Deno
.deno/
@@ -64,8 +63,5 @@ stories/
package-lock.json
yarn.lock
# Angular cache
.angular/cache/
# TypeScript incremental compilation
*.tsbuildinfo

View File

@@ -1,72 +1,144 @@
# Changelog
## 2026-03-20 - 1.7.0 - feat(organization)
add organization rename redirects and redirect management endpoints
- add OrgRedirect model and resolve organizations by historical names
- support renaming organizations while preserving the previous handle as a redirect alias
- add typed requests to list and delete organization redirects with admin permission checks
- allow organization update actions to send name changes
## 2026-03-20 - 1.6.0 - feat(web-organizations)
add organization detail editing and isolate detail view state from global navigation
- adds an update organization action to persist organization detail edits from the detail view
- updates organization and package views to track selected detail entities locally instead of mutating global ui state
- preserves resolved app shell tabs for role-based filtering after async tab loading
- includes type-cast fixes for admin auth provider responses and bundled file Response bodies
## 2026-03-20 - 1.5.1 - fix(web-app)
update dashboard navigation to use the router directly and refresh admin tabs on login changes
- removes the global router workaround in the dashboard and imports appRouter directly
- re-filters resolved view tabs when login state changes so the Admin tab matches system admin access
- adds dashboard navigation support for the organizations view
## 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
- 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
- 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
- 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
- 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
- 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
- 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
- UI: api.service types and methods updated — IToken and ITokenScope include
organizationId/createdById; getTokens and createToken now support an organizationId parameter and
scoped scopes
- .gitignore: added stories/ to ignore
## 2025-11-28 - 1.1.0 - feat(registry)
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated embedded UI file.
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server
restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and
compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated
embedded UI file.
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected clients for hot-reload.
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS and trigger page reloads with exponential reconnect.
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected
clients for hot-reload.
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at
/ws/reload.
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS
and trigger page reloads with exponential reconnect.
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to
index.html.
- Ignore generated embedded UI file (ts/embedded-ui.generated.ts) in .gitignore.
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and a release task to bundle + compile.
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and
a release task to bundle + compile.
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task
bundle-ui:watch).
## 2025-11-28 - 1.0.1 - fix(smartdata)
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
- Updated deno.json imports mapping for @push.rocks/smartdata from ^7.0.9 to ^7.0.13
## 2025-11-28 - 1.0.0 - Initial release
Release with core features, UI, and project scaffolding.
- Implemented account settings and API tokens management.
@@ -85,8 +157,10 @@ Release with core features, UI, and project scaffolding.
- Dependency updates and fixes.
## 2025-11-27 - 2025-11-28 - unknown -> 1.0.0 - housekeeping / duplicate commits
Minor housekeeping and duplicate commits consolidated into the 1.0.0 release.
- Added initial README with project overview, features, and setup instructions.
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown version) into the 1.0.0 release.
- Miscellaneous UI tweaks and dependency updates.
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown
version) into the 1.0.0 release.
- Miscellaneous UI tweaks and dependency updates.

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.4.2",
"version": "1.7.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -12,9 +12,8 @@
"test:e2e": "deno test --allow-all --no-check test/e2e/",
"test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait",
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
"build": "cd ui && pnpm run build",
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
"build-ui": "npx tsbundle",
"watch": "npx tswatch",
"compile": "tsdeno compile",
"check": "deno check mod.ts",
"fmt": "deno fmt",
@@ -35,7 +34,11 @@
"@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",

3058
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

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

33
html/index.html Normal file
View File

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

View File

@@ -64,5 +64,40 @@
}
]
},
"@ship.zone/szci": {}
}
"@ship.zone/szci": {},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
]
},
"@git.zone/tswatch": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
],
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
"command": "deno run --allow-all mod.ts server --ephemeral",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
}
}

View File

@@ -1,13 +1,13 @@
{
"name": "@stack.gallery/registry",
"version": "1.4.2",
"version": "1.7.0",
"private": true,
"description": "Enterprise-grade multi-protocol package registry",
"type": "module",
"scripts": {
"start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
"watch": "concurrently --kill-others --names \"BACKEND,UI,BUNDLER\" --prefix-colors \"cyan,magenta,yellow\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\" \"deno task bundle-ui:watch\"",
"watch": "tswatch",
"build": "deno task check",
"test": "deno task test",
"lint": "deno task lint",
@@ -26,9 +26,19 @@
],
"author": "Stack.Gallery",
"license": "MIT",
"dependencies": {
"@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",
"concurrently": "^9.1.2"
"@git.zone/tswatch": "^3.1.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}

2926
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

270
readme.md
View File

@@ -1,14 +1,21 @@
# @stack.gallery/registry 📦
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.
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
- 🔌 **7 Protocol Support** — NPM, OCI/Docker, Maven, Cargo, PyPI, Composer, RubyGems via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry)
- 🔌 **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
@@ -33,13 +40,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
# Install specific version
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.3.0
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/`
@@ -63,24 +71,25 @@ The registry is available at `http://localhost:3000`.
## ⚙️ Configuration
Configuration is loaded from **environment variables** (production) or from **`.nogit/env.json`** when using the `--ephemeral` flag (development).
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 |
| 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`:**
@@ -99,33 +108,39 @@ Configuration is loaded from **environment variables** (production) or from **`.
## 🔌 Protocol Endpoints
Each protocol is handled natively via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry). Point your package manager at the registry:
Each protocol is handled natively via
[`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry). Point your package
manager at the registry:
| 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` |
| 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` |
Authentication works with **Bearer tokens** (API tokens prefixed `srg_`) and **Basic auth** (email:password or username:token).
Authentication works with **Bearer tokens** (API tokens prefixed `srg_`) and **Basic auth**
(email:password or username:token).
## 🔐 Authentication & Security
### 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
- **Encrypted secrets** — Provider client secrets and bind passwords are stored AES-256-GCM
encrypted
### RBAC Permissions
@@ -143,6 +158,7 @@ Platform Admin (full access)
### 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**
@@ -150,88 +166,98 @@ Tokens are prefixed with `srg_` and can be scoped to:
## 📡 REST API
All management endpoints live under `/api/v1/`. Authenticated via `Authorization: Bearer <jwt_or_api_token>`.
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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Method | Endpoint | Description |
| ------ | ----------------------- | ------------------------------------------------ |
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
## 🏗️ Architecture
@@ -268,6 +294,9 @@ registry/
│ │ ├── 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
@@ -277,17 +306,17 @@ registry/
## 🔧 Technology Stack
| 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 |
| 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 |
## 🛠️ Development
@@ -327,7 +356,8 @@ 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)
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`.
@@ -344,21 +374,31 @@ For example: `packages/npm/myorg/mypackage/1.0.0/mypackage-1.0.0.tgz`
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
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 or third parties, and are not included within the scope of the MIT license granted herein.
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.
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 further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
By using this repository, you acknowledge that you have read this section, agree to comply with its
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
Capital GmbH of any derivative works.

View File

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

View File

@@ -1,18 +1,18 @@
version: "3.8"
version: '3.8'
services:
mongodb-test:
image: mongo:7
container_name: stack-gallery-test-mongo
ports:
- "27117:27017"
- '27117:27017'
environment:
MONGO_INITDB_ROOT_USERNAME: testadmin
MONGO_INITDB_ROOT_PASSWORD: testpass
tmpfs:
- /data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
@@ -21,8 +21,8 @@ services:
image: minio/minio:latest
container_name: stack-gallery-test-minio
ports:
- "9100:9000"
- "9101:9001"
- '9100:9000'
- '9101:9001'
environment:
MINIO_ROOT_USER: testadmin
MINIO_ROOT_PASSWORD: testpassword
@@ -30,7 +30,7 @@ services:
tmpfs:
- /data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 5s
timeout: 5s
retries: 5

View File

@@ -6,25 +6,25 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
createTestApiToken,
clients,
skipIfMissing,
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'
'../fixtures/npm/@stack-test/demo-package',
);
describe('NPM E2E: Full lifecycle', () => {
@@ -98,7 +98,7 @@ describe('NPM E2E: Full lifecycle', () => {
const result = await clients.npm.publish(
FIXTURE_DIR,
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
apiToken,
);
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
@@ -120,20 +120,28 @@ describe('NPM E2E: Full lifecycle', () => {
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
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
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 } }
[
'npm',
'view',
'@stack-test/demo-package',
'--registry',
`${registryUrl}/-/npm/${testOrgName}/`,
],
{ env: { npm_config__authToken: apiToken } },
);
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
@@ -159,32 +167,36 @@ describe('NPM E2E: Full lifecycle', () => {
try {
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
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
apiToken,
);
// Create package.json in temp dir
await Deno.writeTextFile(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'test-install', version: '1.0.0' })
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}`
`@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
tempDir,
);
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
@@ -213,33 +225,41 @@ describe('NPM E2E: Full lifecycle', () => {
// First publish
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
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
apiToken,
);
// Unpublish
const unpublishResult = await clients.npm.unpublish(
'@stack-test/demo-package@1.0.0',
`${registryUrl}/-/npm/${testOrgName}/`,
apiToken
apiToken,
);
assertEquals(
unpublishResult.success,
true,
`npm unpublish failed: ${unpublishResult.stderr}`
`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 } }
[
'npm',
'view',
'@stack-test/demo-package',
'--registry',
`${registryUrl}/-/npm/${testOrgName}/`,
],
{ env: { npm_config__authToken: apiToken } },
);
// Should fail since package was unpublished

View File

@@ -6,24 +6,24 @@
*/
import { assertEquals } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import * as path from '@std/path';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
createTestApiToken,
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'
'../fixtures/oci',
);
describe('OCI E2E: Full lifecycle', () => {

View File

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

View File

@@ -1,34 +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>
<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>
<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>
<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>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@@ -5,5 +5,5 @@
module.exports = {
name: 'demo-package',
greet: (name) => `Hello, ${name}!`,
version: () => require('./package.json').version
version: () => require('./package.json').version,
};

View File

@@ -6,7 +6,11 @@ import { User } from '../../ts/models/user.ts';
import { ApiToken } from '../../ts/models/apitoken.ts';
import { AuthService } from '../../ts/services/auth.service.ts';
import { TokenService } from '../../ts/services/token.service.ts';
import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts';
import type {
ITokenScope,
TRegistryProtocol,
TUserStatus,
} from '../../ts/interfaces/auth.interfaces.ts';
import { testConfig } from '../test.config.ts';
const TEST_PASSWORD = 'TestPassword123!';
@@ -25,7 +29,7 @@ export interface ICreateTestUserOptions {
* Create a test user with sensible defaults
*/
export async function createTestUser(
overrides: ICreateTestUserOptions = {}
overrides: ICreateTestUserOptions = {},
): Promise<{ user: User; password: string }> {
const uniqueId = crypto.randomUUID().slice(0, 8);
const password = overrides.password || TEST_PASSWORD;
@@ -61,7 +65,7 @@ export async function createAdminUser(): Promise<{ user: User; password: string
*/
export async function loginUser(
email: string,
password: string
password: string,
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
const authService = new AuthService({
jwtSecret: testConfig.jwt.secret,
@@ -96,7 +100,7 @@ export interface ICreateTestApiTokenOptions {
* Create test API token
*/
export async function createTestApiToken(
options: ICreateTestApiTokenOptions
options: ICreateTestApiTokenOptions,
): Promise<{ rawToken: string; token: ApiToken }> {
const tokenService = new TokenService();
@@ -127,7 +131,7 @@ export function createAuthHeader(token: string): { Authorization: string } {
*/
export function createBasicAuthHeader(
username: string,
password: string
password: string,
): { Authorization: string } {
const credentials = btoa(`${username}:${password}`);
return { Authorization: `Basic ${credentials}` };

View File

@@ -15,7 +15,7 @@ let isConnected = false;
// We need to patch the global db export since models reference it
// This is done by re-initializing with the test config
import { initDb, closeDb } from '../../ts/models/db.ts';
import { closeDb, initDb } from '../../ts/models/db.ts';
/**
* Initialize test database with unique name per test run

View File

@@ -11,10 +11,10 @@ import { Package } from '../../ts/models/package.ts';
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
import type {
TOrganizationRole,
TTeamRole,
TRegistryProtocol,
TRepositoryRole,
TRepositoryVisibility,
TRegistryProtocol,
TTeamRole,
} from '../../ts/interfaces/auth.interfaces.ts';
export interface ICreateTestOrganizationOptions {
@@ -29,7 +29,7 @@ export interface ICreateTestOrganizationOptions {
* Create test organization
*/
export async function createTestOrganization(
options: ICreateTestOrganizationOptions
options: ICreateTestOrganizationOptions,
): Promise<Organization> {
const uniqueId = crypto.randomUUID().slice(0, 8);
@@ -53,7 +53,7 @@ export async function createTestOrganization(
*/
export async function createOrgWithOwner(
ownerId: string,
orgOptions?: Partial<ICreateTestOrganizationOptions>
orgOptions?: Partial<ICreateTestOrganizationOptions>,
): Promise<{
organization: Organization;
membership: OrganizationMember;
@@ -82,7 +82,7 @@ export async function addOrgMember(
organizationId: string,
userId: string,
role: TOrganizationRole = 'member',
invitedBy?: string
invitedBy?: string,
): Promise<OrganizationMember> {
const membership = await OrganizationMember.addMember({
organizationId,
@@ -113,7 +113,7 @@ export interface ICreateTestRepositoryOptions {
* Create test repository
*/
export async function createTestRepository(
options: ICreateTestRepositoryOptions
options: ICreateTestRepositoryOptions,
): Promise<Repository> {
const uniqueId = crypto.randomUUID().slice(0, 8);
@@ -152,7 +152,7 @@ export async function createTestTeam(options: ICreateTestTeamOptions): Promise<T
export async function addTeamMember(
teamId: string,
userId: string,
role: TTeamRole = 'member'
role: TTeamRole = 'member',
): Promise<TeamMember> {
const member = new TeamMember();
member.id = await TeamMember.getNewId();
@@ -176,7 +176,7 @@ export interface IGrantRepoPermissionOptions {
* Grant repository permission
*/
export async function grantRepoPermission(
options: IGrantRepoPermissionOptions
options: IGrantRepoPermissionOptions,
): Promise<RepositoryPermission> {
const perm = new RepositoryPermission();
perm.id = await RepositoryPermission.getNewId();

View File

@@ -75,7 +75,7 @@ export const del = (path: string, headers?: Record<string, string>) =>
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)}`
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`,
);
}
}
@@ -98,7 +98,7 @@ export function assertBodyHas(response: ITestResponse, keys: string[]): void {
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)}`
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`,
);
}
}

View File

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

View File

@@ -22,7 +22,7 @@ export interface ICommandOptions {
*/
export async function runCommand(
cmd: string[],
options: ICommandOptions = {}
options: ICommandOptions = {},
): Promise<ICommandResult> {
const { cwd, env, timeout = 60000, stdin } = options;
@@ -116,7 +116,7 @@ export const clients = {
publish: (dir: string, registry: string, token: string) =>
runCommand(
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
{ cwd: dir }
{ cwd: dir },
),
yank: (crate: string, version: string, token: string) =>
runCommand([
@@ -164,7 +164,7 @@ export const clients = {
'--repository',
JSON.stringify({ type: 'composer', url: repository }),
],
{ cwd: dir }
{ cwd: dir },
),
},
@@ -190,7 +190,7 @@ export const clients = {
`-Dusername=${username}`,
`-Dpassword=${password}`,
],
{ cwd: dir }
{ cwd: dir },
),
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
},

View File

@@ -4,16 +4,16 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
assertStatus,
cleanupTestDb,
createAuthHeader,
createTestUser,
get,
post,
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
post,
get,
assertStatus,
createAuthHeader,
} from '../helpers/index.ts';
describe('Auth API Integration', () => {
@@ -126,7 +126,7 @@ describe('Auth API Integration', () => {
// Get current user
const meResponse = await get(
'/api/v1/auth/me',
createAuthHeader(loginBody.accessToken as string)
createAuthHeader(loginBody.accessToken as string),
);
assertStatus(meResponse, 200);

View File

@@ -4,19 +4,19 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
assertStatus,
cleanupTestDb,
createAuthHeader,
createTestUser,
del,
get,
loginUser,
post,
get,
put,
del,
assertStatus,
createAuthHeader,
setupTestDb,
teardownTestDb,
} from '../helpers/index.ts';
describe('Organization API Integration', () => {
@@ -48,7 +48,7 @@ describe('Organization API Integration', () => {
displayName: 'My Organization',
description: 'A test organization',
},
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 201);
@@ -64,7 +64,7 @@ describe('Organization API Integration', () => {
name: 'push.rocks',
displayName: 'Push Rocks',
},
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 201);
@@ -76,13 +76,13 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'duplicate', displayName: 'First' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await post(
'/api/v1/organizations',
{ name: 'duplicate', displayName: 'Second' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 409);
@@ -92,7 +92,7 @@ describe('Organization API Integration', () => {
const response = await post(
'/api/v1/organizations',
{ name: '.invalid', displayName: 'Invalid' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 400);
@@ -105,12 +105,12 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'org1', displayName: 'Org 1' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
await post(
'/api/v1/organizations',
{ name: 'org2', displayName: 'Org 2' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
@@ -126,7 +126,7 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'get-me', displayName: 'Get Me' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken));
@@ -139,7 +139,7 @@ describe('Organization API Integration', () => {
it('should return 404 for non-existent org', async () => {
const response = await get(
'/api/v1/organizations/non-existent',
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 404);
@@ -151,13 +151,13 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'update-me', displayName: 'Original' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await put(
'/api/v1/organizations/update-me',
{ displayName: 'Updated', description: 'New description' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 200);
@@ -172,7 +172,7 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'delete-me', displayName: 'Delete Me' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
@@ -182,7 +182,7 @@ describe('Organization API Integration', () => {
// Verify deleted
const getResponse = await get(
'/api/v1/organizations/delete-me',
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(getResponse, 404);
});
@@ -193,12 +193,12 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'members-org', displayName: 'Members Org' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await get(
'/api/v1/organizations/members-org/members',
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 200);
@@ -213,13 +213,13 @@ describe('Organization API Integration', () => {
await post(
'/api/v1/organizations',
{ name: 'add-member-org', displayName: 'Add Member Org' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
const response = await post(
'/api/v1/organizations/add-member-org/members',
{ userId: newUser.id, role: 'member' },
createAuthHeader(accessToken)
createAuthHeader(accessToken),
);
assertStatus(response, 201);

View File

@@ -7,10 +7,10 @@ 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 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';

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { 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', () => {

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { 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', () => {
@@ -73,7 +73,7 @@ describe('Organization Model', () => {
});
},
Error,
'lowercase alphanumeric'
'lowercase alphanumeric',
);
});
@@ -87,7 +87,7 @@ describe('Organization Model', () => {
});
},
Error,
'lowercase alphanumeric'
'lowercase alphanumeric',
);
});
@@ -101,7 +101,7 @@ describe('Organization Model', () => {
});
},
Error,
'lowercase alphanumeric'
'lowercase alphanumeric',
);
});
@@ -115,7 +115,7 @@ describe('Organization Model', () => {
});
},
Error,
'lowercase alphanumeric'
'lowercase alphanumeric',
);
});

View File

@@ -3,14 +3,14 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
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';

View File

@@ -3,13 +3,13 @@
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
cleanupTestDb,
createOrgWithOwner,
createTestUser,
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
} from '../../helpers/index.ts';
import { Repository } from '../../../ts/models/repository.ts';
@@ -103,7 +103,7 @@ describe('Repository Model', () => {
});
},
Error,
'already exists'
'already exists',
);
});
@@ -137,7 +137,7 @@ describe('Repository Model', () => {
});
},
Error,
'lowercase alphanumeric'
'lowercase alphanumeric',
);
});

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { 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', () => {
@@ -70,9 +70,21 @@ describe('Session Model', () => {
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' });
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);
@@ -110,9 +122,21 @@ describe('Session Model', () => {
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' });
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);

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
import { 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', () => {

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { 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';

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { 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';
@@ -39,8 +39,8 @@ describe('TokenService', () => {
assertExists(result.rawToken);
assertExists(result.token);
// Check token format: srg_{prefix}_{random}
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
// 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);
@@ -111,13 +111,19 @@ describe('TokenService', () => {
it('should reject invalid token format', async () => {
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'INVALID_TOKEN_FORMAT');
});
it('should reject non-existent token', async () => {
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
// Must match srg_ prefix + 64 hex chars = 68 total
const validation = await tokenService.validateToken(
'srg_0000000000000000000000000000000000000000000000000000000000000000',
'127.0.0.1',
);
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
});
it('should reject revoked token', async () => {
@@ -132,7 +138,9 @@ describe('TokenService', () => {
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
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 () => {
@@ -150,7 +158,8 @@ describe('TokenService', () => {
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'TOKEN_EXPIRED');
});
it('should record usage on validation', async () => {

View File

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

View File

@@ -113,7 +113,9 @@ export class AdminAuthApi {
});
} else if (body.type === 'ldap' && body.ldapConfig) {
// Encrypt bind password
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
const encryptedPassword = await cryptoService.encrypt(
body.ldapConfig.bindPasswordEncrypted,
);
provider = await AuthProvider.createLdapProvider({
name: body.name,
@@ -228,7 +230,7 @@ export class AdminAuthApi {
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
) {
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
body.oauthConfig.clientSecretEncrypted
body.oauthConfig.clientSecretEncrypted,
);
}
@@ -245,7 +247,7 @@ export class AdminAuthApi {
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
) {
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
body.ldapConfig.bindPasswordEncrypted
body.ldapConfig.bindPasswordEncrypted,
);
}

View File

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

View File

@@ -93,7 +93,7 @@ export class OAuthApi {
const result = await externalAuthService.handleOAuthCallback(
{ code, state },
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
{ ipAddress: ctx.ip, userAgent: ctx.userAgent },
);
if (!result.success) {

View File

@@ -208,7 +208,10 @@ export class OrganizationApi {
}
// Check admin permission using org.id
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
org.id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
@@ -319,13 +322,13 @@ export class OrganizationApi {
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 {
@@ -356,7 +359,10 @@ export class OrganizationApi {
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
org.id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
@@ -431,7 +437,10 @@ export class OrganizationApi {
}
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
org.id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
@@ -492,7 +501,10 @@ export class OrganizationApi {
// Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
org.id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}

View File

@@ -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) {
@@ -213,7 +213,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {
@@ -267,7 +267,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {

View File

@@ -5,7 +5,7 @@
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import { Repository, Organization } from '../../models/index.ts';
import { Organization, Repository } from '../../models/index.ts';
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
export class RepositoryApi {
@@ -28,7 +28,7 @@ export class RepositoryApi {
try {
const repositories = await this.permissionService.getAccessibleRepositories(
ctx.actor.userId,
orgId
orgId,
);
return {
@@ -131,7 +131,10 @@ export class RepositoryApi {
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
return {
status: 400,
body: { error: 'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores' },
body: {
error:
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
},
};
}
@@ -198,7 +201,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' } };
@@ -252,7 +255,7 @@ export class RepositoryApi {
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };

View File

@@ -33,7 +33,10 @@ export class TokenApi {
let tokens;
if (organizationId) {
// Check if user can manage org
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
organizationId,
);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
}
@@ -119,7 +122,10 @@ export class TokenApi {
// If creating org token, verify permission
if (organizationId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
organizationId,
);
if (!canManage) {
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
}
@@ -181,7 +187,7 @@ export class TokenApi {
if (anyToken?.organizationId) {
const canManage = await this.permissionService.canManageOrganization(
ctx.actor.userId,
anyToken.organizationId
anyToken.organizationId,
);
if (canManage) {
token = anyToken;

View File

@@ -104,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));
@@ -138,14 +170,46 @@ export class ApiRouter {
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));
this.addRoute(
'GET',
'/api/v1/admin/auth/providers',
(ctx) => this.adminAuthApi.listProviders(ctx),
);
this.addRoute(
'POST',
'/api/v1/admin/auth/providers',
(ctx) => this.adminAuthApi.createProvider(ctx),
);
this.addRoute(
'GET',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.getProvider(ctx),
);
this.addRoute(
'PUT',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.updateProvider(ctx),
);
this.addRoute(
'DELETE',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.deleteProvider(ctx),
);
this.addRoute(
'POST',
'/api/v1/admin/auth/providers/:id/test',
(ctx) => this.adminAuthApi.testProvider(ctx),
);
this.addRoute(
'GET',
'/api/v1/admin/auth/settings',
(ctx) => this.adminAuthApi.getSettings(ctx),
);
this.addRoute(
'PUT',
'/api/v1/admin/auth/settings',
(ctx) => this.adminAuthApi.updateSettings(ctx),
);
}
/**

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/au
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken> implements IApiToken {
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
implements IApiToken {
@plugins.smartdata.unI()
public id: string = '';
@@ -150,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
@@ -163,7 +164,9 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
// Check action
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) {
continue;
}
return true;
}

View File

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

View File

@@ -5,13 +5,13 @@
import * as plugins from '../plugins.ts';
import type {
IAuthProvider,
TAuthProviderType,
TAuthProviderStatus,
IOAuthConfig,
ILdapConfig,
IAttributeMapping,
IAuthProvider,
ILdapConfig,
IOAuthConfig,
IProvisioningSettings,
TAuthProviderStatus,
TAuthProviderType,
} from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@@ -27,10 +27,8 @@ const DEFAULT_PROVISIONING: IProvisioningSettings = {
};
@plugins.smartdata.Collection(() => db)
export class AuthProvider
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
implements IAuthProvider
{
export class AuthProvider extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
implements IAuthProvider {
@plugins.smartdata.unI()
public id: string = '';

View File

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

View File

@@ -10,8 +10,7 @@ import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class ExternalIdentity
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
implements IExternalIdentity
{
implements IExternalIdentity {
@plugins.smartdata.unI()
public id: string = '';
@@ -55,7 +54,7 @@ export class ExternalIdentity
*/
public static async findByExternalId(
providerId: string,
externalId: string
externalId: string,
): Promise<ExternalIdentity | null> {
return await ExternalIdentity.getInstance({ providerId, externalId });
}
@@ -72,7 +71,7 @@ export class ExternalIdentity
*/
public static async findByUserAndProvider(
userId: string,
providerId: string
providerId: string,
): Promise<ExternalIdentity | null> {
return await ExternalIdentity.getInstance({ userId, providerId });
}

View File

@@ -2,7 +2,7 @@
* Model exports
*/
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
export { closeDb, getDb, initDb, isDbConnected } from './db.ts';
export { User } from './user.ts';
export { Organization } from './organization.ts';
export { OrganizationMember } from './organization.member.ts';
@@ -15,6 +15,9 @@ export { ApiToken } from './apitoken.ts';
export { Session } from './session.ts';
export { AuditLog } from './auditlog.ts';
// Organization redirects
export { OrgRedirect } from './org.redirect.ts';
// External authentication models
export { AuthProvider } from './auth.provider.ts';
export { ExternalIdentity } from './external.identity.ts';

59
ts/models/org.redirect.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* OrgRedirect model - stores old org handles as redirect aliases
* When an org is renamed, the old name becomes a redirect pointing to the org.
* Redirects can be explicitly deleted by org admins.
*/
import * as plugins from '../plugins.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class OrgRedirect extends plugins.smartdata.SmartDataDbDoc<OrgRedirect, OrgRedirect> {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index({ unique: true })
public oldName: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
/**
* Create a redirect from an old org name to the current org
*/
public static async create(oldName: string, organizationId: string): Promise<OrgRedirect> {
const redirect = new OrgRedirect();
redirect.id = `redirect:${oldName}`;
redirect.oldName = oldName;
redirect.organizationId = organizationId;
redirect.createdAt = new Date();
await redirect.save();
return redirect;
}
/**
* Find a redirect by the old name
*/
public static async findByName(name: string): Promise<OrgRedirect | null> {
return await OrgRedirect.getInstance({ oldName: name } as any);
}
/**
* Get all redirects for an organization
*/
public static async getByOrgId(organizationId: string): Promise<OrgRedirect[]> {
return await OrgRedirect.getInstances({ organizationId } as any);
}
/**
* Find a redirect by ID
*/
public static async findById(id: string): Promise<OrgRedirect | null> {
return await OrgRedirect.getInstance({ id } as any);
}
}

View File

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

View File

@@ -18,7 +18,8 @@ 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 = '';
@@ -92,7 +93,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
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 and dots'
'Organization name must be lowercase alphanumeric with optional hyphens and dots',
);
}

View File

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

View File

@@ -4,7 +4,7 @@
*/
import * as plugins from '../plugins.ts';
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
import type { IPlatformAuthSettings, IPlatformSettings } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
@@ -16,8 +16,7 @@ const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
@plugins.smartdata.Collection(() => db)
export class PlatformSettings
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
implements IPlatformSettings
{
implements IPlatformSettings {
@plugins.smartdata.unI()
public id: string = 'singleton';
@@ -51,7 +50,7 @@ export class PlatformSettings
*/
public async updateAuthSettings(
settings: Partial<IPlatformAuthSettings>,
updatedById?: string
updatedById?: string,
): Promise<void> {
this.auth = { ...this.auth, ...settings };
this.updatedAt = new Date();

View File

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

View File

@@ -3,11 +3,16 @@
*/
import * as plugins from '../plugins.ts';
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import type {
IRepository,
TRegistryProtocol,
TRepositoryVisibility,
} from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository> implements IRepository {
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
implements IRepository {
@plugins.smartdata.unI()
public id: string = '';
@@ -70,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
@@ -105,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,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,380 @@
import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
import { cryptoService } from '../../services/crypto.service.ts';
import { externalAuthService } from '../../services/external.auth.service.ts';
import { AuditService } from '../../services/audit.service.ts';
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get Admin Providers
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProviders>(
'getAdminProviders',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const providers = await AuthProvider.getAllProviders();
return {
providers: providers.map((p) => p.toAdminInfo() as unknown as interfaces.data.IAuthProvider),
};
} 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() as unknown as interfaces.data.IAuthProvider };
} 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() as unknown as interfaces.data.IAuthProvider };
} 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() as unknown as interfaces.data.IAuthProvider };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');
}
},
),
);
// Delete Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAdminProvider>(
'deleteAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const provider = await AuthProvider.findById(dataArg.providerId);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
// Soft delete - disable instead of removing
provider.status = 'disabled';
await provider.save();
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
resourceId: provider.id,
success: true,
metadata: { providerName: provider.name },
});
return { message: 'Provider disabled' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete provider');
}
},
),
);
// Test Admin Provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestAdminProvider>(
'testAdminProvider',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const result = await externalAuthService.testConnection(dataArg.providerId);
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
resourceId: dataArg.providerId,
success: result.success,
metadata: {
result: result.success ? 'success' : 'failure',
latencyMs: result.latencyMs,
error: result.error,
},
});
return { result };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to test provider');
}
},
),
);
// Get Platform Settings
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformSettings>(
'getPlatformSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const settings = await PlatformSettings.get();
return {
settings: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
updatedById: settings.updatedById,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get settings');
}
},
),
);
// Update Platform Settings
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdatePlatformSettings>(
'updatePlatformSettings',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
try {
const settings = await PlatformSettings.get();
if (dataArg.auth) {
await settings.updateAuthSettings(dataArg.auth as any, dataArg.identity.userId);
}
// Audit log
await AuditService.withContext({
actorId: dataArg.identity.userId,
actorType: 'user',
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
resourceId: 'platform-settings',
success: true,
});
return {
settings: {
id: settings.id,
auth: settings.auth,
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
updatedById: settings.updatedById,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update settings');
}
},
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,651 @@
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, OrgRedirect, 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> {
if (idOrName.startsWith('Organization:')) {
return await Organization.findById(idOrName);
}
// Try direct name lookup first
const org = await Organization.findByName(idOrName);
if (org) return org;
// Check redirects
const redirect = await OrgRedirect.findByName(idOrName);
if (redirect) {
return await Organization.findById(redirect.organizationId);
}
return null;
}
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');
}
// Handle rename
if (dataArg.name && dataArg.name !== org.name) {
const newName = dataArg.name;
// Validate name format
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(newName)) {
throw new plugins.typedrequest.TypedResponseError(
'Name must be lowercase alphanumeric with optional hyphens and dots',
);
}
// Check new name not taken by another org
const existingOrg = await Organization.findByName(newName);
if (existingOrg && existingOrg.id !== org.id) {
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
}
// Check new name not taken by a redirect pointing elsewhere
const existingRedirect = await OrgRedirect.findByName(newName);
if (existingRedirect && existingRedirect.organizationId !== org.id) {
throw new plugins.typedrequest.TypedResponseError(
'Name is reserved as a redirect for another organization',
);
}
// If new name is one of our own redirects, delete that redirect
if (existingRedirect && existingRedirect.organizationId === org.id) {
await existingRedirect.delete();
}
// Create redirect from old name
await OrgRedirect.create(org.name, org.id);
org.name = newName;
}
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');
}
},
),
);
// Get Org Redirects
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
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');
}
const redirects = await OrgRedirect.getByOrgId(org.id);
return {
redirects: redirects.map((r) => ({
id: r.id,
oldName: r.oldName,
organizationId: r.organizationId,
createdAt: r.createdAt instanceof Date
? r.createdAt.toISOString()
: String(r.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get redirects');
}
},
),
);
// Delete Org Redirect
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrgRedirect>(
'deleteOrgRedirect',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const redirect = await OrgRedirect.findById(dataArg.redirectId);
if (!redirect) {
throw new plugins.typedrequest.TypedResponseError('Redirect not found');
}
// Check permission on the org
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
redirect.organizationId,
);
if (!canManage && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
await redirect.delete();
return { message: 'Redirect deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete redirect');
}
},
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
* Returns userId on success, null on failure
*/
public async authenticate(
credentials: plugins.smartregistry.ICredentials
credentials: plugins.smartregistry.ICredentials,
): Promise<string | null> {
const result = await this.authService.login(credentials.username, credentials.password);
if (!result.success || !result.user) return null;
@@ -62,7 +62,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
*/
public async validateToken(
token: string,
protocol?: plugins.smartregistry.TRegistryProtocol
protocol?: plugins.smartregistry.TRegistryProtocol,
): Promise<plugins.smartregistry.IAuthToken | null> {
// Try API token (srg_ prefix)
if (token.startsWith('srg_')) {
@@ -70,11 +70,10 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
if (!result.valid || !result.token || !result.user) return null;
return {
type: (protocol || result.token.protocols[0] || 'npm') as plugins.smartregistry.TRegistryProtocol,
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(',')}`
),
scopes: result.token.scopes.map((s) => `${s.protocol}:${s.actions.join(',')}`),
readonly: !result.token.scopes.some((s) =>
s.actions.includes('write') || s.actions.includes('*')
),
@@ -98,7 +97,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
public async createToken(
userId: string,
protocol: plugins.smartregistry.TRegistryProtocol,
options?: plugins.smartregistry.ITokenOptions
options?: plugins.smartregistry.ITokenOptions,
): Promise<string> {
const result = await this.tokenService.createToken({
userId,
@@ -133,7 +132,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
public async authorize(
token: plugins.smartregistry.IAuthToken | null,
resource: string,
action: string
action: string,
): Promise<boolean> {
// Anonymous access: only public reads
if (!token) return false;

View File

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

View File

@@ -30,7 +30,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
* Called before a package is stored
*/
public async beforePut(
context: plugins.smartregistry.IStorageHookContext
context: plugins.smartregistry.IStorageHookContext,
): Promise<plugins.smartregistry.IBeforePutResult> {
// Validate organization exists and has quota
const orgId = context.actor?.orgId;
@@ -54,7 +54,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
* Called after a package is successfully stored
*/
public async afterPut(
context: plugins.smartregistry.IStorageHookContext
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageName = context.metadata?.packageName || context.key;
@@ -115,7 +115,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
* Called after a package is fetched
*/
public async afterGet(
context: plugins.smartregistry.IStorageHookContext
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageName = context.metadata?.packageName || context.key;
@@ -134,7 +134,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
* Called before a package is deleted
*/
public async beforeDelete(
context: plugins.smartregistry.IStorageHookContext
context: plugins.smartregistry.IStorageHookContext,
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
return { allowed: true };
}
@@ -143,7 +143,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
* Called after a package is deleted
*/
public async afterDelete(
context: plugins.smartregistry.IStorageHookContext
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageName = context.metadata?.packageName || context.key;
@@ -216,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}`;
}
@@ -227,7 +227,7 @@ 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.getBucketByName(this.config.bucketName);
await bucket.fastPut({

View File

@@ -4,12 +4,46 @@
*/
import * as plugins from './plugins.ts';
import { initDb, closeDb, isDbConnected } from './models/db.ts';
import { closeDb, initDb, isDbConnected } from './models/db.ts';
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
import { ApiRouter } from './api/router.ts';
import { getEmbeddedFile } from './embedded-ui.generated.ts';
import { ReloadSocketManager } from './reload-socket.ts';
import { OpsServer } from './opsserver/classes.opsserver.ts';
// Bundled UI files (generated by tsbundle with base64ts output mode)
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
try {
// @ts-ignore - generated file may not exist yet
const { files } = await import('../ts_bundled/bundle.ts');
bundledFileMap = new Map();
for (const file of files as Array<{ path: string; contentBase64: string }>) {
const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
const ext = file.path.split('.').pop() || '';
bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) });
}
} catch {
console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)');
}
function getContentType(ext: string): string {
const types: Record<string, string> = {
html: 'text/html',
js: 'application/javascript',
css: 'text/css',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
eot: 'application/vnd.ms-fontobject',
map: 'application/json',
};
return types[ext] || 'application/octet-stream';
}
export interface IRegistryConfig {
// MongoDB configuration
@@ -42,8 +76,7 @@ export class StackGalleryRegistry {
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
private authProvider: StackGalleryAuthProvider | null = null;
private storageHooks: StackGalleryStorageHooks | null = null;
private apiRouter: ApiRouter | null = null;
private reloadSocket: ReloadSocketManager | null = null;
private opsServer: OpsServer | null = null;
private isInitialized = false;
constructor(config: IRegistryConfig) {
@@ -115,13 +148,11 @@ export class StackGalleryRegistry {
});
console.log('[StackGalleryRegistry] smartregistry initialized');
// Initialize API router
console.log('[StackGalleryRegistry] Initializing API router...');
this.apiRouter = new ApiRouter();
console.log('[StackGalleryRegistry] API router initialized');
// Initialize reload socket for hot reload
this.reloadSocket = new ReloadSocketManager();
// Initialize OpsServer (TypedRequest handlers)
console.log('[StackGalleryRegistry] Initializing OpsServer...');
this.opsServer = new OpsServer(this);
await this.opsServer.start();
console.log('[StackGalleryRegistry] OpsServer initialized');
this.isInitialized = true;
console.log('[StackGalleryRegistry] Initialization complete');
@@ -144,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}`);
@@ -162,11 +193,14 @@ 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)
const registryPaths = [
'/-/',
@@ -180,8 +214,7 @@ export class StackGalleryRegistry {
'/api/v1/gems/',
'/gems/',
];
const isRegistryPath =
registryPaths.some((p) => path.startsWith(p)) ||
const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) ||
(path.startsWith('/@') && !path.startsWith('/@stack'));
if (this.smartRegistry && isRegistryPath) {
@@ -199,11 +232,6 @@ export class StackGalleryRegistry {
}
}
// WebSocket upgrade for hot reload
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
return this.reloadSocket!.handleUpgrade(request);
}
// Serve static UI files
return this.serveStaticFile(path);
}
@@ -212,7 +240,7 @@ export class StackGalleryRegistry {
* Convert a Deno Request to smartregistry IRequestContext
*/
private async requestToContext(
request: Request
request: Request,
): Promise<plugins.smartregistry.IRequestContext> {
const url = new URL(request.url);
const headers: Record<string, string> = {};
@@ -285,22 +313,26 @@ export class StackGalleryRegistry {
}
/**
* Serve static files from embedded UI
* Serve static files from bundled UI
*/
private serveStaticFile(path: string): Response {
if (!bundledFileMap) {
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
}
const filePath = path === '/' ? '/index.html' : path;
// Get embedded file
const embeddedFile = getEmbeddedFile(filePath);
if (embeddedFile) {
return new Response(embeddedFile.data as unknown as BodyInit, {
// Get bundled file
const file = bundledFileMap.get(filePath);
if (file) {
return new Response(file.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': embeddedFile.contentType },
headers: { 'Content-Type': file.contentType },
});
}
// SPA fallback: serve index.html for unknown paths
const indexFile = getEmbeddedFile('/index.html');
const indexFile = bundledFileMap.get('/index.html');
if (indexFile) {
return new Response(indexFile.data as unknown as BodyInit, {
status: 200,
@@ -312,17 +344,34 @@ export class StackGalleryRegistry {
}
/**
* Handle API requests
* Handle TypedRequest calls
*/
private async handleApiRequest(request: Request): Promise<Response> {
if (!this.apiRouter) {
return new Response(JSON.stringify({ error: 'API router not initialized' }), {
private async handleTypedRequest(request: Request): Promise<Response> {
if (!this.opsServer) {
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return await this.apiRouter.handle(request);
try {
const body = await request.json();
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('[StackGalleryRegistry] TypedRequest error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(
JSON.stringify({ error: message }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
}
/**
@@ -352,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');
@@ -420,9 +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',
@@ -444,7 +497,7 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
} else {
console.warn(
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
error
error,
);
}
return createRegistryFromEnv();

View File

@@ -1,65 +0,0 @@
/**
* WebSocket manager for hot reload
* Generates a unique instance ID on startup and broadcasts it to connected clients.
* When the server restarts, clients detect the new ID and reload the page.
*/
export class ReloadSocketManager {
private instanceId: string;
private clients: Set<WebSocket> = new Set();
constructor() {
this.instanceId = crypto.randomUUID();
console.log(`[ReloadSocket] Instance ID: ${this.instanceId}`);
}
/**
* Get the current instance ID
*/
getInstanceId(): string {
return this.instanceId;
}
/**
* Handle WebSocket upgrade request
*/
handleUpgrade(request: Request): Response {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onopen = () => {
this.clients.add(socket);
console.log(`[ReloadSocket] Client connected (${this.clients.size} total)`);
// Send instance ID immediately
socket.send(JSON.stringify({ type: 'instance', id: this.instanceId }));
};
socket.onclose = () => {
this.clients.delete(socket);
console.log(`[ReloadSocket] Client disconnected (${this.clients.size} remaining)`);
};
socket.onerror = (error) => {
console.error('[ReloadSocket] WebSocket error:', error);
};
return response;
}
/**
* Broadcast a message to all connected clients
*/
broadcast(message: object): void {
const msg = JSON.stringify(message);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}
/**
* Get the number of connected clients
*/
getClientCount(): number {
return this.clients.size;
}
}

View File

@@ -45,7 +45,7 @@ export class AuditService {
errorCode?: string;
errorMessage?: string;
durationMs?: number;
} = {}
} = {},
): Promise<AuditLog> {
return await AuditLog.log({
actorId: this.context.actorId,
@@ -75,7 +75,7 @@ export class AuditService {
resourceType: TAuditResourceType,
resourceId?: string,
resourceName?: string,
metadata?: Record<string, unknown>
metadata?: Record<string, unknown>,
): Promise<AuditLog> {
return await this.log(action, resourceType, {
resourceId,
@@ -94,7 +94,7 @@ export class AuditService {
errorCode: string,
errorMessage: string,
resourceId?: string,
metadata?: Record<string, unknown>
metadata?: Record<string, unknown>,
): Promise<AuditLog> {
return await this.log(action, resourceType, {
resourceId,
@@ -107,11 +107,21 @@ export class AuditService {
// Convenience methods for common actions
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
public async logUserLogin(
userId: string,
success: boolean,
errorMessage?: string,
): Promise<AuditLog> {
if (success) {
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
}
return await this.logFailure('AUTH_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
return await this.logFailure(
'AUTH_LOGIN',
'user',
'LOGIN_FAILED',
errorMessage || 'Login failed',
userId,
);
}
public async logUserLogout(userId: string): Promise<AuditLog> {
@@ -131,7 +141,7 @@ export class AuditService {
packageName: string,
version: string,
organizationId: string,
repositoryId: string
repositoryId: string,
): Promise<AuditLog> {
return await this.log('PACKAGE_PUSHED', 'package', {
resourceId: packageId,
@@ -148,7 +158,7 @@ export class AuditService {
packageName: string,
version: string,
organizationId: string,
repositoryId: string
repositoryId: string,
): Promise<AuditLog> {
return await this.log('PACKAGE_PULLED', 'package', {
resourceId: packageId,
@@ -167,7 +177,7 @@ export class AuditService {
public async logRepositoryCreated(
repoId: string,
repoName: string,
organizationId: string
organizationId: string,
): Promise<AuditLog> {
return await this.log('REPO_CREATED', 'repository', {
resourceId: repoId,
@@ -182,7 +192,7 @@ export class AuditService {
resourceId: string,
targetUserId: string,
oldRole: string | null,
newRole: string | null
newRole: string | null,
): Promise<AuditLog> {
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
resourceId,

View File

@@ -3,7 +3,7 @@
*/
import * as plugins from '../plugins.ts';
import { User, Session } from '../models/index.ts';
import { Session, User } from '../models/index.ts';
import { AuditService } from './audit.service.ts';
export interface IJwtPayload {
@@ -52,7 +52,7 @@ export class AuthService {
public async login(
email: string,
password: string,
options: { userAgent?: string; ipAddress?: string } = {}
options: { userAgent?: string; ipAddress?: string } = {},
): Promise<IAuthResult> {
const auditContext = AuditService.withContext({
actorIp: options.ipAddress,
@@ -195,7 +195,7 @@ export class AuthService {
*/
public async logout(
sessionId: string,
options: { userId?: string; ipAddress?: string } = {}
options: { userId?: string; ipAddress?: string } = {},
): Promise<boolean> {
const session = await Session.findValidSession(sessionId);
if (!session) return false;
@@ -218,7 +218,7 @@ export class AuthService {
*/
public async logoutAll(
userId: string,
options: { ipAddress?: string } = {}
options: { ipAddress?: string } = {},
): Promise<number> {
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
@@ -238,7 +238,9 @@ export class AuthService {
/**
* Validate access token and return user
*/
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
public async validateAccessToken(
accessToken: string,
): Promise<{ user: User; sessionId: string } | null> {
const payload = await this.verifyToken(accessToken);
if (!payload || payload.type !== 'access') return null;
@@ -339,7 +341,7 @@ export class AuthService {
encoder.encode(this.config.jwtSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));

View File

@@ -4,8 +4,8 @@
*/
import type {
IExternalUserInfo,
IConnectionTestResult,
IExternalUserInfo,
} from '../../../interfaces/auth.interfaces.ts';
export interface IOAuthCallbackData {

View File

@@ -9,8 +9,8 @@
import type { AuthProvider } from '../../../models/auth.provider.ts';
import type { CryptoService } from '../../crypto.service.ts';
import type {
IExternalUserInfo,
IConnectionTestResult,
IExternalUserInfo,
} from '../../../interfaces/auth.interfaces.ts';
import type { IAuthStrategy } from './auth.strategy.interface.ts';
@@ -23,7 +23,7 @@ interface ILdapEntry {
export class LdapStrategy implements IAuthStrategy {
constructor(
private provider: AuthProvider,
private cryptoService: CryptoService
private cryptoService: CryptoService,
) {}
/**
@@ -31,7 +31,7 @@ export class LdapStrategy implements IAuthStrategy {
*/
public async authenticateCredentials(
username: string,
password: string
password: string,
): Promise<IExternalUserInfo> {
const config = this.provider.ldapConfig;
if (!config) {
@@ -55,7 +55,7 @@ export class LdapStrategy implements IAuthStrategy {
bindPassword,
config.baseDn,
userFilter,
password
password,
);
// Map LDAP attributes to user info
@@ -86,7 +86,7 @@ export class LdapStrategy implements IAuthStrategy {
config.serverUrl,
config.bindDn,
bindPassword,
config.baseDn
config.baseDn,
);
return {
@@ -129,7 +129,7 @@ export class LdapStrategy implements IAuthStrategy {
bindPassword: string,
baseDn: string,
userFilter: string,
userPassword: string
userPassword: string,
): Promise<ILdapEntry> {
// In a real implementation, this would:
// 1. Connect to LDAP server
@@ -150,7 +150,7 @@ export class LdapStrategy implements IAuthStrategy {
throw new Error(
'LDAP authentication is not yet fully implemented. ' +
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).',
);
}
@@ -161,7 +161,7 @@ export class LdapStrategy implements IAuthStrategy {
serverUrl: string,
bindDn: string,
bindPassword: string,
baseDn: string
baseDn: string,
): Promise<void> {
// Similar to ldapBind, this is a placeholder
// Would connect and bind with service account to verify connectivity
@@ -185,7 +185,9 @@ export class LdapStrategy implements IAuthStrategy {
// Return success for configuration validation
// Actual connectivity test would happen with LDAP library
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
console.log(
'[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)',
);
}
/**
@@ -206,15 +208,9 @@ export class LdapStrategy implements IAuthStrategy {
return {
externalId,
email,
username: entry[mapping.username]
? String(entry[mapping.username])
: undefined,
displayName: entry[mapping.displayName]
? String(entry[mapping.displayName])
: undefined,
groups: mapping.groups
? this.parseGroups(entry[mapping.groups])
: undefined,
username: entry[mapping.username] ? String(entry[mapping.username]) : undefined,
displayName: entry[mapping.displayName] ? String(entry[mapping.displayName]) : undefined,
groups: mapping.groups ? this.parseGroups(entry[mapping.groups]) : undefined,
rawAttributes: entry as Record<string, unknown>,
};
}

View File

@@ -6,8 +6,8 @@
import type { AuthProvider } from '../../../models/auth.provider.ts';
import type { CryptoService } from '../../crypto.service.ts';
import type {
IExternalUserInfo,
IConnectionTestResult,
IExternalUserInfo,
} from '../../../interfaces/auth.interfaces.ts';
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
@@ -34,7 +34,7 @@ export class OAuthStrategy implements IAuthStrategy {
constructor(
private provider: AuthProvider,
private cryptoService: CryptoService
private cryptoService: CryptoService,
) {}
/**
@@ -243,19 +243,15 @@ export class OAuthStrategy implements IAuthStrategy {
return {
externalId,
email,
username: rawInfo[mapping.username]
? String(rawInfo[mapping.username])
: undefined,
displayName: rawInfo[mapping.displayName]
? String(rawInfo[mapping.displayName])
: undefined,
username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined,
displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined,
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
? String(rawInfo[mapping.avatarUrl])
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
groups: mapping.groups && rawInfo[mapping.groups]
? (Array.isArray(rawInfo[mapping.groups])
? (rawInfo[mapping.groups] as string[])
: [String(rawInfo[mapping.groups])])
? (rawInfo[mapping.groups] as string[])
: [String(rawInfo[mapping.groups])])
: undefined,
rawAttributes: rawInfo,
};

View File

@@ -17,7 +17,7 @@ export class CryptoService {
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
if (!keyHex) {
console.warn(
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)',
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
@@ -52,7 +52,7 @@ export class CryptoService {
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encoded.buffer as ArrayBuffer
encoded.buffer as ArrayBuffer,
);
// Format: iv:ciphertext (both base64)
@@ -88,7 +88,7 @@ export class CryptoService {
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
this.masterKey,
encrypted.buffer as ArrayBuffer
encrypted.buffer as ArrayBuffer,
);
// Decode to string
@@ -123,7 +123,7 @@ export class CryptoService {
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
['encrypt', 'decrypt'],
);
}

View File

@@ -3,12 +3,18 @@
* Orchestrates OAuth/OIDC and LDAP authentication flows
*/
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
import {
AuthProvider,
ExternalIdentity,
PlatformSettings,
Session,
User,
} from '../models/index.ts';
import { AuthService, type IAuthResult } from './auth.service.ts';
import { AuditService } from './audit.service.ts';
import { cryptoService } from './crypto.service.ts';
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
import type { IConnectionTestResult, IExternalUserInfo } from '../interfaces/auth.interfaces.ts';
export interface IOAuthState {
providerId: string;
@@ -33,7 +39,7 @@ export class ExternalAuthService {
*/
public async initiateOAuth(
providerId: string,
returnUrl?: string
returnUrl?: string,
): Promise<{ authUrl: string; state: string }> {
const provider = await AuthProvider.findById(providerId);
if (!provider) {
@@ -67,7 +73,7 @@ export class ExternalAuthService {
*/
public async handleOAuthCallback(
data: IOAuthCallbackData,
options: { ipAddress?: string; userAgent?: string } = {}
options: { ipAddress?: string; userAgent?: string } = {},
): Promise<IAuthResult> {
// Validate state
const stateData = await this.validateState(data.state);
@@ -170,7 +176,7 @@ export class ExternalAuthService {
providerId: string,
username: string,
password: string,
options: { ipAddress?: string; userAgent?: string } = {}
options: { ipAddress?: string; userAgent?: string } = {},
): Promise<IAuthResult> {
const provider = await AuthProvider.findById(providerId);
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
@@ -261,7 +267,7 @@ export class ExternalAuthService {
public async linkProvider(
userId: string,
providerId: string,
externalUser: IExternalUserInfo
externalUser: IExternalUserInfo,
): Promise<ExternalIdentity> {
// Check if this external ID is already linked to another user
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
@@ -377,12 +383,12 @@ export class ExternalAuthService {
private async findOrCreateUser(
provider: AuthProvider,
externalUser: IExternalUserInfo,
options: { ipAddress?: string } = {}
options: { ipAddress?: string } = {},
): Promise<{ user: User; isNew: boolean }> {
// 1. Check if external identity already exists
const existingIdentity = await ExternalIdentity.findByExternalId(
provider.id,
externalUser.externalId
externalUser.externalId,
);
if (existingIdentity) {
@@ -544,12 +550,12 @@ export class ExternalAuthService {
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const encodedSignature = this.base64UrlEncode(
String.fromCharCode(...new Uint8Array(signature))
String.fromCharCode(...new Uint8Array(signature)),
);
return `${data}.${encodedSignature}`;

View File

@@ -4,19 +4,19 @@
export { AuditService, type IAuditContext } from './audit.service.ts';
export {
TokenService,
type ICreateTokenOptions,
type ITokenValidationResult,
TokenService,
} from './token.service.ts';
export {
PermissionService,
type TAction,
type IPermissionContext,
type IResolvedPermissions,
PermissionService,
type TAction,
} from './permission.service.ts';
export {
AuthService,
type IJwtPayload,
type IAuthResult,
type IAuthConfig,
type IAuthResult,
type IJwtPayload,
} from './auth.service.ts';

View File

@@ -4,18 +4,18 @@
import type {
TOrganizationRole,
TTeamRole,
TRepositoryRole,
TRegistryProtocol,
TRepositoryRole,
TTeamRole,
} from '../interfaces/auth.interfaces.ts';
import {
User,
Organization,
OrganizationMember,
Team,
TeamMember,
Repository,
RepositoryPermission,
Team,
TeamMember,
User,
} from '../models/index.ts';
export type TAction = 'read' | 'write' | 'delete' | 'admin';
@@ -71,7 +71,10 @@ export class PermissionService {
if (!context.organizationId) return result;
// Get organization membership
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
const orgMember = await OrganizationMember.findMembership(
context.organizationId,
context.userId,
);
if (orgMember) {
result.organizationRole = orgMember.role;
@@ -137,7 +140,10 @@ export class PermissionService {
}
// Get direct repository permission (highest priority)
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
const repoPerm = await RepositoryPermission.findPermission(
context.repositoryId,
context.userId,
);
if (repoPerm) {
result.repositoryRole = repoPerm.role;
this.applyRole(result, repoPerm.role);
@@ -151,7 +157,7 @@ export class PermissionService {
*/
public async checkPermission(
context: IPermissionContext,
action: TAction
action: TAction,
): Promise<boolean> {
const permissions = await this.resolvePermissions(context);
@@ -176,11 +182,11 @@ export class PermissionService {
userId: string,
organizationId: string,
repositoryId: string,
action: 'read' | 'write' | 'delete'
action: 'read' | 'write' | 'delete',
): Promise<boolean> {
return await this.checkPermission(
{ userId, organizationId, repositoryId },
action
action,
);
}
@@ -202,7 +208,7 @@ export class PermissionService {
public async canManageRepository(
userId: string,
organizationId: string,
repositoryId: string
repositoryId: string,
): Promise<boolean> {
const permissions = await this.resolvePermissions({
userId,
@@ -217,7 +223,7 @@ export class PermissionService {
*/
public async getAccessibleRepositories(
userId: string,
organizationId: string
organizationId: string,
): Promise<Repository[]> {
const user = await User.findById(userId);
if (!user || !user.isActive) return [];

View File

@@ -37,7 +37,9 @@ export class TokenService {
* Generate a new API token
* Returns the raw token (only shown once) and the saved token record
*/
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
public async createToken(
options: ICreateTokenOptions,
): Promise<{ rawToken: string; token: ApiToken }> {
// Generate secure random token: srg_{64 hex chars}
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
@@ -206,7 +208,7 @@ export class TokenService {
protocol: TRegistryProtocol,
organizationId?: string,
repositoryId?: string,
action?: string
action?: string,
): boolean {
if (!token.hasProtocol(protocol)) return false;
return token.hasScope(protocol, organizationId, repositoryId, action);

View File

@@ -0,0 +1,80 @@
// ============================================================================
// Admin Data Types
// ============================================================================
import type { TAuthProviderStatus, TAuthProviderType } from './auth.ts';
export interface IOAuthConfig {
clientId: string;
clientSecretEncrypted: string;
issuer: string;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
scopes: string[];
callbackUrl: string;
}
export interface ILdapConfig {
serverUrl: string;
bindDn: string;
bindPasswordEncrypted: string;
baseDn: string;
userSearchFilter: string;
tlsEnabled: boolean;
tlsCaCert?: string;
}
export interface IAttributeMapping {
email: string;
username: string;
displayName: string;
avatarUrl?: string;
groups?: string;
}
export interface IProvisioningSettings {
jitEnabled: boolean;
autoLinkByEmail: boolean;
allowedEmailDomains?: string[];
}
export interface IAuthProvider {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
status: TAuthProviderStatus;
priority: number;
oauthConfig?: IOAuthConfig;
ldapConfig?: ILdapConfig;
attributeMapping: IAttributeMapping;
provisioning: IProvisioningSettings;
createdAt: string;
updatedAt: string;
createdById: string;
lastTestedAt?: string;
lastTestResult?: 'success' | 'failure';
lastTestError?: string;
}
export interface IPlatformAuthSettings {
localAuthEnabled: boolean;
allowUserRegistration: boolean;
sessionDurationMinutes: number;
defaultProviderId?: string;
}
export interface IPlatformSettings {
id: string;
auth: IPlatformAuthSettings;
updatedAt: string;
updatedById?: string;
}
export interface IConnectionTestResult {
success: boolean;
latencyMs: number;
serverInfo?: Record<string, unknown>;
error?: string;
}

View File

@@ -0,0 +1,79 @@
// ============================================================================
// Audit Data Types
// ============================================================================
export type TAuditAction =
| 'AUTH_LOGIN'
| 'AUTH_LOGOUT'
| 'AUTH_FAILED'
| 'AUTH_MFA_ENABLED'
| 'AUTH_MFA_DISABLED'
| 'AUTH_PASSWORD_CHANGED'
| 'AUTH_PASSWORD_RESET'
| 'TOKEN_CREATED'
| 'TOKEN_USED'
| 'TOKEN_REVOKED'
| 'TOKEN_EXPIRED'
| 'USER_CREATED'
| 'USER_UPDATED'
| 'USER_DELETED'
| 'USER_SUSPENDED'
| 'USER_ACTIVATED'
| 'ORG_CREATED'
| 'ORG_UPDATED'
| 'ORG_DELETED'
| 'ORG_MEMBER_ADDED'
| 'ORG_MEMBER_REMOVED'
| 'ORG_MEMBER_ROLE_CHANGED'
| 'TEAM_CREATED'
| 'TEAM_UPDATED'
| 'TEAM_DELETED'
| 'TEAM_MEMBER_ADDED'
| 'TEAM_MEMBER_REMOVED'
| 'REPO_CREATED'
| 'REPO_UPDATED'
| 'REPO_DELETED'
| 'REPO_VISIBILITY_CHANGED'
| 'REPO_PERMISSION_GRANTED'
| 'REPO_PERMISSION_REVOKED'
| 'PACKAGE_PUSHED'
| 'PACKAGE_PULLED'
| 'PACKAGE_DELETED'
| 'PACKAGE_DEPRECATED'
| 'AUTH_PROVIDER_CREATED'
| 'AUTH_PROVIDER_UPDATED'
| 'AUTH_PROVIDER_DELETED'
| 'AUTH_PROVIDER_TESTED'
| 'PLATFORM_SETTINGS_UPDATED'
| 'SECURITY_SCAN_COMPLETED'
| 'SECURITY_VULNERABILITY_FOUND'
| 'SECURITY_IP_BLOCKED'
| 'SECURITY_RATE_LIMITED';
export type TAuditResourceType =
| 'user'
| 'organization'
| 'team'
| 'repository'
| 'package'
| 'api_token'
| 'session'
| 'auth_provider'
| 'platform_settings'
| 'system';
export interface IAuditEntry {
id: string;
actorId?: string;
actorType: 'user' | 'api_token' | 'system' | 'anonymous';
action: TAuditAction;
resourceType: TAuditResourceType;
resourceId?: string;
resourceName?: string;
organizationId?: string;
repositoryId?: string;
success: boolean;
errorCode?: string;
timestamp: string;
metadata: Record<string, unknown>;
}

View File

@@ -0,0 +1,49 @@
// ============================================================================
// Auth Data Types
// ============================================================================
export type TUserStatus = 'active' | 'suspended' | 'pending_verification';
export interface IIdentity {
jwt: string;
refreshJwt: string;
userId: string;
email: string;
username: string;
displayName: string;
isSystemAdmin: boolean;
expiresAt: number;
sessionId: string;
}
export interface IUser {
id: string;
email: string;
username: string;
displayName: string;
avatarUrl?: string;
isSystemAdmin: boolean;
isActive: boolean;
createdAt: string;
lastLoginAt?: string;
}
export interface ISession {
id: string;
userId: string;
userAgent: string;
ipAddress: string;
isValid: boolean;
lastActivityAt: string;
createdAt: string;
}
export interface IPublicAuthProvider {
id: string;
name: string;
displayName: string;
type: TAuthProviderType;
}
export type TAuthProviderType = 'oidc' | 'ldap';
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';

View File

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

View File

@@ -0,0 +1,55 @@
// ============================================================================
// Organization Data Types
// ============================================================================
export type TOrganizationPlan = 'free' | 'team' | 'enterprise';
export type TOrganizationRole = 'owner' | 'admin' | 'member';
export interface IOrganization {
id: string;
name: string;
displayName: string;
description?: string;
avatarUrl?: string;
website?: string;
isPublic: boolean;
memberCount: number;
plan: TOrganizationPlan;
usedStorageBytes: number;
storageQuotaBytes: number;
createdAt: string;
}
export interface IOrganizationDetail extends IOrganization {
settings?: IOrganizationSettings;
}
export interface IOrganizationSettings {
requireMfa: boolean;
allowPublicRepositories: boolean;
defaultRepositoryVisibility: TRepositoryVisibility;
allowedProtocols: TRegistryProtocol[];
}
export interface IOrganizationMember {
userId: string;
role: TOrganizationRole;
addedAt: string;
user: {
username: string;
displayName: string;
avatarUrl?: string;
} | null;
}
export interface IOrgRedirect {
id: string;
oldName: string;
organizationId: string;
createdAt: string;
}
// Re-export types used by settings
import type { TRepositoryVisibility } from './repository.ts';
import type { TRegistryProtocol } from './package.ts';
export type { TRegistryProtocol, TRepositoryVisibility };

View File

@@ -0,0 +1,53 @@
// ============================================================================
// Package Data Types
// ============================================================================
export type TRegistryProtocol =
| 'oci'
| 'npm'
| 'maven'
| 'cargo'
| 'composer'
| 'pypi'
| 'rubygems';
export interface IPackage {
id: string;
name: string;
description?: string;
protocol: TRegistryProtocol;
organizationId: string;
repositoryId: string;
latestVersion?: string;
isPrivate: boolean;
downloadCount: number;
starCount: number;
storageBytes: number;
updatedAt: string;
createdAt: string;
}
export interface IPackageDetail extends IPackage {
distTags: Record<string, string>;
versions: string[];
}
export interface IPackageVersion {
version: string;
publishedAt: string;
size: number;
downloads: number;
checksum?: {
sha256?: string;
sha512?: string;
md5?: string;
};
}
export interface IPackageSearchParams {
query?: string;
protocol?: TRegistryProtocol;
organizationId?: string;
limit?: number;
offset?: number;
}

View File

@@ -0,0 +1,22 @@
// ============================================================================
// Repository Data Types
// ============================================================================
import type { TRegistryProtocol } from './package.ts';
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
export interface IRepository {
id: string;
organizationId: string;
name: string;
description?: string;
protocol: TRegistryProtocol;
visibility: TRepositoryVisibility;
isPublic: boolean;
packageCount: number;
storageBytes: number;
downloadCount: number;
createdAt: string;
}

View File

@@ -0,0 +1,33 @@
// ============================================================================
// Token Data Types
// ============================================================================
import type { TRegistryProtocol } from './package.ts';
export type TTokenAction = 'read' | 'write' | 'delete' | '*';
export interface ITokenScope {
protocol: TRegistryProtocol | '*';
organizationId?: string;
repositoryId?: string;
actions: TTokenAction[];
}
export interface IToken {
id: string;
name: string;
tokenPrefix: string;
protocols: TRegistryProtocol[];
scopes: ITokenScope[];
organizationId?: string;
createdById?: string;
expiresAt?: string;
lastUsedAt?: string;
usageCount: number;
createdAt: string;
}
export interface ITokenCreateResult extends IToken {
token: string;
warning: string;
}

5
ts_interfaces/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import * as plugins from './plugins.ts';
import * as data from './data/index.ts';
import * as requests from './requests/index.ts';
export { data, plugins, requests };

3
ts_interfaces/plugins.ts Normal file
View File

@@ -0,0 +1,3 @@
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
export { typedrequestInterfaces };

View File

@@ -0,0 +1,137 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
// ============================================================================
// Admin Requests
// ============================================================================
export interface IReq_GetAdminProviders extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAdminProviders
> {
method: 'getAdminProviders';
request: {
identity: data.IIdentity;
};
response: {
providers: data.IAuthProvider[];
};
}
export interface IReq_CreateAdminProvider extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateAdminProvider
> {
method: 'createAdminProvider';
request: {
identity: data.IIdentity;
name: string;
displayName: string;
type: data.TAuthProviderType;
oauthConfig?: data.IOAuthConfig;
ldapConfig?: data.ILdapConfig;
attributeMapping?: data.IAttributeMapping;
provisioning?: data.IProvisioningSettings;
};
response: {
provider: data.IAuthProvider;
};
}
export interface IReq_GetAdminProvider extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAdminProvider
> {
method: 'getAdminProvider';
request: {
identity: data.IIdentity;
providerId: string;
};
response: {
provider: data.IAuthProvider;
};
}
export interface IReq_UpdateAdminProvider extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateAdminProvider
> {
method: 'updateAdminProvider';
request: {
identity: data.IIdentity;
providerId: string;
displayName?: string;
status?: data.TAuthProviderStatus;
priority?: number;
oauthConfig?: Partial<data.IOAuthConfig>;
ldapConfig?: Partial<data.ILdapConfig>;
attributeMapping?: Partial<data.IAttributeMapping>;
provisioning?: Partial<data.IProvisioningSettings>;
};
response: {
provider: data.IAuthProvider;
};
}
export interface IReq_DeleteAdminProvider extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteAdminProvider
> {
method: 'deleteAdminProvider';
request: {
identity: data.IIdentity;
providerId: string;
};
response: {
message: string;
};
}
export interface IReq_TestAdminProvider extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_TestAdminProvider
> {
method: 'testAdminProvider';
request: {
identity: data.IIdentity;
providerId: string;
};
response: {
result: data.IConnectionTestResult;
};
}
export interface IReq_GetPlatformSettings extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformSettings
> {
method: 'getPlatformSettings';
request: {
identity: data.IIdentity;
};
response: {
settings: data.IPlatformSettings;
};
}
export interface IReq_UpdatePlatformSettings extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdatePlatformSettings
> {
method: 'updatePlatformSettings';
request: {
identity: data.IIdentity;
auth?: Partial<data.IPlatformAuthSettings>;
};
response: {
settings: data.IPlatformSettings;
};
}

View File

@@ -0,0 +1,33 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
// ============================================================================
// Audit Requests
// ============================================================================
export interface IReq_QueryAudit extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_QueryAudit
> {
method: 'queryAudit';
request: {
identity: data.IIdentity;
organizationId?: string;
repositoryId?: string;
resourceType?: data.TAuditResourceType;
actions?: data.TAuditAction[];
success?: boolean;
startDate?: string;
endDate?: string;
actorId?: string;
limit?: number;
offset?: number;
};
response: {
logs: data.IAuditEntry[];
total: number;
limit: number;
offset: number;
};
}

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