feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,15 +4,14 @@ node_modules/
|
|||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
ui/dist/
|
|
||||||
.angular/
|
|
||||||
out-tsc/
|
|
||||||
|
|
||||||
# tsdeno temporary files
|
# tsdeno temporary files
|
||||||
package.json.bak
|
package.json.bak
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
ts/embedded-ui.generated.ts
|
ts/embedded-ui.generated.ts
|
||||||
|
ts_bundled/
|
||||||
|
dist_ts_web/
|
||||||
|
|
||||||
# Deno
|
# Deno
|
||||||
.deno/
|
.deno/
|
||||||
@@ -64,8 +63,5 @@ stories/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
# Angular cache
|
|
||||||
.angular/cache/
|
|
||||||
|
|
||||||
# TypeScript incremental compilation
|
# TypeScript incremental compilation
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
115
changelog.md
115
changelog.md
@@ -1,72 +1,121 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-20 - 1.5.0 - feat(opsserver,web)
|
||||||
|
replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
|
||||||
|
|
||||||
|
- add a new OpsServer with TypedRequest handlers for auth, organizations, repositories, packages, tokens, audit, admin, OAuth, and user settings flows
|
||||||
|
- introduce shared TypedRequest contracts under ts_interfaces and wire the registry to serve POST /typedrequest requests
|
||||||
|
- replace the embedded Angular build pipeline with tsbundle/tswatch-based web bundling, static html entrypoint, and new ts_web app state and shell views
|
||||||
|
- remove the legacy Angular frontend, custom UI bundler script, reload websocket hot-reload path, and related build configuration
|
||||||
|
|
||||||
## 2026-03-20 - 1.4.2 - fix(registry)
|
## 2026-03-20 - 1.4.2 - fix(registry)
|
||||||
|
|
||||||
align registry integrations with updated auth, storage, repository, and audit models
|
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
|
- update smartregistry auth and storage provider implementations to match the current request,
|
||||||
- fix audit events for auth provider, platform settings, and external authentication flows to use dedicated event types
|
token, and storage hook APIs
|
||||||
- adapt repository, organization, user, and package handlers to renamed model fields and revised repository visibility/protocol data
|
- fix audit events for auth provider, platform settings, and external authentication flows to use
|
||||||
- add missing repository and team model fields plus helper methods needed by the updated API and permission flows
|
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
|
- correct AES-GCM crypto buffer handling and package version checksum mapping
|
||||||
|
|
||||||
## 2026-03-20 - 1.4.1 - fix(repo)
|
## 2026-03-20 - 1.4.1 - fix(repo)
|
||||||
|
|
||||||
no changes to commit
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
## 2026-03-20 - 1.4.0 - feat(release,build,tests)
|
## 2026-03-20 - 1.4.0 - feat(release,build,tests)
|
||||||
|
|
||||||
add automated multi-platform release pipeline and align runtime, model, and test updates
|
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
|
- add a Gitea release workflow that builds the UI, bundles embedded assets, cross-compiles binaries
|
||||||
- switch compilation to tsdeno with compile targets defined in npmextra.json and simplify project scripts for check, lint, format, and compile tasks
|
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
|
- 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
|
- update test configuration to load MongoDB and S3 settings from qenv-based environment files and
|
||||||
- rename package search usage to searchPackages, update audit event names, and align package version fields and model name overrides with newer dependency behavior
|
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)
|
## 2025-12-03 - 1.3.0 - feat(auth)
|
||||||
|
|
||||||
Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
|
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
|
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to
|
||||||
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings
|
store provider configs, links, and platform auth settings
|
||||||
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login
|
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage
|
||||||
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing
|
platform auth settings
|
||||||
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy
|
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling
|
||||||
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation
|
callbacks, and LDAP login
|
||||||
- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags
|
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking,
|
||||||
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler)
|
session/token generation, and provider testing
|
||||||
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection
|
- 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
|
- 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)
|
## 2025-11-28 - 1.2.0 - feat(tokens)
|
||||||
|
|
||||||
Add support for organization-owned API tokens and org-level token management
|
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
|
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and
|
||||||
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate
|
new static getOrgTokens method
|
||||||
- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
|
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById
|
||||||
- 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
|
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
|
- 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
|
- .gitignore: added stories/ to ignore
|
||||||
|
|
||||||
## 2025-11-28 - 1.1.0 - feat(registry)
|
## 2025-11-28 - 1.1.0 - feat(registry)
|
||||||
|
|
||||||
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
|
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.
|
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected
|
||||||
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
|
clients for hot-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.
|
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at
|
||||||
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
|
/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.
|
- 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.
|
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and
|
||||||
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
|
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)
|
## 2025-11-28 - 1.0.1 - fix(smartdata)
|
||||||
|
|
||||||
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
|
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
|
- 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
|
## 2025-11-28 - 1.0.0 - Initial release
|
||||||
|
|
||||||
Release with core features, UI, and project scaffolding.
|
Release with core features, UI, and project scaffolding.
|
||||||
|
|
||||||
- Implemented account settings and API tokens management.
|
- Implemented account settings and API tokens management.
|
||||||
@@ -85,8 +134,10 @@ Release with core features, UI, and project scaffolding.
|
|||||||
- Dependency updates and fixes.
|
- Dependency updates and fixes.
|
||||||
|
|
||||||
## 2025-11-27 - 2025-11-28 - unknown -> 1.0.0 - housekeeping / duplicate commits
|
## 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.
|
Minor housekeeping and duplicate commits consolidated into the 1.0.0 release.
|
||||||
|
|
||||||
- Added initial README with project overview, features, and setup instructions.
|
- 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.
|
- 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.
|
- Miscellaneous UI tweaks and dependency updates.
|
||||||
@@ -12,9 +12,8 @@
|
|||||||
"test:e2e": "deno test --allow-all --no-check test/e2e/",
|
"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-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",
|
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
|
||||||
"build": "cd ui && pnpm run build",
|
"build-ui": "npx tsbundle",
|
||||||
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
|
"watch": "npx tswatch",
|
||||||
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
|
|
||||||
"compile": "tsdeno compile",
|
"compile": "tsdeno compile",
|
||||||
"check": "deno check mod.ts",
|
"check": "deno check mod.ts",
|
||||||
"fmt": "deno fmt",
|
"fmt": "deno fmt",
|
||||||
@@ -35,7 +34,11 @@
|
|||||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.5",
|
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.5",
|
||||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.20",
|
"@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",
|
"@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",
|
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.0",
|
||||||
"@std/path": "jsr:@std/path@^1.0.0",
|
"@std/path": "jsr:@std/path@^1.0.0",
|
||||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# Stack.Gallery Design System
|
# 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)
|
## Colors (HSL)
|
||||||
|
|
||||||
### Dark Theme (Default)
|
### Dark Theme (Default)
|
||||||
|
|
||||||
| Token | HSL | Hex | Usage |
|
| Token | HSL | Hex | Usage |
|
||||||
|--------------------|---------------|---------|------------------------------------|
|
| ------------------ | ----------- | ------- | ---------------------------------- |
|
||||||
| background | 0 0% 0% | #000000 | Page background |
|
| background | 0 0% 0% | #000000 | Page background |
|
||||||
| foreground | 0 0% 100% | #FFFFFF | Primary text |
|
| foreground | 0 0% 100% | #FFFFFF | Primary text |
|
||||||
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
|
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
|
||||||
@@ -23,7 +24,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
|
|||||||
### Light Theme
|
### Light Theme
|
||||||
|
|
||||||
| Token | HSL | Hex |
|
| Token | HSL | Hex |
|
||||||
|------------------|---------------|---------|
|
| ---------------- | ----------- | ------- |
|
||||||
| background | 0 0% 100% | #FFFFFF |
|
| background | 0 0% 100% | #FFFFFF |
|
||||||
| foreground | 0 0% 5% | #0D0D0D |
|
| foreground | 0 0% 5% | #0D0D0D |
|
||||||
| primary | 33 100% 45% | #E67300 |
|
| primary | 33 100% 45% | #E67300 |
|
||||||
@@ -52,7 +53,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
|
|||||||
### Font Sizes
|
### Font Sizes
|
||||||
|
|
||||||
| Element | Size | Weight | Letter Spacing |
|
| Element | Size | Weight | Letter Spacing |
|
||||||
|-----------------|------------------------|--------|-----------------|
|
| --------------- | ---------------------- | ------ | --------------- |
|
||||||
| H1 (Hero) | 3rem / 4rem (md) | 700 | -0.02em (tight) |
|
| H1 (Hero) | 3rem / 4rem (md) | 700 | -0.02em (tight) |
|
||||||
| H2 (Section) | 1.5rem / 1.875rem (md) | 700 | -0.02em |
|
| H2 (Section) | 1.5rem / 1.875rem (md) | 700 | -0.02em |
|
||||||
| H3 (Card title) | 0.875rem | 600 | normal |
|
| H3 (Card title) | 0.875rem | 600 | normal |
|
||||||
|
|||||||
33
html/index.html
Normal file
33
html/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>Stack.Gallery Registry</title>
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p style="color: #fff; text-align: center; margin-top: 100px">
|
||||||
|
JavaScript is required to run the Stack.Gallery Registry.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
14
package.json
14
package.json
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "deno run --allow-all mod.ts server",
|
"start": "deno run --allow-all mod.ts server",
|
||||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
"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",
|
"build": "deno task check",
|
||||||
"test": "deno task test",
|
"test": "deno task test",
|
||||||
"lint": "deno task lint",
|
"lint": "deno task lint",
|
||||||
@@ -26,9 +26,19 @@
|
|||||||
],
|
],
|
||||||
"author": "Stack.Gallery",
|
"author": "Stack.Gallery",
|
||||||
"license": "MIT",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbundle": "^2.8.3",
|
||||||
"@git.zone/tsdeno": "^1.2.0",
|
"@git.zone/tsdeno": "^1.2.0",
|
||||||
"concurrently": "^9.1.2"
|
"@git.zone/tswatch": "^3.1.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
}
|
}
|
||||||
|
|||||||
2924
pnpm-lock.yaml
generated
2924
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
100
readme.md
100
readme.md
@@ -1,14 +1,21 @@
|
|||||||
# @stack.gallery/registry 📦
|
# @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
|
## 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
|
## ✨ 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
|
- 🏢 **Organizations & Teams** — Hierarchical access control: orgs → teams → repositories
|
||||||
- 🔐 **Flexible Authentication** — Local JWT auth, OAuth/OIDC, and LDAP with JIT user provisioning
|
- 🔐 **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
|
- 🎫 **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
|
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||||
|
|
||||||
# Install specific version
|
# 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
|
# Install + set up systemd service
|
||||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --setup-service
|
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --setup-service
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer:
|
The installer:
|
||||||
|
|
||||||
- Detects your platform (Linux/macOS, x64/ARM64)
|
- Detects your platform (Linux/macOS, x64/ARM64)
|
||||||
- Downloads the pre-compiled binary from Gitea releases
|
- Downloads the pre-compiled binary from Gitea releases
|
||||||
- Installs to `/opt/stack-gallery-registry/` with a symlink in `/usr/local/bin/`
|
- Installs to `/opt/stack-gallery-registry/` with a symlink in `/usr/local/bin/`
|
||||||
@@ -63,10 +71,11 @@ The registry is available at `http://localhost:3000`.
|
|||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ 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 |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| ----------------------- | --------------------------- | ------------------------------------------------------------ |
|
||||||
| `MONGODB_URL` | `mongodb://localhost:27017` | MongoDB connection string |
|
| `MONGODB_URL` | `mongodb://localhost:27017` | MongoDB connection string |
|
||||||
| `MONGODB_DB` | `stackgallery` | Database name |
|
| `MONGODB_DB` | `stackgallery` | Database name |
|
||||||
| `S3_ENDPOINT` | `http://localhost:9000` | S3-compatible endpoint |
|
| `S3_ENDPOINT` | `http://localhost:9000` | S3-compatible endpoint |
|
||||||
@@ -77,7 +86,7 @@ Configuration is loaded from **environment variables** (production) or from **`.
|
|||||||
| `HOST` | `0.0.0.0` | Server bind address |
|
| `HOST` | `0.0.0.0` | Server bind address |
|
||||||
| `PORT` | `3000` | Server port |
|
| `PORT` | `3000` | Server port |
|
||||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
| `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 |
|
| `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 |
|
| `STORAGE_PATH` | `packages` | Base path in S3 for artifacts |
|
||||||
| `ENABLE_UPSTREAM_CACHE` | `true` | Cache packages from upstream registries |
|
| `ENABLE_UPSTREAM_CACHE` | `true` | Cache packages from upstream registries |
|
||||||
| `UPSTREAM_CACHE_EXPIRY` | `24` | Cache TTL in hours |
|
| `UPSTREAM_CACHE_EXPIRY` | `24` | Cache TTL in hours |
|
||||||
@@ -99,10 +108,12 @@ Configuration is loaded from **environment variables** (production) or from **`.
|
|||||||
|
|
||||||
## 🔌 Protocol Endpoints
|
## 🔌 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 |
|
| Protocol | Paths | Client Config Example |
|
||||||
|----------|-------|-----------------------|
|
| -------------- | --------------------------- | ------------------------------------------------------ |
|
||||||
| **NPM** | `/-/*`, `/@scope/*` | `npm config set registry http://registry:3000` |
|
| **NPM** | `/-/*`, `/@scope/*` | `npm config set registry http://registry:3000` |
|
||||||
| **OCI/Docker** | `/v2/*` | `docker login registry:3000` |
|
| **OCI/Docker** | `/v2/*` | `docker login registry:3000` |
|
||||||
| **Maven** | `/maven2/*` | Add repository URL in `pom.xml` |
|
| **Maven** | `/maven2/*` | Add repository URL in `pom.xml` |
|
||||||
@@ -111,21 +122,25 @@ Each protocol is handled natively via [`@push.rocks/smartregistry`](https://code
|
|||||||
| **Composer** | `/packages.json`, `/p/*` | Add repository in `composer.json` |
|
| **Composer** | `/packages.json`, `/p/*` | Add repository in `composer.json` |
|
||||||
| **RubyGems** | `/api/v1/gems/*`, `/gems/*` | `gem sources -a http://registry:3000` |
|
| **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
|
## 🔐 Authentication & Security
|
||||||
|
|
||||||
### Local Auth
|
### Local Auth
|
||||||
|
|
||||||
- JWT-based with **15-minute access tokens** and **7-day refresh tokens** (HS256)
|
- JWT-based with **15-minute access tokens** and **7-day refresh tokens** (HS256)
|
||||||
- Session tracking — each login creates a session, tokens embed session IDs
|
- Session tracking — each login creates a session, tokens embed session IDs
|
||||||
- Password hashing with PBKDF2 (10,000 rounds SHA-256 + random salt)
|
- Password hashing with PBKDF2 (10,000 rounds SHA-256 + random salt)
|
||||||
|
|
||||||
### External Auth (OAuth/OIDC & LDAP)
|
### External Auth (OAuth/OIDC & LDAP)
|
||||||
|
|
||||||
- **OAuth/OIDC** — Connect to any OIDC-compliant provider (Keycloak, Okta, Auth0, Azure AD, etc.)
|
- **OAuth/OIDC** — Connect to any OIDC-compliant provider (Keycloak, Okta, Auth0, Azure AD, etc.)
|
||||||
- **LDAP** — Bind + search authentication against Active Directory or OpenLDAP
|
- **LDAP** — Bind + search authentication against Active Directory or OpenLDAP
|
||||||
- **JIT Provisioning** — Users are auto-created on first external login
|
- **JIT Provisioning** — Users are auto-created on first external login
|
||||||
- **Auto-linking** — External identities are linked to existing users by email match
|
- **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
|
### RBAC Permissions
|
||||||
|
|
||||||
@@ -143,6 +158,7 @@ Platform Admin (full access)
|
|||||||
### Scoped API Tokens
|
### Scoped API Tokens
|
||||||
|
|
||||||
Tokens are prefixed with `srg_` and can be scoped to:
|
Tokens are prefixed with `srg_` and can be scoped to:
|
||||||
|
|
||||||
- Specific **protocols** (e.g., npm + oci only)
|
- Specific **protocols** (e.g., npm + oci only)
|
||||||
- Specific **actions** (read / write / delete)
|
- Specific **actions** (read / write / delete)
|
||||||
- Specific **organizations**
|
- Specific **organizations**
|
||||||
@@ -150,11 +166,13 @@ Tokens are prefixed with `srg_` and can be scoped to:
|
|||||||
|
|
||||||
## 📡 REST API
|
## 📡 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
|
### Auth
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| ------ | ---------------------------------- | ----------------------------------- |
|
||||||
| `POST` | `/api/v1/auth/login` | Login (email + password) |
|
| `POST` | `/api/v1/auth/login` | Login (email + password) |
|
||||||
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
||||||
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
||||||
@@ -165,8 +183,9 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `POST` | `/api/v1/auth/ldap/:id/login` | LDAP login |
|
| `POST` | `/api/v1/auth/ldap/:id/login` | LDAP login |
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | ------------------- | ----------- |
|
||||||
| `GET` | `/api/v1/users` | List users |
|
| `GET` | `/api/v1/users` | List users |
|
||||||
| `POST` | `/api/v1/users` | Create user |
|
| `POST` | `/api/v1/users` | Create user |
|
||||||
| `GET` | `/api/v1/users/:id` | Get user |
|
| `GET` | `/api/v1/users/:id` | Get user |
|
||||||
@@ -174,8 +193,9 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `DELETE` | `/api/v1/users/:id` | Delete user |
|
| `DELETE` | `/api/v1/users/:id` | Delete user |
|
||||||
|
|
||||||
### Organizations
|
### Organizations
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | ------------------------------------------- | ------------------- |
|
||||||
| `GET` | `/api/v1/organizations` | List organizations |
|
| `GET` | `/api/v1/organizations` | List organizations |
|
||||||
| `POST` | `/api/v1/organizations` | Create organization |
|
| `POST` | `/api/v1/organizations` | Create organization |
|
||||||
| `GET` | `/api/v1/organizations/:id` | Get organization |
|
| `GET` | `/api/v1/organizations/:id` | Get organization |
|
||||||
@@ -187,8 +207,9 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `DELETE` | `/api/v1/organizations/:id/members/:userId` | Remove member |
|
| `DELETE` | `/api/v1/organizations/:id/members/:userId` | Remove member |
|
||||||
|
|
||||||
### Repositories
|
### Repositories
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | ------------------------------------------- | -------------- |
|
||||||
| `GET` | `/api/v1/organizations/:orgId/repositories` | List org repos |
|
| `GET` | `/api/v1/organizations/:orgId/repositories` | List org repos |
|
||||||
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repo |
|
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repo |
|
||||||
| `GET` | `/api/v1/repositories/:id` | Get repo |
|
| `GET` | `/api/v1/repositories/:id` | Get repo |
|
||||||
@@ -196,8 +217,9 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `DELETE` | `/api/v1/repositories/:id` | Delete repo |
|
| `DELETE` | `/api/v1/repositories/:id` | Delete repo |
|
||||||
|
|
||||||
### Packages
|
### Packages
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | ---------------------------------------- | ------------------- |
|
||||||
| `GET` | `/api/v1/packages` | Search packages |
|
| `GET` | `/api/v1/packages` | Search packages |
|
||||||
| `GET` | `/api/v1/packages/:id` | Get package details |
|
| `GET` | `/api/v1/packages/:id` | Get package details |
|
||||||
| `GET` | `/api/v1/packages/:id/versions` | List versions |
|
| `GET` | `/api/v1/packages/:id/versions` | List versions |
|
||||||
@@ -205,20 +227,23 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `DELETE` | `/api/v1/packages/:id/versions/:version` | Delete version |
|
| `DELETE` | `/api/v1/packages/:id/versions/:version` | Delete version |
|
||||||
|
|
||||||
### Tokens
|
### Tokens
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | -------------------- | ---------------- |
|
||||||
| `GET` | `/api/v1/tokens` | List your tokens |
|
| `GET` | `/api/v1/tokens` | List your tokens |
|
||||||
| `POST` | `/api/v1/tokens` | Create token |
|
| `POST` | `/api/v1/tokens` | Create token |
|
||||||
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
||||||
|
|
||||||
### Audit
|
### Audit
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| ------ | --------------- | ---------------- |
|
||||||
| `GET` | `/api/v1/audit` | Query audit logs |
|
| `GET` | `/api/v1/audit` | Query audit logs |
|
||||||
|
|
||||||
### Admin (Platform Admins Only)
|
### Admin (Platform Admins Only)
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | --------------------------------------- | ------------------------ |
|
||||||
| `GET` | `/api/v1/admin/auth/providers` | List all auth providers |
|
| `GET` | `/api/v1/admin/auth/providers` | List all auth providers |
|
||||||
| `POST` | `/api/v1/admin/auth/providers` | Create auth provider |
|
| `POST` | `/api/v1/admin/auth/providers` | Create auth provider |
|
||||||
| `GET` | `/api/v1/admin/auth/providers/:id` | Get provider details |
|
| `GET` | `/api/v1/admin/auth/providers/:id` | Get provider details |
|
||||||
@@ -229,8 +254,9 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
|||||||
| `PUT` | `/api/v1/admin/auth/settings` | Update platform settings |
|
| `PUT` | `/api/v1/admin/auth/settings` | Update platform settings |
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| ------ | ----------------------- | ------------------------------------------------ |
|
||||||
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
|
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
@@ -268,6 +294,9 @@ registry/
|
|||||||
│ │ ├── auth.provider.ts # IAuthProvider implementation
|
│ │ ├── auth.provider.ts # IAuthProvider implementation
|
||||||
│ │ └── storage.provider.ts # IStorageHooks for quota/audit
|
│ │ └── storage.provider.ts # IStorageHooks for quota/audit
|
||||||
│ └── interfaces/ # TypeScript interfaces & types
|
│ └── 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
|
└── ui/ # Angular 19 + Tailwind CSS frontend
|
||||||
└── src/app/
|
└── src/app/
|
||||||
├── features/ # Login, dashboard, orgs, repos, packages, tokens, admin
|
├── features/ # Login, dashboard, orgs, repos, packages, tokens, admin
|
||||||
@@ -278,7 +307,7 @@ registry/
|
|||||||
## 🔧 Technology Stack
|
## 🔧 Technology Stack
|
||||||
|
|
||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|------------|
|
| ----------------- | ------------------------------------------------------------------------------------ |
|
||||||
| **Runtime** | Deno 2.x |
|
| **Runtime** | Deno 2.x |
|
||||||
| **Language** | TypeScript (strict mode) |
|
| **Language** | TypeScript (strict mode) |
|
||||||
| **Database** | MongoDB via [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) |
|
| **Database** | MongoDB via [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) |
|
||||||
@@ -327,7 +356,8 @@ Releases are automated via Gitea Actions (`.gitea/workflows/release.yml`):
|
|||||||
|
|
||||||
1. Push a `v*` tag
|
1. Push a `v*` tag
|
||||||
2. CI builds the Angular UI and bundles it into TypeScript
|
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
|
4. Binaries + SHA256 checksums are uploaded as Gitea release assets
|
||||||
|
|
||||||
Compile targets are configured in `npmextra.json` under `@git.zone/tsdeno`.
|
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
|
## 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
|
### 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
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
|
||||||
|
|
||||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its
|
||||||
|
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
|
||||||
|
Capital GmbH of any derivative works.
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
version: "3.8"
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mongodb-test:
|
mongodb-test:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
container_name: stack-gallery-test-mongo
|
container_name: stack-gallery-test-mongo
|
||||||
ports:
|
ports:
|
||||||
- "27117:27017"
|
- '27117:27017'
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: testadmin
|
MONGO_INITDB_ROOT_USERNAME: testadmin
|
||||||
MONGO_INITDB_ROOT_PASSWORD: testpass
|
MONGO_INITDB_ROOT_PASSWORD: testpass
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /data/db
|
- /data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -21,8 +21,8 @@ services:
|
|||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: stack-gallery-test-minio
|
container_name: stack-gallery-test-minio
|
||||||
ports:
|
ports:
|
||||||
- "9100:9000"
|
- '9100:9000'
|
||||||
- "9101:9001"
|
- '9101:9001'
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: testadmin
|
MINIO_ROOT_USER: testadmin
|
||||||
MINIO_ROOT_PASSWORD: testpassword
|
MINIO_ROOT_PASSWORD: testpassword
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
- /data
|
- /data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -6,25 +6,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 * as path from '@std/path';
|
||||||
import {
|
import {
|
||||||
setupTestDb,
|
|
||||||
teardownTestDb,
|
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
createTestUser,
|
|
||||||
createOrgWithOwner,
|
|
||||||
createTestRepository,
|
|
||||||
createTestApiToken,
|
|
||||||
clients,
|
clients,
|
||||||
skipIfMissing,
|
createOrgWithOwner,
|
||||||
|
createTestApiToken,
|
||||||
|
createTestRepository,
|
||||||
|
createTestUser,
|
||||||
runCommand,
|
runCommand,
|
||||||
|
setupTestDb,
|
||||||
|
skipIfMissing,
|
||||||
|
teardownTestDb,
|
||||||
testConfig,
|
testConfig,
|
||||||
} from '../helpers/index.ts';
|
} from '../helpers/index.ts';
|
||||||
|
|
||||||
const FIXTURE_DIR = path.join(
|
const FIXTURE_DIR = path.join(
|
||||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||||
'../fixtures/npm/@stack-test/demo-package'
|
'../fixtures/npm/@stack-test/demo-package',
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('NPM E2E: Full lifecycle', () => {
|
describe('NPM E2E: Full lifecycle', () => {
|
||||||
@@ -98,7 +98,7 @@ describe('NPM E2E: Full lifecycle', () => {
|
|||||||
const result = await clients.npm.publish(
|
const result = await clients.npm.publish(
|
||||||
FIXTURE_DIR,
|
FIXTURE_DIR,
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
apiToken
|
apiToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
|
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
|
||||||
@@ -120,20 +120,28 @@ describe('NPM E2E: Full lifecycle', () => {
|
|||||||
|
|
||||||
// First publish
|
// First publish
|
||||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
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 Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await clients.npm.publish(
|
await clients.npm.publish(
|
||||||
FIXTURE_DIR,
|
FIXTURE_DIR,
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
apiToken
|
apiToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch metadata via npm view
|
// Fetch metadata via npm view
|
||||||
const viewResult = await runCommand(
|
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}`);
|
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
|
||||||
@@ -159,32 +167,36 @@ describe('NPM E2E: Full lifecycle', () => {
|
|||||||
try {
|
try {
|
||||||
// First publish
|
// First publish
|
||||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
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 Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||||
|
|
||||||
await clients.npm.publish(
|
await clients.npm.publish(
|
||||||
FIXTURE_DIR,
|
FIXTURE_DIR,
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
apiToken
|
apiToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create package.json in temp dir
|
// Create package.json in temp dir
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(
|
||||||
path.join(tempDir, 'package.json'),
|
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
|
// Create .npmrc in temp dir
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(
|
||||||
path.join(tempDir, '.npmrc'),
|
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
|
// Install
|
||||||
const installResult = await clients.npm.install(
|
const installResult = await clients.npm.install(
|
||||||
'@stack-test/demo-package@1.0.0',
|
'@stack-test/demo-package@1.0.0',
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
tempDir
|
tempDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
|
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
|
||||||
@@ -213,33 +225,41 @@ describe('NPM E2E: Full lifecycle', () => {
|
|||||||
|
|
||||||
// First publish
|
// First publish
|
||||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
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 Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await clients.npm.publish(
|
await clients.npm.publish(
|
||||||
FIXTURE_DIR,
|
FIXTURE_DIR,
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
apiToken
|
apiToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unpublish
|
// Unpublish
|
||||||
const unpublishResult = await clients.npm.unpublish(
|
const unpublishResult = await clients.npm.unpublish(
|
||||||
'@stack-test/demo-package@1.0.0',
|
'@stack-test/demo-package@1.0.0',
|
||||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||||
apiToken
|
apiToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
unpublishResult.success,
|
unpublishResult.success,
|
||||||
true,
|
true,
|
||||||
`npm unpublish failed: ${unpublishResult.stderr}`
|
`npm unpublish failed: ${unpublishResult.stderr}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify package is gone
|
// Verify package is gone
|
||||||
const viewResult = await runCommand(
|
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
|
// Should fail since package was unpublished
|
||||||
|
|||||||
@@ -6,24 +6,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals } from 'jsr:@std/assert';
|
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 * as path from '@std/path';
|
||||||
import {
|
import {
|
||||||
setupTestDb,
|
|
||||||
teardownTestDb,
|
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
createTestUser,
|
|
||||||
createOrgWithOwner,
|
|
||||||
createTestRepository,
|
|
||||||
createTestApiToken,
|
|
||||||
clients,
|
clients,
|
||||||
|
createOrgWithOwner,
|
||||||
|
createTestApiToken,
|
||||||
|
createTestRepository,
|
||||||
|
createTestUser,
|
||||||
|
setupTestDb,
|
||||||
skipIfMissing,
|
skipIfMissing,
|
||||||
|
teardownTestDb,
|
||||||
testConfig,
|
testConfig,
|
||||||
} from '../helpers/index.ts';
|
} from '../helpers/index.ts';
|
||||||
|
|
||||||
const FIXTURE_DIR = path.join(
|
const FIXTURE_DIR = path.join(
|
||||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||||
'../fixtures/oci'
|
'../fixtures/oci',
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('OCI E2E: Full lifecycle', () => {
|
describe('OCI E2E: Full lifecycle', () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project
|
||||||
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||||
|
>
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>com.stacktest</groupId>
|
<groupId>com.stacktest</groupId>
|
||||||
<artifactId>demo-artifact</artifactId>
|
<artifactId>demo-artifact</artifactId>
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'demo-package',
|
name: 'demo-package',
|
||||||
greet: (name) => `Hello, ${name}!`,
|
greet: (name) => `Hello, ${name}!`,
|
||||||
version: () => require('./package.json').version
|
version: () => require('./package.json').version,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { User } from '../../ts/models/user.ts';
|
|||||||
import { ApiToken } from '../../ts/models/apitoken.ts';
|
import { ApiToken } from '../../ts/models/apitoken.ts';
|
||||||
import { AuthService } from '../../ts/services/auth.service.ts';
|
import { AuthService } from '../../ts/services/auth.service.ts';
|
||||||
import { TokenService } from '../../ts/services/token.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';
|
import { testConfig } from '../test.config.ts';
|
||||||
|
|
||||||
const TEST_PASSWORD = 'TestPassword123!';
|
const TEST_PASSWORD = 'TestPassword123!';
|
||||||
@@ -25,7 +29,7 @@ export interface ICreateTestUserOptions {
|
|||||||
* Create a test user with sensible defaults
|
* Create a test user with sensible defaults
|
||||||
*/
|
*/
|
||||||
export async function createTestUser(
|
export async function createTestUser(
|
||||||
overrides: ICreateTestUserOptions = {}
|
overrides: ICreateTestUserOptions = {},
|
||||||
): Promise<{ user: User; password: string }> {
|
): Promise<{ user: User; password: string }> {
|
||||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||||
const password = overrides.password || TEST_PASSWORD;
|
const password = overrides.password || TEST_PASSWORD;
|
||||||
@@ -61,7 +65,7 @@ export async function createAdminUser(): Promise<{ user: User; password: string
|
|||||||
*/
|
*/
|
||||||
export async function loginUser(
|
export async function loginUser(
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
||||||
const authService = new AuthService({
|
const authService = new AuthService({
|
||||||
jwtSecret: testConfig.jwt.secret,
|
jwtSecret: testConfig.jwt.secret,
|
||||||
@@ -96,7 +100,7 @@ export interface ICreateTestApiTokenOptions {
|
|||||||
* Create test API token
|
* Create test API token
|
||||||
*/
|
*/
|
||||||
export async function createTestApiToken(
|
export async function createTestApiToken(
|
||||||
options: ICreateTestApiTokenOptions
|
options: ICreateTestApiTokenOptions,
|
||||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||||
const tokenService = new TokenService();
|
const tokenService = new TokenService();
|
||||||
|
|
||||||
@@ -127,7 +131,7 @@ export function createAuthHeader(token: string): { Authorization: string } {
|
|||||||
*/
|
*/
|
||||||
export function createBasicAuthHeader(
|
export function createBasicAuthHeader(
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string,
|
||||||
): { Authorization: string } {
|
): { Authorization: string } {
|
||||||
const credentials = btoa(`${username}:${password}`);
|
const credentials = btoa(`${username}:${password}`);
|
||||||
return { Authorization: `Basic ${credentials}` };
|
return { Authorization: `Basic ${credentials}` };
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let isConnected = false;
|
|||||||
|
|
||||||
// We need to patch the global db export since models reference it
|
// We need to patch the global db export since models reference it
|
||||||
// This is done by re-initializing with the test config
|
// 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
|
* Initialize test database with unique name per test run
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { Package } from '../../ts/models/package.ts';
|
|||||||
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
|
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
|
||||||
import type {
|
import type {
|
||||||
TOrganizationRole,
|
TOrganizationRole,
|
||||||
TTeamRole,
|
TRegistryProtocol,
|
||||||
TRepositoryRole,
|
TRepositoryRole,
|
||||||
TRepositoryVisibility,
|
TRepositoryVisibility,
|
||||||
TRegistryProtocol,
|
TTeamRole,
|
||||||
} from '../../ts/interfaces/auth.interfaces.ts';
|
} from '../../ts/interfaces/auth.interfaces.ts';
|
||||||
|
|
||||||
export interface ICreateTestOrganizationOptions {
|
export interface ICreateTestOrganizationOptions {
|
||||||
@@ -29,7 +29,7 @@ export interface ICreateTestOrganizationOptions {
|
|||||||
* Create test organization
|
* Create test organization
|
||||||
*/
|
*/
|
||||||
export async function createTestOrganization(
|
export async function createTestOrganization(
|
||||||
options: ICreateTestOrganizationOptions
|
options: ICreateTestOrganizationOptions,
|
||||||
): Promise<Organization> {
|
): Promise<Organization> {
|
||||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export async function createTestOrganization(
|
|||||||
*/
|
*/
|
||||||
export async function createOrgWithOwner(
|
export async function createOrgWithOwner(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
orgOptions?: Partial<ICreateTestOrganizationOptions>
|
orgOptions?: Partial<ICreateTestOrganizationOptions>,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
membership: OrganizationMember;
|
membership: OrganizationMember;
|
||||||
@@ -82,7 +82,7 @@ export async function addOrgMember(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: TOrganizationRole = 'member',
|
role: TOrganizationRole = 'member',
|
||||||
invitedBy?: string
|
invitedBy?: string,
|
||||||
): Promise<OrganizationMember> {
|
): Promise<OrganizationMember> {
|
||||||
const membership = await OrganizationMember.addMember({
|
const membership = await OrganizationMember.addMember({
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -113,7 +113,7 @@ export interface ICreateTestRepositoryOptions {
|
|||||||
* Create test repository
|
* Create test repository
|
||||||
*/
|
*/
|
||||||
export async function createTestRepository(
|
export async function createTestRepository(
|
||||||
options: ICreateTestRepositoryOptions
|
options: ICreateTestRepositoryOptions,
|
||||||
): Promise<Repository> {
|
): Promise<Repository> {
|
||||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ export async function createTestTeam(options: ICreateTestTeamOptions): Promise<T
|
|||||||
export async function addTeamMember(
|
export async function addTeamMember(
|
||||||
teamId: string,
|
teamId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: TTeamRole = 'member'
|
role: TTeamRole = 'member',
|
||||||
): Promise<TeamMember> {
|
): Promise<TeamMember> {
|
||||||
const member = new TeamMember();
|
const member = new TeamMember();
|
||||||
member.id = await TeamMember.getNewId();
|
member.id = await TeamMember.getNewId();
|
||||||
@@ -176,7 +176,7 @@ export interface IGrantRepoPermissionOptions {
|
|||||||
* Grant repository permission
|
* Grant repository permission
|
||||||
*/
|
*/
|
||||||
export async function grantRepoPermission(
|
export async function grantRepoPermission(
|
||||||
options: IGrantRepoPermissionOptions
|
options: IGrantRepoPermissionOptions,
|
||||||
): Promise<RepositoryPermission> {
|
): Promise<RepositoryPermission> {
|
||||||
const perm = new RepositoryPermission();
|
const perm = new RepositoryPermission();
|
||||||
perm.id = await RepositoryPermission.getNewId();
|
perm.id = await RepositoryPermission.getNewId();
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const del = (path: string, headers?: Record<string, string>) =>
|
|||||||
export function assertStatus(response: ITestResponse, expected: number): void {
|
export function assertStatus(response: ITestResponse, expected: number): void {
|
||||||
if (response.status !== expected) {
|
if (response.status !== expected) {
|
||||||
throw new Error(
|
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 {
|
export function assertSuccess(response: ITestResponse): void {
|
||||||
if (response.status < 200 || response.status >= 300) {
|
if (response.status < 200 || response.status >= 300) {
|
||||||
throw new Error(
|
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)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,82 +4,82 @@
|
|||||||
|
|
||||||
// Database helpers
|
// Database helpers
|
||||||
export {
|
export {
|
||||||
setupTestDb,
|
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
teardownTestDb,
|
|
||||||
clearCollections,
|
clearCollections,
|
||||||
getTestDbName,
|
|
||||||
getTestDb,
|
getTestDb,
|
||||||
|
getTestDbName,
|
||||||
|
setupTestDb,
|
||||||
|
teardownTestDb,
|
||||||
} from './db.helper.ts';
|
} from './db.helper.ts';
|
||||||
|
|
||||||
// Auth helpers
|
// Auth helpers
|
||||||
export {
|
export {
|
||||||
createTestUser,
|
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
loginUser,
|
|
||||||
createTestApiToken,
|
|
||||||
createAuthHeader,
|
createAuthHeader,
|
||||||
createBasicAuthHeader,
|
createBasicAuthHeader,
|
||||||
|
createTestApiToken,
|
||||||
|
createTestUser,
|
||||||
getTestPassword,
|
getTestPassword,
|
||||||
type ICreateTestUserOptions,
|
|
||||||
type ICreateTestApiTokenOptions,
|
type ICreateTestApiTokenOptions,
|
||||||
|
type ICreateTestUserOptions,
|
||||||
|
loginUser,
|
||||||
} from './auth.helper.ts';
|
} from './auth.helper.ts';
|
||||||
|
|
||||||
// Factory helpers
|
// Factory helpers
|
||||||
export {
|
export {
|
||||||
createTestOrganization,
|
|
||||||
createOrgWithOwner,
|
|
||||||
addOrgMember,
|
addOrgMember,
|
||||||
|
addTeamMember,
|
||||||
|
createFullTestScenario,
|
||||||
|
createOrgWithOwner,
|
||||||
|
createTestOrganization,
|
||||||
|
createTestPackage,
|
||||||
createTestRepository,
|
createTestRepository,
|
||||||
createTestTeam,
|
createTestTeam,
|
||||||
addTeamMember,
|
|
||||||
grantRepoPermission,
|
grantRepoPermission,
|
||||||
createTestPackage,
|
|
||||||
createFullTestScenario,
|
|
||||||
type ICreateTestOrganizationOptions,
|
type ICreateTestOrganizationOptions,
|
||||||
|
type ICreateTestPackageOptions,
|
||||||
type ICreateTestRepositoryOptions,
|
type ICreateTestRepositoryOptions,
|
||||||
type ICreateTestTeamOptions,
|
type ICreateTestTeamOptions,
|
||||||
type IGrantRepoPermissionOptions,
|
type IGrantRepoPermissionOptions,
|
||||||
type ICreateTestPackageOptions,
|
|
||||||
} from './factory.helper.ts';
|
} from './factory.helper.ts';
|
||||||
|
|
||||||
// HTTP helpers
|
// HTTP helpers
|
||||||
export {
|
export {
|
||||||
testRequest,
|
|
||||||
get,
|
|
||||||
post,
|
|
||||||
put,
|
|
||||||
patch,
|
|
||||||
del,
|
|
||||||
assertStatus,
|
|
||||||
assertBodyHas,
|
assertBodyHas,
|
||||||
assertSuccess,
|
|
||||||
assertError,
|
assertError,
|
||||||
|
assertStatus,
|
||||||
|
assertSuccess,
|
||||||
|
del,
|
||||||
|
get,
|
||||||
type ITestRequest,
|
type ITestRequest,
|
||||||
type ITestResponse,
|
type ITestResponse,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
put,
|
||||||
|
testRequest,
|
||||||
} from './http.helper.ts';
|
} from './http.helper.ts';
|
||||||
|
|
||||||
// Subprocess helpers
|
// Subprocess helpers
|
||||||
export {
|
export {
|
||||||
runCommand,
|
|
||||||
commandExists,
|
|
||||||
clients,
|
clients,
|
||||||
skipIfMissing,
|
commandExists,
|
||||||
type ICommandResult,
|
|
||||||
type ICommandOptions,
|
type ICommandOptions,
|
||||||
|
type ICommandResult,
|
||||||
|
runCommand,
|
||||||
|
skipIfMissing,
|
||||||
} from './subprocess.helper.ts';
|
} from './subprocess.helper.ts';
|
||||||
|
|
||||||
// Storage helpers
|
// Storage helpers
|
||||||
export {
|
export {
|
||||||
setupTestStorage,
|
|
||||||
checkStorageAvailable,
|
checkStorageAvailable,
|
||||||
objectExists,
|
cleanupTestStorage,
|
||||||
listObjects,
|
|
||||||
deleteObject,
|
deleteObject,
|
||||||
deletePrefix,
|
deletePrefix,
|
||||||
cleanupTestStorage,
|
|
||||||
isStorageAvailable,
|
isStorageAvailable,
|
||||||
|
listObjects,
|
||||||
|
objectExists,
|
||||||
|
setupTestStorage,
|
||||||
} from './storage.helper.ts';
|
} from './storage.helper.ts';
|
||||||
|
|
||||||
// Re-export test config
|
// Re-export test config
|
||||||
export { testConfig, getTestConfig } from '../test.config.ts';
|
export { getTestConfig, testConfig } from '../test.config.ts';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface ICommandOptions {
|
|||||||
*/
|
*/
|
||||||
export async function runCommand(
|
export async function runCommand(
|
||||||
cmd: string[],
|
cmd: string[],
|
||||||
options: ICommandOptions = {}
|
options: ICommandOptions = {},
|
||||||
): Promise<ICommandResult> {
|
): Promise<ICommandResult> {
|
||||||
const { cwd, env, timeout = 60000, stdin } = options;
|
const { cwd, env, timeout = 60000, stdin } = options;
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export const clients = {
|
|||||||
publish: (dir: string, registry: string, token: string) =>
|
publish: (dir: string, registry: string, token: string) =>
|
||||||
runCommand(
|
runCommand(
|
||||||
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
|
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
|
||||||
{ cwd: dir }
|
{ cwd: dir },
|
||||||
),
|
),
|
||||||
yank: (crate: string, version: string, token: string) =>
|
yank: (crate: string, version: string, token: string) =>
|
||||||
runCommand([
|
runCommand([
|
||||||
@@ -164,7 +164,7 @@ export const clients = {
|
|||||||
'--repository',
|
'--repository',
|
||||||
JSON.stringify({ type: 'composer', url: repository }),
|
JSON.stringify({ type: 'composer', url: repository }),
|
||||||
],
|
],
|
||||||
{ cwd: dir }
|
{ cwd: dir },
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ export const clients = {
|
|||||||
`-Dusername=${username}`,
|
`-Dusername=${username}`,
|
||||||
`-Dpassword=${password}`,
|
`-Dpassword=${password}`,
|
||||||
],
|
],
|
||||||
{ cwd: dir }
|
{ cwd: dir },
|
||||||
),
|
),
|
||||||
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
|
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 {
|
import {
|
||||||
|
assertStatus,
|
||||||
|
cleanupTestDb,
|
||||||
|
createAuthHeader,
|
||||||
|
createTestUser,
|
||||||
|
get,
|
||||||
|
post,
|
||||||
setupTestDb,
|
setupTestDb,
|
||||||
teardownTestDb,
|
teardownTestDb,
|
||||||
cleanupTestDb,
|
|
||||||
createTestUser,
|
|
||||||
post,
|
|
||||||
get,
|
|
||||||
assertStatus,
|
|
||||||
createAuthHeader,
|
|
||||||
} from '../helpers/index.ts';
|
} from '../helpers/index.ts';
|
||||||
|
|
||||||
describe('Auth API Integration', () => {
|
describe('Auth API Integration', () => {
|
||||||
@@ -126,7 +126,7 @@ describe('Auth API Integration', () => {
|
|||||||
// Get current user
|
// Get current user
|
||||||
const meResponse = await get(
|
const meResponse = await get(
|
||||||
'/api/v1/auth/me',
|
'/api/v1/auth/me',
|
||||||
createAuthHeader(loginBody.accessToken as string)
|
createAuthHeader(loginBody.accessToken as string),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(meResponse, 200);
|
assertStatus(meResponse, 200);
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 {
|
import {
|
||||||
setupTestDb,
|
assertStatus,
|
||||||
teardownTestDb,
|
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
|
createAuthHeader,
|
||||||
createTestUser,
|
createTestUser,
|
||||||
|
del,
|
||||||
|
get,
|
||||||
loginUser,
|
loginUser,
|
||||||
post,
|
post,
|
||||||
get,
|
|
||||||
put,
|
put,
|
||||||
del,
|
setupTestDb,
|
||||||
assertStatus,
|
teardownTestDb,
|
||||||
createAuthHeader,
|
|
||||||
} from '../helpers/index.ts';
|
} from '../helpers/index.ts';
|
||||||
|
|
||||||
describe('Organization API Integration', () => {
|
describe('Organization API Integration', () => {
|
||||||
@@ -48,7 +48,7 @@ describe('Organization API Integration', () => {
|
|||||||
displayName: 'My Organization',
|
displayName: 'My Organization',
|
||||||
description: 'A test organization',
|
description: 'A test organization',
|
||||||
},
|
},
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 201);
|
assertStatus(response, 201);
|
||||||
@@ -64,7 +64,7 @@ describe('Organization API Integration', () => {
|
|||||||
name: 'push.rocks',
|
name: 'push.rocks',
|
||||||
displayName: 'Push Rocks',
|
displayName: 'Push Rocks',
|
||||||
},
|
},
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 201);
|
assertStatus(response, 201);
|
||||||
@@ -76,13 +76,13 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'duplicate', displayName: 'First' },
|
{ name: 'duplicate', displayName: 'First' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await post(
|
const response = await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'duplicate', displayName: 'Second' },
|
{ name: 'duplicate', displayName: 'Second' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 409);
|
assertStatus(response, 409);
|
||||||
@@ -92,7 +92,7 @@ describe('Organization API Integration', () => {
|
|||||||
const response = await post(
|
const response = await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: '.invalid', displayName: 'Invalid' },
|
{ name: '.invalid', displayName: 'Invalid' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 400);
|
assertStatus(response, 400);
|
||||||
@@ -105,12 +105,12 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'org1', displayName: 'Org 1' },
|
{ name: 'org1', displayName: 'Org 1' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'org2', displayName: 'Org 2' },
|
{ name: 'org2', displayName: 'Org 2' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
|
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
|
||||||
@@ -126,7 +126,7 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'get-me', displayName: 'Get Me' },
|
{ name: 'get-me', displayName: 'Get Me' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await get('/api/v1/organizations/get-me', 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 () => {
|
it('should return 404 for non-existent org', async () => {
|
||||||
const response = await get(
|
const response = await get(
|
||||||
'/api/v1/organizations/non-existent',
|
'/api/v1/organizations/non-existent',
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 404);
|
assertStatus(response, 404);
|
||||||
@@ -151,13 +151,13 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'update-me', displayName: 'Original' },
|
{ name: 'update-me', displayName: 'Original' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await put(
|
const response = await put(
|
||||||
'/api/v1/organizations/update-me',
|
'/api/v1/organizations/update-me',
|
||||||
{ displayName: 'Updated', description: 'New description' },
|
{ displayName: 'Updated', description: 'New description' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 200);
|
assertStatus(response, 200);
|
||||||
@@ -172,7 +172,7 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'delete-me', displayName: 'Delete Me' },
|
{ name: 'delete-me', displayName: 'Delete Me' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
|
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
|
||||||
@@ -182,7 +182,7 @@ describe('Organization API Integration', () => {
|
|||||||
// Verify deleted
|
// Verify deleted
|
||||||
const getResponse = await get(
|
const getResponse = await get(
|
||||||
'/api/v1/organizations/delete-me',
|
'/api/v1/organizations/delete-me',
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
assertStatus(getResponse, 404);
|
assertStatus(getResponse, 404);
|
||||||
});
|
});
|
||||||
@@ -193,12 +193,12 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'members-org', displayName: 'Members Org' },
|
{ name: 'members-org', displayName: 'Members Org' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await get(
|
const response = await get(
|
||||||
'/api/v1/organizations/members-org/members',
|
'/api/v1/organizations/members-org/members',
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 200);
|
assertStatus(response, 200);
|
||||||
@@ -213,13 +213,13 @@ describe('Organization API Integration', () => {
|
|||||||
await post(
|
await post(
|
||||||
'/api/v1/organizations',
|
'/api/v1/organizations',
|
||||||
{ name: 'add-member-org', displayName: 'Add Member Org' },
|
{ name: 'add-member-org', displayName: 'Add Member Org' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await post(
|
const response = await post(
|
||||||
'/api/v1/organizations/add-member-org/members',
|
'/api/v1/organizations/add-member-org/members',
|
||||||
{ userId: newUser.id, role: 'member' },
|
{ userId: newUser.id, role: 'member' },
|
||||||
createAuthHeader(accessToken)
|
createAuthHeader(accessToken),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertStatus(response, 201);
|
assertStatus(response, 201);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { Qenv } from '@push.rocks/qenv';
|
|||||||
|
|
||||||
const testQenv = new Qenv('./', '.nogit/', false);
|
const testQenv = new Qenv('./', '.nogit/', false);
|
||||||
|
|
||||||
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL')
|
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL') ||
|
||||||
|| 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
|
'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
|
||||||
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME')
|
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') ||
|
||||||
|| 'test-registry';
|
'test-registry';
|
||||||
|
|
||||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT') || 'localhost';
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT') || 'localhost';
|
||||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT') || '9100';
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT') || '9100';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 } from '../../helpers/index.ts';
|
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||||
|
|
||||||
describe('ApiToken Model', () => {
|
describe('ApiToken Model', () => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
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 { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { Organization } from '../../../ts/models/organization.ts';
|
import { Organization } from '../../../ts/models/organization.ts';
|
||||||
|
|
||||||
describe('Organization Model', () => {
|
describe('Organization Model', () => {
|
||||||
@@ -73,7 +73,7 @@ describe('Organization Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'lowercase alphanumeric'
|
'lowercase alphanumeric',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ describe('Organization Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'lowercase alphanumeric'
|
'lowercase alphanumeric',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ describe('Organization Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'lowercase alphanumeric'
|
'lowercase alphanumeric',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ describe('Organization Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'lowercase alphanumeric'
|
'lowercase alphanumeric',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 {
|
import {
|
||||||
setupTestDb,
|
|
||||||
teardownTestDb,
|
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
createTestUser,
|
|
||||||
createOrgWithOwner,
|
createOrgWithOwner,
|
||||||
createTestRepository,
|
createTestRepository,
|
||||||
|
createTestUser,
|
||||||
|
setupTestDb,
|
||||||
|
teardownTestDb,
|
||||||
} from '../../helpers/index.ts';
|
} from '../../helpers/index.ts';
|
||||||
import { Package } from '../../../ts/models/package.ts';
|
import { Package } from '../../../ts/models/package.ts';
|
||||||
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
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 {
|
import {
|
||||||
|
cleanupTestDb,
|
||||||
|
createOrgWithOwner,
|
||||||
|
createTestUser,
|
||||||
setupTestDb,
|
setupTestDb,
|
||||||
teardownTestDb,
|
teardownTestDb,
|
||||||
cleanupTestDb,
|
|
||||||
createTestUser,
|
|
||||||
createOrgWithOwner,
|
|
||||||
} from '../../helpers/index.ts';
|
} from '../../helpers/index.ts';
|
||||||
import { Repository } from '../../../ts/models/repository.ts';
|
import { Repository } from '../../../ts/models/repository.ts';
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ describe('Repository Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'already exists'
|
'already exists',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ describe('Repository Model', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
Error,
|
Error,
|
||||||
'lowercase alphanumeric'
|
'lowercase alphanumeric',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 } from '../../helpers/index.ts';
|
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { Session } from '../../../ts/models/session.ts';
|
import { Session } from '../../../ts/models/session.ts';
|
||||||
|
|
||||||
describe('Session Model', () => {
|
describe('Session Model', () => {
|
||||||
@@ -70,9 +70,21 @@ describe('Session Model', () => {
|
|||||||
|
|
||||||
describe('getUserSessions', () => {
|
describe('getUserSessions', () => {
|
||||||
it('should return all valid sessions for user', async () => {
|
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({
|
||||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
userId: testUserId,
|
||||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
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);
|
const sessions = await Session.getUserSessions(testUserId);
|
||||||
assertEquals(sessions.length, 3);
|
assertEquals(sessions.length, 3);
|
||||||
@@ -110,9 +122,21 @@ describe('Session Model', () => {
|
|||||||
|
|
||||||
describe('invalidateAllUserSessions', () => {
|
describe('invalidateAllUserSessions', () => {
|
||||||
it('should invalidate all user sessions', async () => {
|
it('should invalidate all user sessions', async () => {
|
||||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
await Session.createSession({
|
||||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
userId: testUserId,
|
||||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
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');
|
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
|
||||||
assertEquals(count, 3);
|
assertEquals(count, 3);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
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 { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
|
import { cleanupTestDb, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { User } from '../../../ts/models/user.ts';
|
import { User } from '../../../ts/models/user.ts';
|
||||||
|
|
||||||
describe('User Model', () => {
|
describe('User Model', () => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
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 } from '../../helpers/index.ts';
|
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { AuthService } from '../../../ts/services/auth.service.ts';
|
import { AuthService } from '../../../ts/services/auth.service.ts';
|
||||||
import { Session } from '../../../ts/models/session.ts';
|
import { Session } from '../../../ts/models/session.ts';
|
||||||
import { testConfig } from '../../test.config.ts';
|
import { testConfig } from '../../test.config.ts';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
|
import { assertEquals, assertExists, assertMatch } 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 } from '../../helpers/index.ts';
|
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||||
import { TokenService } from '../../../ts/services/token.service.ts';
|
import { TokenService } from '../../../ts/services/token.service.ts';
|
||||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ describe('TokenService', () => {
|
|||||||
assertExists(result.rawToken);
|
assertExists(result.rawToken);
|
||||||
assertExists(result.token);
|
assertExists(result.token);
|
||||||
|
|
||||||
// Check token format: srg_{prefix}_{random}
|
// Check token format: srg_ + 64 hex chars
|
||||||
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
|
assertMatch(result.rawToken, /^srg_[a-f0-9]{64}$/);
|
||||||
assertEquals(result.token.name, 'test-token');
|
assertEquals(result.token.name, 'test-token');
|
||||||
assertEquals(result.token.protocols.includes('npm'), true);
|
assertEquals(result.token.protocols.includes('npm'), true);
|
||||||
assertEquals(result.token.protocols.includes('oci'), true);
|
assertEquals(result.token.protocols.includes('oci'), true);
|
||||||
@@ -111,13 +111,19 @@ describe('TokenService', () => {
|
|||||||
it('should reject invalid token format', async () => {
|
it('should reject invalid token format', async () => {
|
||||||
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
|
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 () => {
|
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 () => {
|
it('should reject revoked token', async () => {
|
||||||
@@ -132,7 +138,9 @@ describe('TokenService', () => {
|
|||||||
|
|
||||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
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 () => {
|
it('should reject expired token', async () => {
|
||||||
@@ -150,7 +158,8 @@ describe('TokenService', () => {
|
|||||||
|
|
||||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
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 () => {
|
it('should record usage on validation', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@stack.gallery/registry',
|
name: '@stack.gallery/registry',
|
||||||
version: '1.4.2',
|
version: '1.5.0',
|
||||||
description: 'Enterprise-grade multi-protocol package registry'
|
description: 'Enterprise-grade multi-protocol package registry'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ export class AdminAuthApi {
|
|||||||
});
|
});
|
||||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||||
// Encrypt bind password
|
// Encrypt bind password
|
||||||
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
|
const encryptedPassword = await cryptoService.encrypt(
|
||||||
|
body.ldapConfig.bindPasswordEncrypted,
|
||||||
|
);
|
||||||
|
|
||||||
provider = await AuthProvider.createLdapProvider({
|
provider = await AuthProvider.createLdapProvider({
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -228,7 +230,7 @@ export class AdminAuthApi {
|
|||||||
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
||||||
) {
|
) {
|
||||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||||
body.oauthConfig.clientSecretEncrypted
|
body.oauthConfig.clientSecretEncrypted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +247,7 @@ export class AdminAuthApi {
|
|||||||
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
||||||
) {
|
) {
|
||||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||||
body.ldapConfig.bindPasswordEncrypted
|
body.ldapConfig.bindPasswordEncrypted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export class AuditApi {
|
|||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||||
const repositoryId = ctx.url.searchParams.get('repositoryId') || 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 actionsParam = ctx.url.searchParams.get('actions');
|
||||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||||
const success = ctx.url.searchParams.has('success')
|
const success = ctx.url.searchParams.has('success')
|
||||||
@@ -54,7 +56,7 @@ export class AuditApi {
|
|||||||
// Check if user can manage this org
|
// Check if user can manage this org
|
||||||
const canManage = await this.permissionService.canManageOrganization(
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
organizationId
|
organizationId,
|
||||||
);
|
);
|
||||||
if (!canManage) {
|
if (!canManage) {
|
||||||
// User can only see their own actions in this org
|
// User can only see their own actions in this org
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export class OAuthApi {
|
|||||||
|
|
||||||
const result = await externalAuthService.handleOAuthCallback(
|
const result = await externalAuthService.handleOAuthCallback(
|
||||||
{ code, state },
|
{ code, state },
|
||||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
|
{ ipAddress: ctx.ip, userAgent: ctx.userAgent },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ export class OrganizationApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check admin permission using org.id
|
// 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) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
}
|
}
|
||||||
@@ -325,7 +328,7 @@ export class OrganizationApi {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -356,7 +359,10 @@ export class OrganizationApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check admin permission
|
// 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) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
}
|
}
|
||||||
@@ -431,7 +437,10 @@ export class OrganizationApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check admin permission
|
// 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) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
}
|
}
|
||||||
@@ -492,7 +501,10 @@ export class OrganizationApi {
|
|||||||
|
|
||||||
// Users can remove themselves, admins can remove others
|
// Users can remove themselves, admins can remove others
|
||||||
if (userId !== ctx.actor.userId) {
|
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) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class PackageApi {
|
|||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
pkg.organizationId,
|
pkg.organizationId,
|
||||||
pkg.repositoryId,
|
pkg.repositoryId,
|
||||||
'read'
|
'read',
|
||||||
);
|
);
|
||||||
if (canAccess) {
|
if (canAccess) {
|
||||||
accessiblePackages.push(pkg);
|
accessiblePackages.push(pkg);
|
||||||
@@ -106,7 +106,7 @@ export class PackageApi {
|
|||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
pkg.organizationId,
|
pkg.organizationId,
|
||||||
pkg.repositoryId,
|
pkg.repositoryId,
|
||||||
'read'
|
'read',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canAccess) {
|
if (!canAccess) {
|
||||||
@@ -161,7 +161,7 @@ export class PackageApi {
|
|||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
pkg.organizationId,
|
pkg.organizationId,
|
||||||
pkg.repositoryId,
|
pkg.repositoryId,
|
||||||
'read'
|
'read',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canAccess) {
|
if (!canAccess) {
|
||||||
@@ -213,7 +213,7 @@ export class PackageApi {
|
|||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
pkg.organizationId,
|
pkg.organizationId,
|
||||||
pkg.repositoryId,
|
pkg.repositoryId,
|
||||||
'delete'
|
'delete',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canDelete) {
|
if (!canDelete) {
|
||||||
@@ -267,7 +267,7 @@ export class PackageApi {
|
|||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
pkg.organizationId,
|
pkg.organizationId,
|
||||||
pkg.repositoryId,
|
pkg.repositoryId,
|
||||||
'delete'
|
'delete',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canDelete) {
|
if (!canDelete) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||||
import { PermissionService } from '../../services/permission.service.ts';
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
import { AuditService } from '../../services/audit.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';
|
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
|
||||||
|
|
||||||
export class RepositoryApi {
|
export class RepositoryApi {
|
||||||
@@ -28,7 +28,7 @@ export class RepositoryApi {
|
|||||||
try {
|
try {
|
||||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
orgId
|
orgId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,7 +131,10 @@ export class RepositoryApi {
|
|||||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
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(
|
const canManage = await this.permissionService.canManageRepository(
|
||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
repo.organizationId,
|
repo.organizationId,
|
||||||
id
|
id,
|
||||||
);
|
);
|
||||||
if (!canManage) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
@@ -252,7 +255,7 @@ export class RepositoryApi {
|
|||||||
const canManage = await this.permissionService.canManageRepository(
|
const canManage = await this.permissionService.canManageRepository(
|
||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
repo.organizationId,
|
repo.organizationId,
|
||||||
id
|
id,
|
||||||
);
|
);
|
||||||
if (!canManage) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Admin access required' } };
|
return { status: 403, body: { error: 'Admin access required' } };
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export class TokenApi {
|
|||||||
let tokens;
|
let tokens;
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
// Check if user can manage org
|
// 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) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
|
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 creating org token, verify permission
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
ctx.actor.userId,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
if (!canManage) {
|
if (!canManage) {
|
||||||
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
|
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
|
||||||
}
|
}
|
||||||
@@ -181,7 +187,7 @@ export class TokenApi {
|
|||||||
if (anyToken?.organizationId) {
|
if (anyToken?.organizationId) {
|
||||||
const canManage = await this.permissionService.canManageOrganization(
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
ctx.actor.userId,
|
ctx.actor.userId,
|
||||||
anyToken.organizationId
|
anyToken.organizationId,
|
||||||
);
|
);
|
||||||
if (canManage) {
|
if (canManage) {
|
||||||
token = anyToken;
|
token = anyToken;
|
||||||
|
|||||||
@@ -104,24 +104,56 @@ export class ApiRouter {
|
|||||||
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
|
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('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
|
||||||
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(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(
|
||||||
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
|
'GET',
|
||||||
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
|
'/api/v1/organizations/:id/members',
|
||||||
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
|
(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
|
// 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('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('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
|
||||||
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
||||||
|
|
||||||
// Package routes
|
// Package routes
|
||||||
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
|
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', (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', (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
|
// Token routes
|
||||||
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
|
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));
|
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
|
||||||
|
|
||||||
// Admin auth routes (platform admin only)
|
// Admin auth routes (platform admin only)
|
||||||
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
|
this.addRoute(
|
||||||
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
|
'GET',
|
||||||
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
|
'/api/v1/admin/auth/providers',
|
||||||
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
|
(ctx) => this.adminAuthApi.listProviders(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(
|
||||||
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
|
'POST',
|
||||||
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
|
'/api/v1/admin/auth/providers',
|
||||||
|
(ctx) => this.adminAuthApi.createProvider(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/admin/auth/providers/:id',
|
||||||
|
(ctx) => this.adminAuthApi.getProvider(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'PUT',
|
||||||
|
'/api/v1/admin/auth/providers/:id',
|
||||||
|
(ctx) => this.adminAuthApi.updateProvider(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'DELETE',
|
||||||
|
'/api/v1/admin/auth/providers/:id',
|
||||||
|
(ctx) => this.adminAuthApi.deleteProvider(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/admin/auth/providers/:id/test',
|
||||||
|
(ctx) => this.adminAuthApi.testProvider(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/admin/auth/settings',
|
||||||
|
(ctx) => this.adminAuthApi.getSettings(ctx),
|
||||||
|
);
|
||||||
|
this.addRoute(
|
||||||
|
'PUT',
|
||||||
|
'/api/v1/admin/auth/settings',
|
||||||
|
(ctx) => this.adminAuthApi.updateSettings(ctx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
12
ts/cli.ts
12
ts/cli.ts
@@ -3,9 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './plugins.ts';
|
import * 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 { 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';
|
import { AuthService } from './services/auth.service.ts';
|
||||||
|
|
||||||
export async function runCli(): Promise<void> {
|
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
|
// Use env file in ephemeral/dev mode, otherwise use environment variables
|
||||||
const registry = isEphemeral
|
const registry = isEphemeral ? await createRegistryFromEnvFile() : createRegistryFromEnv();
|
||||||
? await createRegistryFromEnvFile()
|
|
||||||
: createRegistryFromEnv();
|
|
||||||
await registry.start();
|
await registry.start();
|
||||||
|
|
||||||
// Handle shutdown gracefully
|
// Handle shutdown gracefully
|
||||||
|
|||||||
@@ -103,7 +103,14 @@ export interface ITeamMember {
|
|||||||
|
|
||||||
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
|
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
|
||||||
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
|
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 {
|
export interface IRepository {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/au
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
|||||||
protocol: TRegistryProtocol,
|
protocol: TRegistryProtocol,
|
||||||
organizationId?: string,
|
organizationId?: string,
|
||||||
repositoryId?: string,
|
repositoryId?: string,
|
||||||
action?: string
|
action?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
for (const scope of this.scopes) {
|
for (const scope of this.scopes) {
|
||||||
// Check protocol
|
// Check protocol
|
||||||
@@ -163,7 +164,9 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
|||||||
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
|
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
|
||||||
|
|
||||||
// Check action
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
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';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import type {
|
import type {
|
||||||
IAuthProvider,
|
|
||||||
TAuthProviderType,
|
|
||||||
TAuthProviderStatus,
|
|
||||||
IOAuthConfig,
|
|
||||||
ILdapConfig,
|
|
||||||
IAttributeMapping,
|
IAttributeMapping,
|
||||||
|
IAuthProvider,
|
||||||
|
ILdapConfig,
|
||||||
|
IOAuthConfig,
|
||||||
IProvisioningSettings,
|
IProvisioningSettings,
|
||||||
|
TAuthProviderStatus,
|
||||||
|
TAuthProviderType,
|
||||||
} from '../interfaces/auth.interfaces.ts';
|
} from '../interfaces/auth.interfaces.ts';
|
||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@@ -27,10 +27,8 @@ const DEFAULT_PROVISIONING: IProvisioningSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@plugins.smartdata.Collection(() => db)
|
||||||
export class AuthProvider
|
export class AuthProvider extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||||
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
implements IAuthProvider {
|
||||||
implements IAuthProvider
|
|
||||||
{
|
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ let isInitialized = false;
|
|||||||
*/
|
*/
|
||||||
export async function initDb(
|
export async function initDb(
|
||||||
mongoDbUrl: string,
|
mongoDbUrl: string,
|
||||||
mongoDbName?: string
|
mongoDbName?: string,
|
||||||
): Promise<plugins.smartdata.SmartdataDb> {
|
): Promise<plugins.smartdata.SmartdataDb> {
|
||||||
if (isInitialized && db) {
|
if (isInitialized && db) {
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { db } from './db.ts';
|
|||||||
@plugins.smartdata.Collection(() => db)
|
@plugins.smartdata.Collection(() => db)
|
||||||
export class ExternalIdentity
|
export class ExternalIdentity
|
||||||
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
||||||
implements IExternalIdentity
|
implements IExternalIdentity {
|
||||||
{
|
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ export class ExternalIdentity
|
|||||||
*/
|
*/
|
||||||
public static async findByExternalId(
|
public static async findByExternalId(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
externalId: string
|
externalId: string,
|
||||||
): Promise<ExternalIdentity | null> {
|
): Promise<ExternalIdentity | null> {
|
||||||
return await ExternalIdentity.getInstance({ providerId, externalId });
|
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||||
}
|
}
|
||||||
@@ -72,7 +71,7 @@ export class ExternalIdentity
|
|||||||
*/
|
*/
|
||||||
public static async findByUserAndProvider(
|
public static async findByUserAndProvider(
|
||||||
userId: string,
|
userId: string,
|
||||||
providerId: string
|
providerId: string,
|
||||||
): Promise<ExternalIdentity | null> {
|
): Promise<ExternalIdentity | null> {
|
||||||
return await ExternalIdentity.getInstance({ userId, providerId });
|
return await ExternalIdentity.getInstance({ userId, providerId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Model exports
|
* Model exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
|
export { closeDb, getDb, initDb, isDbConnected } from './db.ts';
|
||||||
export { User } from './user.ts';
|
export { User } from './user.ts';
|
||||||
export { Organization } from './organization.ts';
|
export { Organization } from './organization.ts';
|
||||||
export { OrganizationMember } from './organization.member.ts';
|
export { OrganizationMember } from './organization.member.ts';
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<Organiz
|
|||||||
*/
|
*/
|
||||||
public static async findMembership(
|
public static async findMembership(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
): Promise<OrganizationMember | null> {
|
): Promise<OrganizationMember | null> {
|
||||||
return await OrganizationMember.getInstance({
|
return await OrganizationMember.getInstance({
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const DEFAULT_SETTINGS: IOrganizationSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
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])?$/;
|
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||||
if (!nameRegex.test(data.name)) {
|
if (!nameRegex.test(data.name)) {
|
||||||
throw new Error(
|
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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = ''; // {protocol}:{org}:{name}
|
public id: string = ''; // {protocol}:{org}:{name}
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
|||||||
public static async findByName(
|
public static async findByName(
|
||||||
protocol: TRegistryProtocol,
|
protocol: TRegistryProtocol,
|
||||||
orgName: string,
|
orgName: string,
|
||||||
name: string
|
name: string,
|
||||||
): Promise<Package | null> {
|
): Promise<Package | null> {
|
||||||
const id = Package.generateId(protocol, orgName, name);
|
const id = Package.generateId(protocol, orgName, name);
|
||||||
return await Package.findById(id);
|
return await Package.findById(id);
|
||||||
@@ -118,7 +119,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
|||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
},
|
||||||
): Promise<Package[]> {
|
): Promise<Package[]> {
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: Record<string, unknown> = {};
|
||||||
if (options?.protocol) filter.protocol = options.protocol;
|
if (options?.protocol) filter.protocol = options.protocol;
|
||||||
@@ -133,7 +134,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
|||||||
const filtered = allPackages.filter(
|
const filtered = allPackages.filter(
|
||||||
(pkg) =>
|
(pkg) =>
|
||||||
pkg.name.toLowerCase().includes(lowerQuery) ||
|
pkg.name.toLowerCase().includes(lowerQuery) ||
|
||||||
pkg.description?.toLowerCase().includes(lowerQuery)
|
pkg.description?.toLowerCase().includes(lowerQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
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';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||||
@@ -16,8 +16,7 @@ const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
|||||||
@plugins.smartdata.Collection(() => db)
|
@plugins.smartdata.Collection(() => db)
|
||||||
export class PlatformSettings
|
export class PlatformSettings
|
||||||
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
||||||
implements IPlatformSettings
|
implements IPlatformSettings {
|
||||||
{
|
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
public id: string = 'singleton';
|
public id: string = 'singleton';
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export class PlatformSettings
|
|||||||
*/
|
*/
|
||||||
public async updateAuthSettings(
|
public async updateAuthSettings(
|
||||||
settings: Partial<IPlatformAuthSettings>,
|
settings: Partial<IPlatformAuthSettings>,
|
||||||
updatedById?: string
|
updatedById?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.auth = { ...this.auth, ...settings };
|
this.auth = { ...this.auth, ...settings };
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
|||||||
*/
|
*/
|
||||||
public static async findPermission(
|
public static async findPermission(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
): Promise<RepositoryPermission | null> {
|
): Promise<RepositoryPermission | null> {
|
||||||
return await RepositoryPermission.getUserPermission(repositoryId, userId);
|
return await RepositoryPermission.getUserPermission(repositoryId, userId);
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
|||||||
*/
|
*/
|
||||||
public static async getUserPermission(
|
public static async getUserPermission(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
): Promise<RepositoryPermission | null> {
|
): Promise<RepositoryPermission | null> {
|
||||||
return await RepositoryPermission.getInstance({
|
return await RepositoryPermission.getInstance({
|
||||||
repositoryId,
|
repositoryId,
|
||||||
@@ -127,7 +129,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
|||||||
*/
|
*/
|
||||||
public static async getTeamPermission(
|
public static async getTeamPermission(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
teamId: string
|
teamId: string,
|
||||||
): Promise<RepositoryPermission | null> {
|
): Promise<RepositoryPermission | null> {
|
||||||
return await RepositoryPermission.getInstance({
|
return await RepositoryPermission.getInstance({
|
||||||
repositoryId,
|
repositoryId,
|
||||||
@@ -149,7 +151,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
|||||||
*/
|
*/
|
||||||
public static async getTeamPermissionsForRepo(
|
public static async getTeamPermissionsForRepo(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
teamIds: string[]
|
teamIds: string[],
|
||||||
): Promise<RepositoryPermission[]> {
|
): Promise<RepositoryPermission[]> {
|
||||||
if (teamIds.length === 0) return [];
|
if (teamIds.length === 0) return [];
|
||||||
return await RepositoryPermission.getInstances({
|
return await RepositoryPermission.getInstances({
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
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';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -70,7 +75,9 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
|||||||
// Validate name
|
// Validate name
|
||||||
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
||||||
if (!nameRegex.test(data.name.toLowerCase())) {
|
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
|
// Check for duplicate name in org + protocol
|
||||||
@@ -105,7 +112,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
|||||||
public static async findByName(
|
public static async findByName(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
protocol: TRegistryProtocol
|
protocol: TRegistryProtocol,
|
||||||
): Promise<Repository | null> {
|
): Promise<Repository | null> {
|
||||||
return await Repository.getInstance({
|
return await Repository.getInstance({
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { ISession } from '../interfaces/auth.interfaces.ts';
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
|||||||
*/
|
*/
|
||||||
public static async invalidateAllUserSessions(
|
public static async invalidateAllUserSessions(
|
||||||
userId: string,
|
userId: string,
|
||||||
reason: string = 'logout_all'
|
reason: string = 'logout_all',
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const sessions = await Session.getUserSessions(userId);
|
const sessions = await Session.getUserSessions(userId);
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
|
|||||||
import { db } from './db.ts';
|
import { db } from './db.ts';
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => db)
|
@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()
|
@plugins.smartdata.unI()
|
||||||
public id: string = '';
|
public id: string = '';
|
||||||
|
|
||||||
|
|||||||
51
ts/opsserver/classes.opsserver.ts
Normal file
51
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import type { StackGalleryRegistry } from '../registry.ts';
|
||||||
|
import * as handlers from './handlers/index.ts';
|
||||||
|
|
||||||
|
export class OpsServer {
|
||||||
|
public registryRef: StackGalleryRegistry;
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Handler instances
|
||||||
|
public authHandler!: handlers.AuthHandler;
|
||||||
|
public organizationHandler!: handlers.OrganizationHandler;
|
||||||
|
public repositoryHandler!: handlers.RepositoryHandler;
|
||||||
|
public packageHandler!: handlers.PackageHandler;
|
||||||
|
public tokenHandler!: handlers.TokenHandler;
|
||||||
|
public auditHandler!: handlers.AuditHandler;
|
||||||
|
public adminHandler!: handlers.AdminHandler;
|
||||||
|
public oauthHandler!: handlers.OAuthHandler;
|
||||||
|
public userHandler!: handlers.UserHandler;
|
||||||
|
|
||||||
|
constructor(registryRef: StackGalleryRegistry) {
|
||||||
|
this.registryRef = registryRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all handlers. Must be called before routing requests.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
// AuthHandler must be initialized first (other handlers depend on its guards)
|
||||||
|
this.authHandler = new handlers.AuthHandler(this);
|
||||||
|
await this.authHandler.initialize();
|
||||||
|
|
||||||
|
// All other handlers self-register in their constructors
|
||||||
|
this.organizationHandler = new handlers.OrganizationHandler(this);
|
||||||
|
this.repositoryHandler = new handlers.RepositoryHandler(this);
|
||||||
|
this.packageHandler = new handlers.PackageHandler(this);
|
||||||
|
this.tokenHandler = new handlers.TokenHandler(this);
|
||||||
|
this.auditHandler = new handlers.AuditHandler(this);
|
||||||
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
|
this.oauthHandler = new handlers.OAuthHandler(this);
|
||||||
|
this.userHandler = new handlers.UserHandler(this);
|
||||||
|
|
||||||
|
console.log('[OpsServer] TypedRequest handlers initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
console.log('[OpsServer] Stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
380
ts/opsserver/handlers/admin.handler.ts
Normal file
380
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireAdminIdentity } from '../helpers/guards.ts';
|
||||||
|
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||||
|
import { cryptoService } from '../../services/crypto.service.ts';
|
||||||
|
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||||
|
import { AuditService } from '../../services/audit.service.ts';
|
||||||
|
|
||||||
|
export class AdminHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get Admin Providers
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProviders>(
|
||||||
|
'getAdminProviders',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = await AuthProvider.getAllProviders();
|
||||||
|
return {
|
||||||
|
providers: providers.map((p) => p.toAdminInfo()),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list providers');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Admin Provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateAdminProvider>(
|
||||||
|
'createAdminProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, displayName, type, oauthConfig, ldapConfig, attributeMapping, provisioning } = dataArg;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !displayName || !type) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'name, displayName, and type are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name uniqueness
|
||||||
|
const existing = await AuthProvider.findByName(name);
|
||||||
|
if (existing) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Provider name already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type-specific config
|
||||||
|
if (type === 'oidc' && !oauthConfig) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'oauthConfig is required for OIDC provider',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'ldap' && !ldapConfig) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'ldapConfig is required for LDAP provider',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider: AuthProvider;
|
||||||
|
|
||||||
|
if (type === 'oidc' && oauthConfig) {
|
||||||
|
// Encrypt client secret
|
||||||
|
const encryptedSecret = await cryptoService.encrypt(
|
||||||
|
oauthConfig.clientSecretEncrypted,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider = await AuthProvider.createOAuthProvider({
|
||||||
|
name,
|
||||||
|
displayName,
|
||||||
|
oauthConfig: {
|
||||||
|
...oauthConfig,
|
||||||
|
clientSecretEncrypted: encryptedSecret,
|
||||||
|
},
|
||||||
|
attributeMapping,
|
||||||
|
provisioning,
|
||||||
|
createdById: dataArg.identity.userId,
|
||||||
|
});
|
||||||
|
} else if (type === 'ldap' && ldapConfig) {
|
||||||
|
// Encrypt bind password
|
||||||
|
const encryptedPassword = await cryptoService.encrypt(
|
||||||
|
ldapConfig.bindPasswordEncrypted,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider = await AuthProvider.createLdapProvider({
|
||||||
|
name,
|
||||||
|
displayName,
|
||||||
|
ldapConfig: {
|
||||||
|
...ldapConfig,
|
||||||
|
bindPasswordEncrypted: encryptedPassword,
|
||||||
|
},
|
||||||
|
attributeMapping,
|
||||||
|
provisioning,
|
||||||
|
createdById: dataArg.identity.userId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid provider type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
|
||||||
|
resourceId: provider.id,
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
providerName: provider.name,
|
||||||
|
providerType: provider.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { provider: provider.toAdminInfo() };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Admin Provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProvider>(
|
||||||
|
'getAdminProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { provider: provider.toAdminInfo() };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Admin Provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAdminProvider>(
|
||||||
|
'updateAdminProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update basic fields
|
||||||
|
if (dataArg.displayName !== undefined) provider.displayName = dataArg.displayName;
|
||||||
|
if (dataArg.status !== undefined) provider.status = dataArg.status as any;
|
||||||
|
if (dataArg.priority !== undefined) provider.priority = dataArg.priority;
|
||||||
|
|
||||||
|
// Update OAuth config
|
||||||
|
if (dataArg.oauthConfig && provider.oauthConfig) {
|
||||||
|
const newOAuthConfig = { ...provider.oauthConfig, ...dataArg.oauthConfig };
|
||||||
|
|
||||||
|
// Encrypt new client secret if provided and not already encrypted
|
||||||
|
if (
|
||||||
|
dataArg.oauthConfig.clientSecretEncrypted &&
|
||||||
|
!cryptoService.isEncrypted(dataArg.oauthConfig.clientSecretEncrypted)
|
||||||
|
) {
|
||||||
|
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||||
|
dataArg.oauthConfig.clientSecretEncrypted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.oauthConfig = newOAuthConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update LDAP config
|
||||||
|
if (dataArg.ldapConfig && provider.ldapConfig) {
|
||||||
|
const newLdapConfig = { ...provider.ldapConfig, ...dataArg.ldapConfig };
|
||||||
|
|
||||||
|
// Encrypt new bind password if provided and not already encrypted
|
||||||
|
if (
|
||||||
|
dataArg.ldapConfig.bindPasswordEncrypted &&
|
||||||
|
!cryptoService.isEncrypted(dataArg.ldapConfig.bindPasswordEncrypted)
|
||||||
|
) {
|
||||||
|
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||||
|
dataArg.ldapConfig.bindPasswordEncrypted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.ldapConfig = newLdapConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update attribute mapping
|
||||||
|
if (dataArg.attributeMapping) {
|
||||||
|
provider.attributeMapping = {
|
||||||
|
...provider.attributeMapping,
|
||||||
|
...dataArg.attributeMapping,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update provisioning settings
|
||||||
|
if (dataArg.provisioning) {
|
||||||
|
provider.provisioning = {
|
||||||
|
...provider.provisioning,
|
||||||
|
...dataArg.provisioning,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
await provider.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
|
||||||
|
resourceId: provider.id,
|
||||||
|
success: true,
|
||||||
|
metadata: { providerName: provider.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { provider: provider.toAdminInfo() };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Admin Provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAdminProvider>(
|
||||||
|
'deleteAdminProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete - disable instead of removing
|
||||||
|
provider.status = 'disabled';
|
||||||
|
await provider.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
|
||||||
|
resourceId: provider.id,
|
||||||
|
success: true,
|
||||||
|
metadata: { providerName: provider.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Provider disabled' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test Admin Provider
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestAdminProvider>(
|
||||||
|
'testAdminProvider',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await externalAuthService.testConnection(dataArg.providerId);
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
|
||||||
|
resourceId: dataArg.providerId,
|
||||||
|
success: result.success,
|
||||||
|
metadata: {
|
||||||
|
result: result.success ? 'success' : 'failure',
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
error: result.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { result };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to test provider');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Platform Settings
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformSettings>(
|
||||||
|
'getPlatformSettings',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await PlatformSettings.get();
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
id: settings.id,
|
||||||
|
auth: settings.auth,
|
||||||
|
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||||
|
updatedById: settings.updatedById,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get settings');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Platform Settings
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdatePlatformSettings>(
|
||||||
|
'updatePlatformSettings',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await PlatformSettings.get();
|
||||||
|
|
||||||
|
if (dataArg.auth) {
|
||||||
|
await settings.updateAuthSettings(dataArg.auth as any, dataArg.identity.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
|
||||||
|
resourceId: 'platform-settings',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
id: settings.id,
|
||||||
|
auth: settings.auth,
|
||||||
|
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||||
|
updatedById: settings.updatedById,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update settings');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
ts/opsserver/handlers/audit.handler.ts
Normal file
105
ts/opsserver/handlers/audit.handler.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
import { AuditLog } from '../../models/auditlog.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
|
|
||||||
|
export class AuditHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private permissionService = new PermissionService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Query Audit Logs
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_QueryAudit>(
|
||||||
|
'queryAudit',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
organizationId,
|
||||||
|
repositoryId,
|
||||||
|
resourceType,
|
||||||
|
actions,
|
||||||
|
success,
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
limit: limitParam,
|
||||||
|
offset: offsetParam,
|
||||||
|
} = dataArg;
|
||||||
|
|
||||||
|
const limit = limitParam || 100;
|
||||||
|
const offset = offsetParam || 0;
|
||||||
|
const startDate = startDateStr ? new Date(startDateStr) : undefined;
|
||||||
|
const endDate = endDateStr ? new Date(endDateStr) : undefined;
|
||||||
|
|
||||||
|
// Determine actor filter based on permissions
|
||||||
|
let actorId: string | undefined;
|
||||||
|
|
||||||
|
if (dataArg.identity.isSystemAdmin) {
|
||||||
|
// System admins can see all
|
||||||
|
actorId = dataArg.actorId;
|
||||||
|
} else if (organizationId) {
|
||||||
|
// Check if user can manage this org
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
// User can only see their own actions in this org
|
||||||
|
actorId = dataArg.identity.userId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-admins without org filter can only see their own actions
|
||||||
|
actorId = dataArg.identity.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AuditLog.query({
|
||||||
|
actorId,
|
||||||
|
organizationId,
|
||||||
|
repositoryId,
|
||||||
|
resourceType: resourceType as any,
|
||||||
|
action: actions as any[],
|
||||||
|
success,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs: result.logs.map((log) => ({
|
||||||
|
id: log.id,
|
||||||
|
actorId: log.actorId,
|
||||||
|
actorType: log.actorType as interfaces.data.IAuditEntry['actorType'],
|
||||||
|
action: log.action as interfaces.data.TAuditAction,
|
||||||
|
resourceType: log.resourceType as interfaces.data.TAuditResourceType,
|
||||||
|
resourceId: log.resourceId,
|
||||||
|
resourceName: log.resourceName,
|
||||||
|
organizationId: log.organizationId,
|
||||||
|
repositoryId: log.repositoryId,
|
||||||
|
success: log.success,
|
||||||
|
errorCode: log.errorCode,
|
||||||
|
timestamp: log.timestamp instanceof Date ? log.timestamp.toISOString() : String(log.timestamp),
|
||||||
|
metadata: log.metadata || {},
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to query audit logs');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
ts/opsserver/handlers/auth.handler.ts
Normal file
263
ts/opsserver/handlers/auth.handler.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { AuthService } from '../../services/auth.service.ts';
|
||||||
|
import { User } from '../../models/user.ts';
|
||||||
|
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||||
|
|
||||||
|
export class AuthHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private authService: AuthService;
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.authService = new AuthService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auth handler - must be called after construction
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Login
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Login>(
|
||||||
|
'login',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = dataArg;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Email and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.login(email, password);
|
||||||
|
|
||||||
|
if (!result.success || !result.user || !result.accessToken || !result.refreshToken) {
|
||||||
|
return {
|
||||||
|
errorCode: result.errorCode,
|
||||||
|
errorMessage: result.errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.user;
|
||||||
|
const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
const identity: interfaces.data.IIdentity = {
|
||||||
|
jwt: result.accessToken,
|
||||||
|
refreshJwt: result.refreshToken,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
expiresAt,
|
||||||
|
sessionId: result.sessionId!,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt instanceof Date
|
||||||
|
? user.createdAt.toISOString()
|
||||||
|
: String(user.createdAt),
|
||||||
|
lastLoginAt: user.lastLoginAt instanceof Date
|
||||||
|
? user.lastLoginAt.toISOString()
|
||||||
|
: user.lastLoginAt
|
||||||
|
? String(user.lastLoginAt)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Login failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh Token
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RefreshToken>(
|
||||||
|
'refreshToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
if (!dataArg.identity?.refreshJwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Refresh token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.refresh(dataArg.identity.refreshJwt);
|
||||||
|
|
||||||
|
if (!result.success || !result.user || !result.accessToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
result.errorMessage || 'Token refresh failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.user;
|
||||||
|
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
jwt: result.accessToken,
|
||||||
|
refreshJwt: dataArg.identity.refreshJwt,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
expiresAt,
|
||||||
|
sessionId: result.sessionId || dataArg.identity.sessionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Token refresh failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_Logout>(
|
||||||
|
'logout',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Identity required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArg.all) {
|
||||||
|
const count = await this.authService.logoutAll(dataArg.identity.userId);
|
||||||
|
return { message: `Logged out from ${count} sessions` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = dataArg.sessionId || dataArg.identity.sessionId;
|
||||||
|
if (sessionId) {
|
||||||
|
await this.authService.logout(sessionId, {
|
||||||
|
userId: dataArg.identity.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Logout failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Me
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMe>(
|
||||||
|
'getMe',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Identity required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
|
||||||
|
if (!validated) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid or expired token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = validated.user;
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt instanceof Date
|
||||||
|
? user.createdAt.toISOString()
|
||||||
|
: String(user.createdAt),
|
||||||
|
lastLoginAt: user.lastLoginAt instanceof Date
|
||||||
|
? user.lastLoginAt.toISOString()
|
||||||
|
: user.lastLoginAt
|
||||||
|
? String(user.lastLoginAt)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get user info');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Auth Providers (public)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAuthProviders>(
|
||||||
|
'getAuthProviders',
|
||||||
|
async (_dataArg) => {
|
||||||
|
try {
|
||||||
|
const settings = await PlatformSettings.get();
|
||||||
|
const providers = await AuthProvider.getActiveProviders();
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: providers.map((p) => p.toPublicInfo()),
|
||||||
|
localAuthEnabled: settings.auth.localAuthEnabled,
|
||||||
|
defaultProviderId: settings.auth.defaultProviderId,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get auth providers');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard for valid identity - validates JWT via AuthService
|
||||||
|
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
if (!dataArg.identity?.jwt) return false;
|
||||||
|
try {
|
||||||
|
const validated = await this.authService.validateAccessToken(dataArg.identity.jwt);
|
||||||
|
if (!validated) return false;
|
||||||
|
// Verify the userId matches the identity claim
|
||||||
|
if (dataArg.identity.userId !== validated.user.id) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guard for admin identity - validates JWT + checks isSystemAdmin
|
||||||
|
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||||
|
if (!isValid) return false;
|
||||||
|
// Check isSystemAdmin claim from the identity
|
||||||
|
if (!dataArg.identity.isSystemAdmin) return false;
|
||||||
|
// Double-check from database
|
||||||
|
const user = await User.findById(dataArg.identity.userId);
|
||||||
|
if (!user || !user.isSystemAdmin) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||||
|
);
|
||||||
|
}
|
||||||
9
ts/opsserver/handlers/index.ts
Normal file
9
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './auth.handler.ts';
|
||||||
|
export * from './organization.handler.ts';
|
||||||
|
export * from './repository.handler.ts';
|
||||||
|
export * from './package.handler.ts';
|
||||||
|
export * from './token.handler.ts';
|
||||||
|
export * from './audit.handler.ts';
|
||||||
|
export * from './admin.handler.ts';
|
||||||
|
export * from './oauth.handler.ts';
|
||||||
|
export * from './user.handler.ts';
|
||||||
160
ts/opsserver/handlers/oauth.handler.ts
Normal file
160
ts/opsserver/handlers/oauth.handler.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||||
|
|
||||||
|
export class OAuthHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// OAuth Authorize - initiate OAuth flow
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthAuthorize>(
|
||||||
|
'oauthAuthorize',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const { providerId, returnUrl } = dataArg;
|
||||||
|
|
||||||
|
const { authUrl } = await externalAuthService.initiateOAuth(providerId, returnUrl);
|
||||||
|
|
||||||
|
return { redirectUrl: authUrl };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// OAuth Callback - handle provider callback
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_OAuthCallback>(
|
||||||
|
'oauthCallback',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const { code, state } = dataArg;
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return {
|
||||||
|
errorCode: 'MISSING_PARAMETERS',
|
||||||
|
errorMessage: 'Code and state are required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await externalAuthService.handleOAuthCallback(
|
||||||
|
{ code, state },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
errorCode: result.errorCode,
|
||||||
|
errorMessage: result.errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.user!;
|
||||||
|
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
jwt: result.accessToken!,
|
||||||
|
refreshJwt: result.refreshToken!,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
expiresAt,
|
||||||
|
sessionId: result.sessionId!,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||||
|
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('OAuth callback failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// LDAP Login
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_LdapLogin>(
|
||||||
|
'ldapLogin',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const { providerId, username, password } = dataArg;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Username and password are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await externalAuthService.authenticateLdap(
|
||||||
|
providerId,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
errorCode: result.errorCode,
|
||||||
|
errorMessage: result.errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.user!;
|
||||||
|
const expiresAt = Date.now() + 15 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
jwt: result.accessToken!,
|
||||||
|
refreshJwt: result.refreshToken!,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
expiresAt,
|
||||||
|
sessionId: result.sessionId!,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||||
|
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('LDAP login failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
548
ts/opsserver/handlers/organization.handler.ts
Normal file
548
ts/opsserver/handlers/organization.handler.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
|
import { AuditService } from '../../services/audit.service.ts';
|
||||||
|
|
||||||
|
export class OrganizationHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private permissionService = new PermissionService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to resolve organization by ID or name
|
||||||
|
*/
|
||||||
|
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||||
|
return idOrName.startsWith('Organization:')
|
||||||
|
? await Organization.findById(idOrName)
|
||||||
|
: await Organization.findByName(idOrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get Organizations
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizations>(
|
||||||
|
'getOrganizations',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = dataArg.identity.userId;
|
||||||
|
let organizations: Organization[];
|
||||||
|
|
||||||
|
if (dataArg.identity.isSystemAdmin) {
|
||||||
|
organizations = await Organization.getInstances({});
|
||||||
|
} else {
|
||||||
|
const memberships = await OrganizationMember.getUserOrganizations(userId);
|
||||||
|
const orgs: Organization[] = [];
|
||||||
|
for (const m of memberships) {
|
||||||
|
const org = await Organization.findById(m.organizationId);
|
||||||
|
if (org) orgs.push(org);
|
||||||
|
}
|
||||||
|
organizations = orgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizations: organizations.map((org) => ({
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
displayName: org.displayName,
|
||||||
|
description: org.description,
|
||||||
|
avatarUrl: org.avatarUrl,
|
||||||
|
website: org.website,
|
||||||
|
isPublic: org.isPublic,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
plan: (org as any).plan || 'free',
|
||||||
|
usedStorageBytes: org.usedStorageBytes || 0,
|
||||||
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||||
|
createdAt: org.createdAt instanceof Date
|
||||||
|
? org.createdAt.toISOString()
|
||||||
|
: String(org.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list organizations');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Organization
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganization>(
|
||||||
|
'getOrganization',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access - public orgs visible to all, private requires membership
|
||||||
|
if (!org.isPublic) {
|
||||||
|
const isMember = await OrganizationMember.findMembership(
|
||||||
|
org.id,
|
||||||
|
dataArg.identity.userId,
|
||||||
|
);
|
||||||
|
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgData: interfaces.data.IOrganizationDetail = {
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
displayName: org.displayName,
|
||||||
|
description: org.description,
|
||||||
|
avatarUrl: org.avatarUrl,
|
||||||
|
website: org.website,
|
||||||
|
isPublic: org.isPublic,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
plan: (org as any).plan || 'free',
|
||||||
|
usedStorageBytes: org.usedStorageBytes || 0,
|
||||||
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||||
|
createdAt: org.createdAt instanceof Date
|
||||||
|
? org.createdAt.toISOString()
|
||||||
|
: String(org.createdAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include settings for admins
|
||||||
|
if (dataArg.identity.isSystemAdmin && org.settings) {
|
||||||
|
orgData.settings = org.settings as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { organization: orgData };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get organization');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Organization
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateOrganization>(
|
||||||
|
'createOrganization',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, displayName, description, isPublic } = dataArg;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format
|
||||||
|
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Name must be lowercase alphanumeric with optional hyphens and dots',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
const existing = await Organization.findByName(name);
|
||||||
|
if (existing) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create organization
|
||||||
|
const org = new Organization();
|
||||||
|
org.id = await Organization.getNewId();
|
||||||
|
org.name = name;
|
||||||
|
org.displayName = displayName || name;
|
||||||
|
org.description = description;
|
||||||
|
org.isPublic = isPublic ?? false;
|
||||||
|
org.memberCount = 1;
|
||||||
|
org.createdAt = new Date();
|
||||||
|
org.createdById = dataArg.identity.userId;
|
||||||
|
|
||||||
|
await org.save();
|
||||||
|
|
||||||
|
// Add creator as owner
|
||||||
|
const membership = new OrganizationMember();
|
||||||
|
membership.id = await OrganizationMember.getNewId();
|
||||||
|
membership.organizationId = org.id;
|
||||||
|
membership.userId = dataArg.identity.userId;
|
||||||
|
membership.role = 'owner';
|
||||||
|
membership.invitedBy = dataArg.identity.userId;
|
||||||
|
membership.joinedAt = new Date();
|
||||||
|
|
||||||
|
await membership.save();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
}).logOrganizationCreated(org.id, org.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: {
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
displayName: org.displayName,
|
||||||
|
description: org.description,
|
||||||
|
isPublic: org.isPublic,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
plan: (org as any).plan || 'free',
|
||||||
|
usedStorageBytes: org.usedStorageBytes || 0,
|
||||||
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||||
|
createdAt: org.createdAt instanceof Date
|
||||||
|
? org.createdAt.toISOString()
|
||||||
|
: String(org.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to create organization');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Organization
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganization>(
|
||||||
|
'updateOrganization',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
org.id,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
|
||||||
|
if (dataArg.description !== undefined) org.description = dataArg.description;
|
||||||
|
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
|
||||||
|
if (dataArg.website !== undefined) org.website = dataArg.website;
|
||||||
|
if (dataArg.isPublic !== undefined) org.isPublic = dataArg.isPublic;
|
||||||
|
|
||||||
|
// Only system admins can change settings
|
||||||
|
if (dataArg.settings && dataArg.identity.isSystemAdmin) {
|
||||||
|
org.settings = { ...org.settings, ...dataArg.settings } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
await org.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: {
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
displayName: org.displayName,
|
||||||
|
description: org.description,
|
||||||
|
avatarUrl: org.avatarUrl,
|
||||||
|
website: org.website,
|
||||||
|
isPublic: org.isPublic,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
plan: (org as any).plan || 'free',
|
||||||
|
usedStorageBytes: org.usedStorageBytes || 0,
|
||||||
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||||
|
createdAt: org.createdAt instanceof Date
|
||||||
|
? org.createdAt.toISOString()
|
||||||
|
: String(org.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update organization');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Organization
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrganization>(
|
||||||
|
'deleteOrganization',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owners and system admins can delete
|
||||||
|
const membership = await OrganizationMember.findMembership(
|
||||||
|
org.id,
|
||||||
|
dataArg.identity.userId,
|
||||||
|
);
|
||||||
|
if (membership?.role !== 'owner' && !dataArg.identity.isSystemAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Owner access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
await org.delete();
|
||||||
|
|
||||||
|
return { message: 'Organization deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete organization');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Organization Members
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizationMembers>(
|
||||||
|
'getOrganizationMembers',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check membership
|
||||||
|
const isMember = await OrganizationMember.findMembership(
|
||||||
|
org.id,
|
||||||
|
dataArg.identity.userId,
|
||||||
|
);
|
||||||
|
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||||
|
|
||||||
|
const membersWithUsers = await Promise.all(
|
||||||
|
members.map(async (m) => {
|
||||||
|
const user = await User.findById(m.userId);
|
||||||
|
return {
|
||||||
|
userId: m.userId,
|
||||||
|
role: m.role as interfaces.data.TOrganizationRole,
|
||||||
|
addedAt: m.joinedAt instanceof Date
|
||||||
|
? m.joinedAt.toISOString()
|
||||||
|
: String(m.joinedAt),
|
||||||
|
user: user
|
||||||
|
? {
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { members: membersWithUsers };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list members');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Organization Member
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddOrganizationMember>(
|
||||||
|
'addOrganizationMember',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
org.id,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, role } = dataArg;
|
||||||
|
|
||||||
|
if (!userId || !role) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('userId and role are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user exists
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||||
|
if (existing) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User is already a member');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add member
|
||||||
|
const membership = new OrganizationMember();
|
||||||
|
membership.id = await OrganizationMember.getNewId();
|
||||||
|
membership.organizationId = org.id;
|
||||||
|
membership.userId = userId;
|
||||||
|
membership.role = role;
|
||||||
|
membership.invitedBy = dataArg.identity.userId;
|
||||||
|
membership.joinedAt = new Date();
|
||||||
|
|
||||||
|
await membership.save();
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
org.memberCount += 1;
|
||||||
|
await org.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
member: {
|
||||||
|
userId: membership.userId,
|
||||||
|
role: membership.role as interfaces.data.TOrganizationRole,
|
||||||
|
addedAt: membership.joinedAt instanceof Date
|
||||||
|
? membership.joinedAt.toISOString()
|
||||||
|
: String(membership.joinedAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to add member');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Organization Member
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganizationMember>(
|
||||||
|
'updateOrganizationMember',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
org.id,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, role } = dataArg;
|
||||||
|
|
||||||
|
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Valid role is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot change last owner
|
||||||
|
if (membership.role === 'owner' && role !== 'owner') {
|
||||||
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||||
|
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
membership.role = role;
|
||||||
|
await membership.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
member: {
|
||||||
|
userId: membership.userId,
|
||||||
|
role: membership.role as interfaces.data.TOrganizationRole,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update member');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove Organization Member
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveOrganizationMember>(
|
||||||
|
'removeOrganizationMember',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can remove themselves, admins can remove others
|
||||||
|
if (dataArg.userId !== dataArg.identity.userId) {
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
org.id,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await OrganizationMember.findMembership(org.id, dataArg.userId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot remove last owner
|
||||||
|
if (membership.role === 'owner') {
|
||||||
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||||
|
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await membership.delete();
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||||
|
await org.save();
|
||||||
|
|
||||||
|
return { message: 'Member removed successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to remove member');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
315
ts/opsserver/handlers/package.handler.ts
Normal file
315
ts/opsserver/handlers/package.handler.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
import { Package, Repository } from '../../models/index.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
|
|
||||||
|
export class PackageHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private permissionService = new PermissionService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Search Packages
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SearchPackages>(
|
||||||
|
'searchPackages',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const query = dataArg.query || '';
|
||||||
|
const protocol = dataArg.protocol;
|
||||||
|
const organizationId = dataArg.organizationId;
|
||||||
|
const limit = dataArg.limit || 50;
|
||||||
|
const offset = dataArg.offset || 0;
|
||||||
|
|
||||||
|
// Determine visibility: anonymous users see only public packages
|
||||||
|
const hasIdentity = !!dataArg.identity?.jwt;
|
||||||
|
const isPrivate = hasIdentity ? undefined : false;
|
||||||
|
|
||||||
|
const packages = await Package.searchPackages(query, {
|
||||||
|
protocol,
|
||||||
|
organizationId,
|
||||||
|
isPrivate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out packages user doesn't have access to
|
||||||
|
const accessiblePackages: typeof packages = [];
|
||||||
|
for (const pkg of packages) {
|
||||||
|
if (!pkg.isPrivate) {
|
||||||
|
accessiblePackages.push(pkg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIdentity && dataArg.identity) {
|
||||||
|
const canAccess = await this.permissionService.canAccessPackage(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
pkg.organizationId,
|
||||||
|
pkg.repositoryId,
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
if (canAccess) {
|
||||||
|
accessiblePackages.push(pkg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packages: accessiblePackages.map((pkg) => ({
|
||||||
|
id: pkg.id,
|
||||||
|
name: pkg.name,
|
||||||
|
description: pkg.description,
|
||||||
|
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
organizationId: pkg.organizationId,
|
||||||
|
repositoryId: pkg.repositoryId,
|
||||||
|
latestVersion: pkg.distTags?.['latest'],
|
||||||
|
isPrivate: pkg.isPrivate,
|
||||||
|
downloadCount: pkg.downloadCount || 0,
|
||||||
|
starCount: pkg.starCount || 0,
|
||||||
|
storageBytes: pkg.storageBytes || 0,
|
||||||
|
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||||
|
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||||
|
})),
|
||||||
|
total: accessiblePackages.length,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to search packages');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Package
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackage>(
|
||||||
|
'getPackage',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const pkg = await Package.findById(dataArg.packageId);
|
||||||
|
if (!pkg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access for private packages
|
||||||
|
if (pkg.isPrivate) {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = await this.permissionService.canAccessPackage(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
pkg.organizationId,
|
||||||
|
pkg.repositoryId,
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
package: {
|
||||||
|
id: pkg.id,
|
||||||
|
name: pkg.name,
|
||||||
|
description: pkg.description,
|
||||||
|
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
organizationId: pkg.organizationId,
|
||||||
|
repositoryId: pkg.repositoryId,
|
||||||
|
latestVersion: pkg.distTags?.['latest'],
|
||||||
|
isPrivate: pkg.isPrivate,
|
||||||
|
downloadCount: pkg.downloadCount || 0,
|
||||||
|
starCount: pkg.starCount || 0,
|
||||||
|
storageBytes: pkg.storageBytes || 0,
|
||||||
|
distTags: pkg.distTags || {},
|
||||||
|
versions: Object.keys(pkg.versions || {}),
|
||||||
|
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||||
|
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get package');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Package Versions
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackageVersions>(
|
||||||
|
'getPackageVersions',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
const pkg = await Package.findById(dataArg.packageId);
|
||||||
|
if (!pkg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access for private packages
|
||||||
|
if (pkg.isPrivate) {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = await this.permissionService.canAccessPackage(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
pkg.organizationId,
|
||||||
|
pkg.repositoryId,
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = Object.entries(pkg.versions || {}).map(([version, data]) => ({
|
||||||
|
version,
|
||||||
|
publishedAt: data.publishedAt instanceof Date ? data.publishedAt.toISOString() : String(data.publishedAt || ''),
|
||||||
|
size: data.size || 0,
|
||||||
|
downloads: data.downloads || 0,
|
||||||
|
checksum: data.metadata?.checksum as interfaces.data.IPackageVersion['checksum'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageId: pkg.id,
|
||||||
|
packageName: pkg.name,
|
||||||
|
distTags: pkg.distTags || {},
|
||||||
|
versions,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list versions');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Package
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackage>(
|
||||||
|
'deletePackage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = await Package.findById(dataArg.packageId);
|
||||||
|
if (!pkg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check delete permission
|
||||||
|
const canDelete = await this.permissionService.canAccessPackage(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
pkg.organizationId,
|
||||||
|
pkg.repositoryId,
|
||||||
|
'delete',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canDelete) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update repository counts before deleting
|
||||||
|
const repo = await Repository.findById(pkg.repositoryId);
|
||||||
|
if (repo) {
|
||||||
|
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||||
|
repo.storageBytes -= pkg.storageBytes;
|
||||||
|
await repo.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
await pkg.delete();
|
||||||
|
|
||||||
|
return { message: 'Package deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete package');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Package Version
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackageVersion>(
|
||||||
|
'deletePackageVersion',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = await Package.findById(dataArg.packageId);
|
||||||
|
if (!pkg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionData = pkg.versions?.[dataArg.version];
|
||||||
|
if (!versionData) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Version not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check delete permission
|
||||||
|
const canDelete = await this.permissionService.canAccessPackage(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
pkg.organizationId,
|
||||||
|
pkg.repositoryId,
|
||||||
|
'delete',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canDelete) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the only version
|
||||||
|
if (Object.keys(pkg.versions).length === 1) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Cannot delete the only version. Delete the entire package instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove version
|
||||||
|
const sizeReduction = versionData.size || 0;
|
||||||
|
delete pkg.versions[dataArg.version];
|
||||||
|
pkg.storageBytes -= sizeReduction;
|
||||||
|
|
||||||
|
// Update dist tags
|
||||||
|
for (const [tag, tagVersion] of Object.entries(pkg.distTags || {})) {
|
||||||
|
if (tagVersion === dataArg.version) {
|
||||||
|
delete pkg.distTags[tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new latest if needed
|
||||||
|
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
||||||
|
const versions = Object.keys(pkg.versions).sort();
|
||||||
|
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
await pkg.save();
|
||||||
|
|
||||||
|
// Update repository storage
|
||||||
|
const repo = await Repository.findById(pkg.repositoryId);
|
||||||
|
if (repo) {
|
||||||
|
repo.storageBytes -= sizeReduction;
|
||||||
|
await repo.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Version deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete version');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
ts/opsserver/handlers/repository.handler.ts
Normal file
272
ts/opsserver/handlers/repository.handler.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
import { Organization, Repository } from '../../models/index.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
|
import { AuditService } from '../../services/audit.service.ts';
|
||||||
|
|
||||||
|
export class RepositoryHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private permissionService = new PermissionService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get Repositories
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepositories>(
|
||||||
|
'getRepositories',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
dataArg.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositories: repositories.map((repo) => ({
|
||||||
|
id: repo.id,
|
||||||
|
organizationId: repo.organizationId,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description,
|
||||||
|
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||||
|
isPublic: repo.isPublic,
|
||||||
|
packageCount: repo.packageCount,
|
||||||
|
storageBytes: repo.storageBytes || 0,
|
||||||
|
downloadCount: (repo as any).downloadCount || 0,
|
||||||
|
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list repositories');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Repository
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepository>(
|
||||||
|
'getRepository',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repo = await Repository.findById(dataArg.repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if (!repo.isPublic) {
|
||||||
|
const permissions = await this.permissionService.resolvePermissions({
|
||||||
|
userId: dataArg.identity.userId,
|
||||||
|
organizationId: repo.organizationId,
|
||||||
|
repositoryId: repo.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!permissions.canRead) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository: {
|
||||||
|
id: repo.id,
|
||||||
|
organizationId: repo.organizationId,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description,
|
||||||
|
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||||
|
isPublic: repo.isPublic,
|
||||||
|
packageCount: repo.packageCount,
|
||||||
|
storageBytes: repo.storageBytes || 0,
|
||||||
|
downloadCount: (repo as any).downloadCount || 0,
|
||||||
|
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get repository');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Repository
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRepository>(
|
||||||
|
'createRepository',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { organizationId, name, description, protocol, visibility } = dataArg;
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Repository name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format
|
||||||
|
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check org exists
|
||||||
|
const org = await Organization.findById(organizationId);
|
||||||
|
if (!org) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
const repo = await Repository.createRepository({
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
protocol: protocol || 'npm',
|
||||||
|
visibility: visibility || 'private',
|
||||||
|
createdById: dataArg.identity.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await AuditService.withContext({
|
||||||
|
actorId: dataArg.identity.userId,
|
||||||
|
actorType: 'user',
|
||||||
|
organizationId,
|
||||||
|
}).logRepositoryCreated(repo.id, repo.name, organizationId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository: {
|
||||||
|
id: repo.id,
|
||||||
|
organizationId: repo.organizationId,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description,
|
||||||
|
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||||
|
isPublic: repo.isPublic,
|
||||||
|
packageCount: repo.packageCount,
|
||||||
|
storageBytes: repo.storageBytes || 0,
|
||||||
|
downloadCount: 0,
|
||||||
|
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to create repository');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Repository
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRepository>(
|
||||||
|
'updateRepository',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repo = await Repository.findById(dataArg.repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageRepository(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
repo.organizationId,
|
||||||
|
dataArg.repositoryId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArg.description !== undefined) repo.description = dataArg.description;
|
||||||
|
if (dataArg.visibility !== undefined) repo.visibility = dataArg.visibility as any;
|
||||||
|
|
||||||
|
await repo.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository: {
|
||||||
|
id: repo.id,
|
||||||
|
organizationId: repo.organizationId,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description,
|
||||||
|
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||||
|
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||||
|
isPublic: repo.isPublic,
|
||||||
|
packageCount: repo.packageCount,
|
||||||
|
storageBytes: repo.storageBytes || 0,
|
||||||
|
downloadCount: (repo as any).downloadCount || 0,
|
||||||
|
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update repository');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Repository
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRepository>(
|
||||||
|
'deleteRepository',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repo = await Repository.findById(dataArg.repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin permission
|
||||||
|
const canManage = await this.permissionService.canManageRepository(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
repo.organizationId,
|
||||||
|
dataArg.repositoryId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for packages
|
||||||
|
if (repo.packageCount > 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Cannot delete repository with packages. Remove all packages first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.delete();
|
||||||
|
|
||||||
|
return { message: 'Repository deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete repository');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
ts/opsserver/handlers/token.handler.ts
Normal file
198
ts/opsserver/handlers/token.handler.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||||
|
import { ApiToken } from '../../models/index.ts';
|
||||||
|
import { TokenService } from '../../services/token.service.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
|
|
||||||
|
export class TokenHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private tokenService = new TokenService();
|
||||||
|
private permissionService = new PermissionService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get Tokens
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTokens>(
|
||||||
|
'getTokens',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let tokens;
|
||||||
|
if (dataArg.organizationId) {
|
||||||
|
// Check if user can manage org
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
dataArg.organizationId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Not authorized to view organization tokens',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tokens = await this.tokenService.getOrgTokens(dataArg.organizationId);
|
||||||
|
} else {
|
||||||
|
tokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens: tokens.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
tokenPrefix: t.tokenPrefix,
|
||||||
|
protocols: t.protocols as interfaces.data.TRegistryProtocol[],
|
||||||
|
scopes: t.scopes as interfaces.data.ITokenScope[],
|
||||||
|
organizationId: t.organizationId,
|
||||||
|
createdById: t.createdById,
|
||||||
|
expiresAt: t.expiresAt instanceof Date ? t.expiresAt.toISOString() : t.expiresAt ? String(t.expiresAt) : undefined,
|
||||||
|
lastUsedAt: t.lastUsedAt instanceof Date ? t.lastUsedAt.toISOString() : t.lastUsedAt ? String(t.lastUsedAt) : undefined,
|
||||||
|
usageCount: t.usageCount,
|
||||||
|
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list tokens');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Token
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateToken>(
|
||||||
|
'createToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, organizationId, protocols, scopes, expiresInDays } = dataArg;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Token name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!protocols || protocols.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('At least one protocol is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scopes || scopes.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('At least one scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate protocols
|
||||||
|
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
|
||||||
|
for (const protocol of protocols) {
|
||||||
|
if (!validProtocols.includes(protocol)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Invalid protocol: ${protocol}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scopes
|
||||||
|
for (const scope of scopes) {
|
||||||
|
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid scope configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If creating org token, verify permission
|
||||||
|
if (organizationId) {
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Not authorized to create organization tokens',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.tokenService.createToken({
|
||||||
|
userId: dataArg.identity.userId,
|
||||||
|
organizationId,
|
||||||
|
createdById: dataArg.identity.userId,
|
||||||
|
name,
|
||||||
|
protocols: protocols as any[],
|
||||||
|
scopes: scopes as any[],
|
||||||
|
expiresInDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: {
|
||||||
|
id: result.token.id,
|
||||||
|
name: result.token.name,
|
||||||
|
token: result.rawToken,
|
||||||
|
tokenPrefix: result.token.tokenPrefix,
|
||||||
|
protocols: result.token.protocols as interfaces.data.TRegistryProtocol[],
|
||||||
|
scopes: result.token.scopes as interfaces.data.ITokenScope[],
|
||||||
|
organizationId: result.token.organizationId,
|
||||||
|
createdById: result.token.createdById,
|
||||||
|
expiresAt: result.token.expiresAt instanceof Date ? result.token.expiresAt.toISOString() : result.token.expiresAt ? String(result.token.expiresAt) : undefined,
|
||||||
|
usageCount: result.token.usageCount,
|
||||||
|
createdAt: result.token.createdAt instanceof Date ? result.token.createdAt.toISOString() : String(result.token.createdAt),
|
||||||
|
warning: 'Store this token securely. It will not be shown again.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to create token');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke Token
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeToken>(
|
||||||
|
'revokeToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tokenId } = dataArg;
|
||||||
|
|
||||||
|
// Check if it's a personal token
|
||||||
|
const userTokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||||
|
let token = userTokens.find((t) => t.id === tokenId);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Check if it's an org token the user can manage
|
||||||
|
const anyToken = await ApiToken.getInstance({ id: tokenId, isRevoked: false });
|
||||||
|
if (anyToken?.organizationId) {
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
dataArg.identity.userId,
|
||||||
|
anyToken.organizationId,
|
||||||
|
);
|
||||||
|
if (canManage) {
|
||||||
|
token = anyToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await this.tokenService.revokeToken(tokenId, 'user_revoked');
|
||||||
|
if (!success) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Token revoked successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
ts/opsserver/handlers/user.handler.ts
Normal file
263
ts/opsserver/handlers/user.handler.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.ts';
|
||||||
|
import { requireValidIdentity, requireAdminIdentity } from '../helpers/guards.ts';
|
||||||
|
import { User } from '../../models/user.ts';
|
||||||
|
import { Session } from '../../models/session.ts';
|
||||||
|
import { AuthService } from '../../services/auth.service.ts';
|
||||||
|
|
||||||
|
export class UserHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
private authService = new AuthService();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to format user to IUser interface
|
||||||
|
*/
|
||||||
|
private formatUser(user: User): interfaces.data.IUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
isSystemAdmin: user.isSystemAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||||
|
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get Users (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUsers>(
|
||||||
|
'getUsers',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await User.getInstances({});
|
||||||
|
return {
|
||||||
|
users: users.map((u) => this.formatUser(u)),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to list users');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get User
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUser>(
|
||||||
|
'getUser',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId } = dataArg;
|
||||||
|
|
||||||
|
// Users can view their own profile, admins can view any
|
||||||
|
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: this.formatUser(user) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get user');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update User
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateUser>(
|
||||||
|
'updateUser',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId, displayName, avatarUrl, password, isActive, isSystemAdmin } = dataArg;
|
||||||
|
|
||||||
|
// Users can update their own profile, admins can update any
|
||||||
|
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName !== undefined) user.displayName = displayName;
|
||||||
|
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||||
|
|
||||||
|
// Only admins can change these
|
||||||
|
if (dataArg.identity.isSystemAdmin) {
|
||||||
|
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
|
||||||
|
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
if (password) {
|
||||||
|
user.passwordHash = await AuthService.hashPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return { user: this.formatUser(user) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to update user');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get User Sessions
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUserSessions>(
|
||||||
|
'getUserSessions',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await Session.getUserSessions(dataArg.identity.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
userId: s.userId,
|
||||||
|
userAgent: s.userAgent,
|
||||||
|
ipAddress: s.ipAddress,
|
||||||
|
isValid: s.isValid,
|
||||||
|
lastActivityAt: s.lastActivityAt instanceof Date ? s.lastActivityAt.toISOString() : String(s.lastActivityAt),
|
||||||
|
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : String(s.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to get sessions');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke Session
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeSession>(
|
||||||
|
'revokeSession',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.authService.logout(dataArg.sessionId, {
|
||||||
|
userId: dataArg.identity.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Session revoked successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to revoke session');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change Password
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||||
|
'changePassword',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = dataArg;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Current password and new password are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(dataArg.identity.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValid = await user.verifyPassword(currentPassword);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash and set new password
|
||||||
|
user.passwordHash = await AuthService.hashPassword(newPassword);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return { message: 'Password changed successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to change password');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete Account
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAccount>(
|
||||||
|
'deleteAccount',
|
||||||
|
async (dataArg) => {
|
||||||
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { password } = dataArg;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Password is required to delete account',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(dataArg.identity.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValid = await user.verifyPassword(password);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete - deactivate instead of removing
|
||||||
|
user.status = 'suspended';
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Invalidate all sessions
|
||||||
|
await Session.invalidateAllUserSessions(user.id, 'account_deleted');
|
||||||
|
|
||||||
|
return { message: 'Account deactivated successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete account');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import type { AuthHandler } from '../handlers/auth.handler.ts';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||||
|
|
||||||
|
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||||
|
authHandler: AuthHandler,
|
||||||
|
dataArg: T,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!dataArg.identity) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
|
}
|
||||||
|
const passed = await authHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
|
if (!passed) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||||
|
authHandler: AuthHandler,
|
||||||
|
dataArg: T,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!dataArg.identity) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
|
}
|
||||||
|
const passed = await authHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
|
if (!passed) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ import * as smartunique from '@push.rocks/smartunique';
|
|||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartcli from '@push.rocks/smartcli';
|
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
|
// tsclass types
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
@@ -28,25 +32,28 @@ import * as fs from '@std/fs';
|
|||||||
import * as http from '@std/http';
|
import * as http from '@std/http';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Push.rocks
|
|
||||||
smartregistry,
|
|
||||||
smartdata,
|
|
||||||
smartbucket,
|
|
||||||
smartlog,
|
|
||||||
smartenv,
|
|
||||||
smartpath,
|
|
||||||
smartpromise,
|
|
||||||
smartstring,
|
|
||||||
smartcrypto,
|
|
||||||
smartjwt,
|
|
||||||
smartunique,
|
|
||||||
smartdelay,
|
|
||||||
smartrx,
|
|
||||||
smartcli,
|
|
||||||
// tsclass
|
|
||||||
tsclass,
|
|
||||||
// Deno std
|
|
||||||
path,
|
|
||||||
fs,
|
fs,
|
||||||
http,
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
|||||||
* Returns userId on success, null on failure
|
* Returns userId on success, null on failure
|
||||||
*/
|
*/
|
||||||
public async authenticate(
|
public async authenticate(
|
||||||
credentials: plugins.smartregistry.ICredentials
|
credentials: plugins.smartregistry.ICredentials,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const result = await this.authService.login(credentials.username, credentials.password);
|
const result = await this.authService.login(credentials.username, credentials.password);
|
||||||
if (!result.success || !result.user) return null;
|
if (!result.success || !result.user) return null;
|
||||||
@@ -62,7 +62,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
|||||||
*/
|
*/
|
||||||
public async validateToken(
|
public async validateToken(
|
||||||
token: string,
|
token: string,
|
||||||
protocol?: plugins.smartregistry.TRegistryProtocol
|
protocol?: plugins.smartregistry.TRegistryProtocol,
|
||||||
): Promise<plugins.smartregistry.IAuthToken | null> {
|
): Promise<plugins.smartregistry.IAuthToken | null> {
|
||||||
// Try API token (srg_ prefix)
|
// Try API token (srg_ prefix)
|
||||||
if (token.startsWith('srg_')) {
|
if (token.startsWith('srg_')) {
|
||||||
@@ -70,11 +70,10 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
|||||||
if (!result.valid || !result.token || !result.user) return null;
|
if (!result.valid || !result.token || !result.user) return null;
|
||||||
|
|
||||||
return {
|
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,
|
userId: result.user.id,
|
||||||
scopes: result.token.scopes.map((s) =>
|
scopes: result.token.scopes.map((s) => `${s.protocol}:${s.actions.join(',')}`),
|
||||||
`${s.protocol}:${s.actions.join(',')}`
|
|
||||||
),
|
|
||||||
readonly: !result.token.scopes.some((s) =>
|
readonly: !result.token.scopes.some((s) =>
|
||||||
s.actions.includes('write') || s.actions.includes('*')
|
s.actions.includes('write') || s.actions.includes('*')
|
||||||
),
|
),
|
||||||
@@ -98,7 +97,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
|||||||
public async createToken(
|
public async createToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
protocol: plugins.smartregistry.TRegistryProtocol,
|
protocol: plugins.smartregistry.TRegistryProtocol,
|
||||||
options?: plugins.smartregistry.ITokenOptions
|
options?: plugins.smartregistry.ITokenOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const result = await this.tokenService.createToken({
|
const result = await this.tokenService.createToken({
|
||||||
userId,
|
userId,
|
||||||
@@ -133,7 +132,7 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
|||||||
public async authorize(
|
public async authorize(
|
||||||
token: plugins.smartregistry.IAuthToken | null,
|
token: plugins.smartregistry.IAuthToken | null,
|
||||||
resource: string,
|
resource: string,
|
||||||
action: string
|
action: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Anonymous access: only public reads
|
// Anonymous access: only public reads
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
* Provider exports
|
* Provider exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
|
export { type IStackGalleryActor, StackGalleryAuthProvider } from './auth.provider.ts';
|
||||||
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
|
export { type IStorageConfig, StackGalleryStorageHooks } from './storage.provider.ts';
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
* Called before a package is stored
|
* Called before a package is stored
|
||||||
*/
|
*/
|
||||||
public async beforePut(
|
public async beforePut(
|
||||||
context: plugins.smartregistry.IStorageHookContext
|
context: plugins.smartregistry.IStorageHookContext,
|
||||||
): Promise<plugins.smartregistry.IBeforePutResult> {
|
): Promise<plugins.smartregistry.IBeforePutResult> {
|
||||||
// Validate organization exists and has quota
|
// Validate organization exists and has quota
|
||||||
const orgId = context.actor?.orgId;
|
const orgId = context.actor?.orgId;
|
||||||
@@ -54,7 +54,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
* Called after a package is successfully stored
|
* Called after a package is successfully stored
|
||||||
*/
|
*/
|
||||||
public async afterPut(
|
public async afterPut(
|
||||||
context: plugins.smartregistry.IStorageHookContext
|
context: plugins.smartregistry.IStorageHookContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const protocol = context.protocol as TRegistryProtocol;
|
const protocol = context.protocol as TRegistryProtocol;
|
||||||
const packageName = context.metadata?.packageName || context.key;
|
const packageName = context.metadata?.packageName || context.key;
|
||||||
@@ -115,7 +115,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
* Called after a package is fetched
|
* Called after a package is fetched
|
||||||
*/
|
*/
|
||||||
public async afterGet(
|
public async afterGet(
|
||||||
context: plugins.smartregistry.IStorageHookContext
|
context: plugins.smartregistry.IStorageHookContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const protocol = context.protocol as TRegistryProtocol;
|
const protocol = context.protocol as TRegistryProtocol;
|
||||||
const packageName = context.metadata?.packageName || context.key;
|
const packageName = context.metadata?.packageName || context.key;
|
||||||
@@ -134,7 +134,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
* Called before a package is deleted
|
* Called before a package is deleted
|
||||||
*/
|
*/
|
||||||
public async beforeDelete(
|
public async beforeDelete(
|
||||||
context: plugins.smartregistry.IStorageHookContext
|
context: plugins.smartregistry.IStorageHookContext,
|
||||||
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
* Called after a package is deleted
|
* Called after a package is deleted
|
||||||
*/
|
*/
|
||||||
public async afterDelete(
|
public async afterDelete(
|
||||||
context: plugins.smartregistry.IStorageHookContext
|
context: plugins.smartregistry.IStorageHookContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const protocol = context.protocol as TRegistryProtocol;
|
const protocol = context.protocol as TRegistryProtocol;
|
||||||
const packageName = context.metadata?.packageName || context.key;
|
const packageName = context.metadata?.packageName || context.key;
|
||||||
@@ -216,7 +216,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
organizationName: string,
|
organizationName: string,
|
||||||
packageName: string,
|
packageName: string,
|
||||||
version: string,
|
version: string,
|
||||||
filename: string
|
filename: string,
|
||||||
): string {
|
): string {
|
||||||
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
|||||||
public async storeArtifact(
|
public async storeArtifact(
|
||||||
path: string,
|
path: string,
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
contentType?: string
|
contentType?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||||
await bucket.fastPut({
|
await bucket.fastPut({
|
||||||
|
|||||||
137
ts/registry.ts
137
ts/registry.ts
@@ -4,12 +4,46 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './plugins.ts';
|
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 { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||||
import { ApiRouter } from './api/router.ts';
|
import { OpsServer } from './opsserver/classes.opsserver.ts';
|
||||||
import { getEmbeddedFile } from './embedded-ui.generated.ts';
|
|
||||||
import { ReloadSocketManager } from './reload-socket.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 {
|
export interface IRegistryConfig {
|
||||||
// MongoDB configuration
|
// MongoDB configuration
|
||||||
@@ -42,8 +76,7 @@ export class StackGalleryRegistry {
|
|||||||
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
||||||
private authProvider: StackGalleryAuthProvider | null = null;
|
private authProvider: StackGalleryAuthProvider | null = null;
|
||||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||||
private apiRouter: ApiRouter | null = null;
|
private opsServer: OpsServer | null = null;
|
||||||
private reloadSocket: ReloadSocketManager | null = null;
|
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
constructor(config: IRegistryConfig) {
|
constructor(config: IRegistryConfig) {
|
||||||
@@ -115,13 +148,11 @@ export class StackGalleryRegistry {
|
|||||||
});
|
});
|
||||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||||
|
|
||||||
// Initialize API router
|
// Initialize OpsServer (TypedRequest handlers)
|
||||||
console.log('[StackGalleryRegistry] Initializing API router...');
|
console.log('[StackGalleryRegistry] Initializing OpsServer...');
|
||||||
this.apiRouter = new ApiRouter();
|
this.opsServer = new OpsServer(this);
|
||||||
console.log('[StackGalleryRegistry] API router initialized');
|
await this.opsServer.start();
|
||||||
|
console.log('[StackGalleryRegistry] OpsServer initialized');
|
||||||
// Initialize reload socket for hot reload
|
|
||||||
this.reloadSocket = new ReloadSocketManager();
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
console.log('[StackGalleryRegistry] Initialization complete');
|
console.log('[StackGalleryRegistry] Initialization complete');
|
||||||
@@ -144,7 +175,7 @@ export class StackGalleryRegistry {
|
|||||||
{ port, hostname: host },
|
{ port, hostname: host },
|
||||||
async (request: Request): Promise<Response> => {
|
async (request: Request): Promise<Response> => {
|
||||||
return await this.handleRequest(request);
|
return await this.handleRequest(request);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
||||||
@@ -162,11 +193,14 @@ export class StackGalleryRegistry {
|
|||||||
return this.healthCheck();
|
return this.healthCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
// API endpoints (handled by REST API layer)
|
// TypedRequest endpoint (handled by OpsServer TypedRouter)
|
||||||
if (path.startsWith('/api/')) {
|
if (path === '/typedrequest' && request.method === 'POST') {
|
||||||
return await this.handleApiRequest(request);
|
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)
|
// Registry protocol endpoints (handled by smartregistry)
|
||||||
const registryPaths = [
|
const registryPaths = [
|
||||||
'/-/',
|
'/-/',
|
||||||
@@ -180,8 +214,7 @@ export class StackGalleryRegistry {
|
|||||||
'/api/v1/gems/',
|
'/api/v1/gems/',
|
||||||
'/gems/',
|
'/gems/',
|
||||||
];
|
];
|
||||||
const isRegistryPath =
|
const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) ||
|
||||||
registryPaths.some((p) => path.startsWith(p)) ||
|
|
||||||
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
||||||
|
|
||||||
if (this.smartRegistry && isRegistryPath) {
|
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
|
// Serve static UI files
|
||||||
return this.serveStaticFile(path);
|
return this.serveStaticFile(path);
|
||||||
}
|
}
|
||||||
@@ -212,7 +240,7 @@ export class StackGalleryRegistry {
|
|||||||
* Convert a Deno Request to smartregistry IRequestContext
|
* Convert a Deno Request to smartregistry IRequestContext
|
||||||
*/
|
*/
|
||||||
private async requestToContext(
|
private async requestToContext(
|
||||||
request: Request
|
request: Request,
|
||||||
): Promise<plugins.smartregistry.IRequestContext> {
|
): Promise<plugins.smartregistry.IRequestContext> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -285,24 +313,28 @@ export class StackGalleryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve static files from embedded UI
|
* Serve static files from bundled UI
|
||||||
*/
|
*/
|
||||||
private serveStaticFile(path: string): Response {
|
private serveStaticFile(path: string): Response {
|
||||||
|
if (!bundledFileMap) {
|
||||||
|
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path === '/' ? '/index.html' : path;
|
const filePath = path === '/' ? '/index.html' : path;
|
||||||
|
|
||||||
// Get embedded file
|
// Get bundled file
|
||||||
const embeddedFile = getEmbeddedFile(filePath);
|
const file = bundledFileMap.get(filePath);
|
||||||
if (embeddedFile) {
|
if (file) {
|
||||||
return new Response(embeddedFile.data as unknown as BodyInit, {
|
return new Response(file.data, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': embeddedFile.contentType },
|
headers: { 'Content-Type': file.contentType },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA fallback: serve index.html for unknown paths
|
// SPA fallback: serve index.html for unknown paths
|
||||||
const indexFile = getEmbeddedFile('/index.html');
|
const indexFile = bundledFileMap.get('/index.html');
|
||||||
if (indexFile) {
|
if (indexFile) {
|
||||||
return new Response(indexFile.data as unknown as BodyInit, {
|
return new Response(indexFile.data, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'text/html' },
|
headers: { 'Content-Type': 'text/html' },
|
||||||
});
|
});
|
||||||
@@ -312,17 +344,34 @@ export class StackGalleryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle API requests
|
* Handle TypedRequest calls
|
||||||
*/
|
*/
|
||||||
private async handleApiRequest(request: Request): Promise<Response> {
|
private async handleTypedRequest(request: Request): Promise<Response> {
|
||||||
if (!this.apiRouter) {
|
if (!this.opsServer) {
|
||||||
return new Response(JSON.stringify({ error: 'API router not initialized' }), {
|
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
|
||||||
status: 503,
|
status: 503,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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> {
|
public async stop(): Promise<void> {
|
||||||
console.log('[StackGalleryRegistry] Shutting down...');
|
console.log('[StackGalleryRegistry] Shutting down...');
|
||||||
|
if (this.opsServer) {
|
||||||
|
await this.opsServer.stop();
|
||||||
|
}
|
||||||
await closeDb();
|
await closeDb();
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
console.log('[StackGalleryRegistry] Shutdown complete');
|
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 s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
|
||||||
|
|
||||||
const config: IRegistryConfig = {
|
const config: IRegistryConfig = {
|
||||||
mongoUrl:
|
mongoUrl: env.MONGODB_URL ||
|
||||||
env.MONGODB_URL ||
|
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${
|
||||||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
|
env.MONGODB_PORT || '27017'
|
||||||
|
}/${env.MONGODB_NAME}?authSource=admin`,
|
||||||
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
||||||
s3Endpoint: s3Endpoint,
|
s3Endpoint: s3Endpoint,
|
||||||
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
||||||
@@ -444,7 +497,7 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
|||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return createRegistryFromEnv();
|
return createRegistryFromEnv();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@ export class AuditService {
|
|||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
} = {}
|
} = {},
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await AuditLog.log({
|
return await AuditLog.log({
|
||||||
actorId: this.context.actorId,
|
actorId: this.context.actorId,
|
||||||
@@ -75,7 +75,7 @@ export class AuditService {
|
|||||||
resourceType: TAuditResourceType,
|
resourceType: TAuditResourceType,
|
||||||
resourceId?: string,
|
resourceId?: string,
|
||||||
resourceName?: string,
|
resourceName?: string,
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log(action, resourceType, {
|
return await this.log(action, resourceType, {
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -94,7 +94,7 @@ export class AuditService {
|
|||||||
errorCode: string,
|
errorCode: string,
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
resourceId?: string,
|
resourceId?: string,
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log(action, resourceType, {
|
return await this.log(action, resourceType, {
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -107,11 +107,21 @@ export class AuditService {
|
|||||||
|
|
||||||
// Convenience methods for common actions
|
// 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) {
|
if (success) {
|
||||||
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
|
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> {
|
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||||
@@ -131,7 +141,7 @@ export class AuditService {
|
|||||||
packageName: string,
|
packageName: string,
|
||||||
version: string,
|
version: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
repositoryId: string
|
repositoryId: string,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log('PACKAGE_PUSHED', 'package', {
|
return await this.log('PACKAGE_PUSHED', 'package', {
|
||||||
resourceId: packageId,
|
resourceId: packageId,
|
||||||
@@ -148,7 +158,7 @@ export class AuditService {
|
|||||||
packageName: string,
|
packageName: string,
|
||||||
version: string,
|
version: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
repositoryId: string
|
repositoryId: string,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log('PACKAGE_PULLED', 'package', {
|
return await this.log('PACKAGE_PULLED', 'package', {
|
||||||
resourceId: packageId,
|
resourceId: packageId,
|
||||||
@@ -167,7 +177,7 @@ export class AuditService {
|
|||||||
public async logRepositoryCreated(
|
public async logRepositoryCreated(
|
||||||
repoId: string,
|
repoId: string,
|
||||||
repoName: string,
|
repoName: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log('REPO_CREATED', 'repository', {
|
return await this.log('REPO_CREATED', 'repository', {
|
||||||
resourceId: repoId,
|
resourceId: repoId,
|
||||||
@@ -182,7 +192,7 @@ export class AuditService {
|
|||||||
resourceId: string,
|
resourceId: string,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
oldRole: string | null,
|
oldRole: string | null,
|
||||||
newRole: string | null
|
newRole: string | null,
|
||||||
): Promise<AuditLog> {
|
): Promise<AuditLog> {
|
||||||
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
||||||
resourceId,
|
resourceId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
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';
|
import { AuditService } from './audit.service.ts';
|
||||||
|
|
||||||
export interface IJwtPayload {
|
export interface IJwtPayload {
|
||||||
@@ -52,7 +52,7 @@ export class AuthService {
|
|||||||
public async login(
|
public async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
options: { userAgent?: string; ipAddress?: string } = {}
|
options: { userAgent?: string; ipAddress?: string } = {},
|
||||||
): Promise<IAuthResult> {
|
): Promise<IAuthResult> {
|
||||||
const auditContext = AuditService.withContext({
|
const auditContext = AuditService.withContext({
|
||||||
actorIp: options.ipAddress,
|
actorIp: options.ipAddress,
|
||||||
@@ -195,7 +195,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
public async logout(
|
public async logout(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: { userId?: string; ipAddress?: string } = {}
|
options: { userId?: string; ipAddress?: string } = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const session = await Session.findValidSession(sessionId);
|
const session = await Session.findValidSession(sessionId);
|
||||||
if (!session) return false;
|
if (!session) return false;
|
||||||
@@ -218,7 +218,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
public async logoutAll(
|
public async logoutAll(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: { ipAddress?: string } = {}
|
options: { ipAddress?: string } = {},
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||||
|
|
||||||
@@ -238,7 +238,9 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Validate access token and return user
|
* 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);
|
const payload = await this.verifyToken(accessToken);
|
||||||
if (!payload || payload.type !== 'access') return null;
|
if (!payload || payload.type !== 'access') return null;
|
||||||
|
|
||||||
@@ -339,7 +341,7 @@ export class AuthService {
|
|||||||
encoder.encode(this.config.jwtSecret),
|
encoder.encode(this.config.jwtSecret),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign']
|
['sign'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IExternalUserInfo,
|
|
||||||
IConnectionTestResult,
|
IConnectionTestResult,
|
||||||
|
IExternalUserInfo,
|
||||||
} from '../../../interfaces/auth.interfaces.ts';
|
} from '../../../interfaces/auth.interfaces.ts';
|
||||||
|
|
||||||
export interface IOAuthCallbackData {
|
export interface IOAuthCallbackData {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||||
import type { CryptoService } from '../../crypto.service.ts';
|
import type { CryptoService } from '../../crypto.service.ts';
|
||||||
import type {
|
import type {
|
||||||
IExternalUserInfo,
|
|
||||||
IConnectionTestResult,
|
IConnectionTestResult,
|
||||||
|
IExternalUserInfo,
|
||||||
} from '../../../interfaces/auth.interfaces.ts';
|
} from '../../../interfaces/auth.interfaces.ts';
|
||||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ interface ILdapEntry {
|
|||||||
export class LdapStrategy implements IAuthStrategy {
|
export class LdapStrategy implements IAuthStrategy {
|
||||||
constructor(
|
constructor(
|
||||||
private provider: AuthProvider,
|
private provider: AuthProvider,
|
||||||
private cryptoService: CryptoService
|
private cryptoService: CryptoService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +31,7 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
*/
|
*/
|
||||||
public async authenticateCredentials(
|
public async authenticateCredentials(
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string,
|
||||||
): Promise<IExternalUserInfo> {
|
): Promise<IExternalUserInfo> {
|
||||||
const config = this.provider.ldapConfig;
|
const config = this.provider.ldapConfig;
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -55,7 +55,7 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
bindPassword,
|
bindPassword,
|
||||||
config.baseDn,
|
config.baseDn,
|
||||||
userFilter,
|
userFilter,
|
||||||
password
|
password,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map LDAP attributes to user info
|
// Map LDAP attributes to user info
|
||||||
@@ -86,7 +86,7 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
config.serverUrl,
|
config.serverUrl,
|
||||||
config.bindDn,
|
config.bindDn,
|
||||||
bindPassword,
|
bindPassword,
|
||||||
config.baseDn
|
config.baseDn,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -129,7 +129,7 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
bindPassword: string,
|
bindPassword: string,
|
||||||
baseDn: string,
|
baseDn: string,
|
||||||
userFilter: string,
|
userFilter: string,
|
||||||
userPassword: string
|
userPassword: string,
|
||||||
): Promise<ILdapEntry> {
|
): Promise<ILdapEntry> {
|
||||||
// In a real implementation, this would:
|
// In a real implementation, this would:
|
||||||
// 1. Connect to LDAP server
|
// 1. Connect to LDAP server
|
||||||
@@ -150,7 +150,7 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'LDAP authentication is not yet fully implemented. ' +
|
'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,
|
serverUrl: string,
|
||||||
bindDn: string,
|
bindDn: string,
|
||||||
bindPassword: string,
|
bindPassword: string,
|
||||||
baseDn: string
|
baseDn: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Similar to ldapBind, this is a placeholder
|
// Similar to ldapBind, this is a placeholder
|
||||||
// Would connect and bind with service account to verify connectivity
|
// Would connect and bind with service account to verify connectivity
|
||||||
@@ -185,7 +185,9 @@ export class LdapStrategy implements IAuthStrategy {
|
|||||||
|
|
||||||
// Return success for configuration validation
|
// Return success for configuration validation
|
||||||
// Actual connectivity test would happen with LDAP library
|
// 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 {
|
return {
|
||||||
externalId,
|
externalId,
|
||||||
email,
|
email,
|
||||||
username: entry[mapping.username]
|
username: entry[mapping.username] ? String(entry[mapping.username]) : undefined,
|
||||||
? String(entry[mapping.username])
|
displayName: entry[mapping.displayName] ? String(entry[mapping.displayName]) : undefined,
|
||||||
: undefined,
|
groups: mapping.groups ? this.parseGroups(entry[mapping.groups]) : 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>,
|
rawAttributes: entry as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||||
import type { CryptoService } from '../../crypto.service.ts';
|
import type { CryptoService } from '../../crypto.service.ts';
|
||||||
import type {
|
import type {
|
||||||
IExternalUserInfo,
|
|
||||||
IConnectionTestResult,
|
IConnectionTestResult,
|
||||||
|
IExternalUserInfo,
|
||||||
} from '../../../interfaces/auth.interfaces.ts';
|
} from '../../../interfaces/auth.interfaces.ts';
|
||||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export class OAuthStrategy implements IAuthStrategy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private provider: AuthProvider,
|
private provider: AuthProvider,
|
||||||
private cryptoService: CryptoService
|
private cryptoService: CryptoService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,12 +243,8 @@ export class OAuthStrategy implements IAuthStrategy {
|
|||||||
return {
|
return {
|
||||||
externalId,
|
externalId,
|
||||||
email,
|
email,
|
||||||
username: rawInfo[mapping.username]
|
username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined,
|
||||||
? String(rawInfo[mapping.username])
|
displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined,
|
||||||
: undefined,
|
|
||||||
displayName: rawInfo[mapping.displayName]
|
|
||||||
? String(rawInfo[mapping.displayName])
|
|
||||||
: undefined,
|
|
||||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||||
? String(rawInfo[mapping.avatarUrl])
|
? String(rawInfo[mapping.avatarUrl])
|
||||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class CryptoService {
|
|||||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||||
if (!keyHex) {
|
if (!keyHex) {
|
||||||
console.warn(
|
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));
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||||
@@ -52,7 +52,7 @@ export class CryptoService {
|
|||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||||
this.masterKey,
|
this.masterKey,
|
||||||
encoded.buffer as ArrayBuffer
|
encoded.buffer as ArrayBuffer,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Format: iv:ciphertext (both base64)
|
// Format: iv:ciphertext (both base64)
|
||||||
@@ -88,7 +88,7 @@ export class CryptoService {
|
|||||||
const decrypted = await crypto.subtle.decrypt(
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||||
this.masterKey,
|
this.masterKey,
|
||||||
encrypted.buffer as ArrayBuffer
|
encrypted.buffer as ArrayBuffer,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decode to string
|
// Decode to string
|
||||||
@@ -123,7 +123,7 @@ export class CryptoService {
|
|||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: 'AES-GCM' },
|
{ name: 'AES-GCM' },
|
||||||
false,
|
false,
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
* 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 { AuthService, type IAuthResult } from './auth.service.ts';
|
||||||
import { AuditService } from './audit.service.ts';
|
import { AuditService } from './audit.service.ts';
|
||||||
import { cryptoService } from './crypto.service.ts';
|
import { cryptoService } from './crypto.service.ts';
|
||||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.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 {
|
export interface IOAuthState {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -33,7 +39,7 @@ export class ExternalAuthService {
|
|||||||
*/
|
*/
|
||||||
public async initiateOAuth(
|
public async initiateOAuth(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
returnUrl?: string
|
returnUrl?: string,
|
||||||
): Promise<{ authUrl: string; state: string }> {
|
): Promise<{ authUrl: string; state: string }> {
|
||||||
const provider = await AuthProvider.findById(providerId);
|
const provider = await AuthProvider.findById(providerId);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -67,7 +73,7 @@ export class ExternalAuthService {
|
|||||||
*/
|
*/
|
||||||
public async handleOAuthCallback(
|
public async handleOAuthCallback(
|
||||||
data: IOAuthCallbackData,
|
data: IOAuthCallbackData,
|
||||||
options: { ipAddress?: string; userAgent?: string } = {}
|
options: { ipAddress?: string; userAgent?: string } = {},
|
||||||
): Promise<IAuthResult> {
|
): Promise<IAuthResult> {
|
||||||
// Validate state
|
// Validate state
|
||||||
const stateData = await this.validateState(data.state);
|
const stateData = await this.validateState(data.state);
|
||||||
@@ -170,7 +176,7 @@ export class ExternalAuthService {
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options: { ipAddress?: string; userAgent?: string } = {}
|
options: { ipAddress?: string; userAgent?: string } = {},
|
||||||
): Promise<IAuthResult> {
|
): Promise<IAuthResult> {
|
||||||
const provider = await AuthProvider.findById(providerId);
|
const provider = await AuthProvider.findById(providerId);
|
||||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||||
@@ -261,7 +267,7 @@ export class ExternalAuthService {
|
|||||||
public async linkProvider(
|
public async linkProvider(
|
||||||
userId: string,
|
userId: string,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
externalUser: IExternalUserInfo
|
externalUser: IExternalUserInfo,
|
||||||
): Promise<ExternalIdentity> {
|
): Promise<ExternalIdentity> {
|
||||||
// Check if this external ID is already linked to another user
|
// Check if this external ID is already linked to another user
|
||||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||||
@@ -377,12 +383,12 @@ export class ExternalAuthService {
|
|||||||
private async findOrCreateUser(
|
private async findOrCreateUser(
|
||||||
provider: AuthProvider,
|
provider: AuthProvider,
|
||||||
externalUser: IExternalUserInfo,
|
externalUser: IExternalUserInfo,
|
||||||
options: { ipAddress?: string } = {}
|
options: { ipAddress?: string } = {},
|
||||||
): Promise<{ user: User; isNew: boolean }> {
|
): Promise<{ user: User; isNew: boolean }> {
|
||||||
// 1. Check if external identity already exists
|
// 1. Check if external identity already exists
|
||||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||||
provider.id,
|
provider.id,
|
||||||
externalUser.externalId
|
externalUser.externalId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIdentity) {
|
if (existingIdentity) {
|
||||||
@@ -544,12 +550,12 @@ export class ExternalAuthService {
|
|||||||
encoder.encode(secret),
|
encoder.encode(secret),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign']
|
['sign'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
const encodedSignature = this.base64UrlEncode(
|
const encodedSignature = this.base64UrlEncode(
|
||||||
String.fromCharCode(...new Uint8Array(signature))
|
String.fromCharCode(...new Uint8Array(signature)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return `${data}.${encodedSignature}`;
|
return `${data}.${encodedSignature}`;
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||||
export {
|
export {
|
||||||
TokenService,
|
|
||||||
type ICreateTokenOptions,
|
type ICreateTokenOptions,
|
||||||
type ITokenValidationResult,
|
type ITokenValidationResult,
|
||||||
|
TokenService,
|
||||||
} from './token.service.ts';
|
} from './token.service.ts';
|
||||||
export {
|
export {
|
||||||
PermissionService,
|
|
||||||
type TAction,
|
|
||||||
type IPermissionContext,
|
type IPermissionContext,
|
||||||
type IResolvedPermissions,
|
type IResolvedPermissions,
|
||||||
|
PermissionService,
|
||||||
|
type TAction,
|
||||||
} from './permission.service.ts';
|
} from './permission.service.ts';
|
||||||
export {
|
export {
|
||||||
AuthService,
|
AuthService,
|
||||||
type IJwtPayload,
|
|
||||||
type IAuthResult,
|
|
||||||
type IAuthConfig,
|
type IAuthConfig,
|
||||||
|
type IAuthResult,
|
||||||
|
type IJwtPayload,
|
||||||
} from './auth.service.ts';
|
} from './auth.service.ts';
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
TOrganizationRole,
|
TOrganizationRole,
|
||||||
TTeamRole,
|
|
||||||
TRepositoryRole,
|
|
||||||
TRegistryProtocol,
|
TRegistryProtocol,
|
||||||
|
TRepositoryRole,
|
||||||
|
TTeamRole,
|
||||||
} from '../interfaces/auth.interfaces.ts';
|
} from '../interfaces/auth.interfaces.ts';
|
||||||
import {
|
import {
|
||||||
User,
|
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationMember,
|
OrganizationMember,
|
||||||
Team,
|
|
||||||
TeamMember,
|
|
||||||
Repository,
|
Repository,
|
||||||
RepositoryPermission,
|
RepositoryPermission,
|
||||||
|
Team,
|
||||||
|
TeamMember,
|
||||||
|
User,
|
||||||
} from '../models/index.ts';
|
} from '../models/index.ts';
|
||||||
|
|
||||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||||
@@ -71,7 +71,10 @@ export class PermissionService {
|
|||||||
if (!context.organizationId) return result;
|
if (!context.organizationId) return result;
|
||||||
|
|
||||||
// Get organization membership
|
// Get organization membership
|
||||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
const orgMember = await OrganizationMember.findMembership(
|
||||||
|
context.organizationId,
|
||||||
|
context.userId,
|
||||||
|
);
|
||||||
if (orgMember) {
|
if (orgMember) {
|
||||||
result.organizationRole = orgMember.role;
|
result.organizationRole = orgMember.role;
|
||||||
|
|
||||||
@@ -137,7 +140,10 @@ export class PermissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get direct repository permission (highest priority)
|
// 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) {
|
if (repoPerm) {
|
||||||
result.repositoryRole = repoPerm.role;
|
result.repositoryRole = repoPerm.role;
|
||||||
this.applyRole(result, repoPerm.role);
|
this.applyRole(result, repoPerm.role);
|
||||||
@@ -151,7 +157,7 @@ export class PermissionService {
|
|||||||
*/
|
*/
|
||||||
public async checkPermission(
|
public async checkPermission(
|
||||||
context: IPermissionContext,
|
context: IPermissionContext,
|
||||||
action: TAction
|
action: TAction,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const permissions = await this.resolvePermissions(context);
|
const permissions = await this.resolvePermissions(context);
|
||||||
|
|
||||||
@@ -176,11 +182,11 @@ export class PermissionService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
action: 'read' | 'write' | 'delete'
|
action: 'read' | 'write' | 'delete',
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await this.checkPermission(
|
return await this.checkPermission(
|
||||||
{ userId, organizationId, repositoryId },
|
{ userId, organizationId, repositoryId },
|
||||||
action
|
action,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +208,7 @@ export class PermissionService {
|
|||||||
public async canManageRepository(
|
public async canManageRepository(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
repositoryId: string
|
repositoryId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const permissions = await this.resolvePermissions({
|
const permissions = await this.resolvePermissions({
|
||||||
userId,
|
userId,
|
||||||
@@ -217,7 +223,7 @@ export class PermissionService {
|
|||||||
*/
|
*/
|
||||||
public async getAccessibleRepositories(
|
public async getAccessibleRepositories(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
): Promise<Repository[]> {
|
): Promise<Repository[]> {
|
||||||
const user = await User.findById(userId);
|
const user = await User.findById(userId);
|
||||||
if (!user || !user.isActive) return [];
|
if (!user || !user.isActive) return [];
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export class TokenService {
|
|||||||
* Generate a new API token
|
* Generate a new API token
|
||||||
* Returns the raw token (only shown once) and the saved token record
|
* 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}
|
// Generate secure random token: srg_{64 hex chars}
|
||||||
const randomBytes = new Uint8Array(32);
|
const randomBytes = new Uint8Array(32);
|
||||||
crypto.getRandomValues(randomBytes);
|
crypto.getRandomValues(randomBytes);
|
||||||
@@ -206,7 +208,7 @@ export class TokenService {
|
|||||||
protocol: TRegistryProtocol,
|
protocol: TRegistryProtocol,
|
||||||
organizationId?: string,
|
organizationId?: string,
|
||||||
repositoryId?: string,
|
repositoryId?: string,
|
||||||
action?: string
|
action?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!token.hasProtocol(protocol)) return false;
|
if (!token.hasProtocol(protocol)) return false;
|
||||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||||
|
|||||||
80
ts_interfaces/data/admin.ts
Normal file
80
ts_interfaces/data/admin.ts
Normal 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;
|
||||||
|
}
|
||||||
79
ts_interfaces/data/audit.ts
Normal file
79
ts_interfaces/data/audit.ts
Normal 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>;
|
||||||
|
}
|
||||||
49
ts_interfaces/data/auth.ts
Normal file
49
ts_interfaces/data/auth.ts
Normal 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';
|
||||||
7
ts_interfaces/data/index.ts
Normal file
7
ts_interfaces/data/index.ts
Normal 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';
|
||||||
48
ts_interfaces/data/organization.ts
Normal file
48
ts_interfaces/data/organization.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types used by settings
|
||||||
|
import type { TRepositoryVisibility } from './repository.ts';
|
||||||
|
import type { TRegistryProtocol } from './package.ts';
|
||||||
|
export type { TRegistryProtocol, TRepositoryVisibility };
|
||||||
53
ts_interfaces/data/package.ts
Normal file
53
ts_interfaces/data/package.ts
Normal 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;
|
||||||
|
}
|
||||||
22
ts_interfaces/data/repository.ts
Normal file
22
ts_interfaces/data/repository.ts
Normal 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;
|
||||||
|
}
|
||||||
33
ts_interfaces/data/token.ts
Normal file
33
ts_interfaces/data/token.ts
Normal 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
5
ts_interfaces/index.ts
Normal 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
3
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
|
|
||||||
|
export { typedrequestInterfaces };
|
||||||
137
ts_interfaces/requests/admin.ts
Normal file
137
ts_interfaces/requests/admin.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
33
ts_interfaces/requests/audit.ts
Normal file
33
ts_interfaces/requests/audit.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
82
ts_interfaces/requests/auth.ts
Normal file
82
ts_interfaces/requests/auth.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import * as data from '../data/index.ts';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Requests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IReq_Login extends
|
||||||
|
plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_Login
|
||||||
|
> {
|
||||||
|
method: 'login';
|
||||||
|
request: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
identity?: data.IIdentity;
|
||||||
|
user?: data.IUser;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RefreshToken extends
|
||||||
|
plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RefreshToken
|
||||||
|
> {
|
||||||
|
method: 'refreshToken';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_Logout extends
|
||||||
|
plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_Logout
|
||||||
|
> {
|
||||||
|
method: 'logout';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
sessionId?: string;
|
||||||
|
all?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetMe extends
|
||||||
|
plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetMe
|
||||||
|
> {
|
||||||
|
method: 'getMe';
|
||||||
|
request: {
|
||||||
|
identity: data.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
user: data.IUser;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetAuthProviders extends
|
||||||
|
plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetAuthProviders
|
||||||
|
> {
|
||||||
|
method: 'getAuthProviders';
|
||||||
|
request: {};
|
||||||
|
response: {
|
||||||
|
providers: data.IPublicAuthProvider[];
|
||||||
|
localAuthEnabled: boolean;
|
||||||
|
defaultProviderId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
9
ts_interfaces/requests/index.ts
Normal file
9
ts_interfaces/requests/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './auth.ts';
|
||||||
|
export * from './oauth.ts';
|
||||||
|
export * from './organizations.ts';
|
||||||
|
export * from './repositories.ts';
|
||||||
|
export * from './packages.ts';
|
||||||
|
export * from './tokens.ts';
|
||||||
|
export * from './audit.ts';
|
||||||
|
export * from './admin.ts';
|
||||||
|
export * from './users.ts';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user