Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27955c6a7b | |||
| 3b2aa57b7d | |||
| 2d84470688 | |||
| 883fc1d22b | |||
| 6961ac7e27 | |||
| fae8147414 | |||
| c589476590 | |||
| 03529bc140 | |||
| ffade4d5ca | |||
| 9c4636906a | |||
| f44b03b47d | |||
| 6d6ed61e70 | |||
| 392060bf23 | |||
| 8cb5e4fa96 | |||
| c60a0ed536 | |||
| 087b8c0bb3 | |||
| ffe7ffbde9 | |||
| b9a3d79b5f | |||
| aacf30e582 | |||
| d4f758ce0f | |||
| 0fc74ff995 | |||
| d71ae08645 |
@@ -33,9 +33,6 @@ jobs:
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --ignore-scripts
|
||||
|
||||
- name: Install UI dependencies
|
||||
run: cd ui && pnpm install
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -56,11 +53,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Angular UI
|
||||
run: cd ui && pnpm run build
|
||||
|
||||
- name: Bundle UI into TypeScript
|
||||
run: deno run --allow-all scripts/bundle-ui.ts
|
||||
- name: Build UI
|
||||
run: npx tsbundle
|
||||
|
||||
- name: Compile binaries for all platforms
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,15 +4,14 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
ui/dist/
|
||||
.angular/
|
||||
out-tsc/
|
||||
|
||||
# tsdeno temporary files
|
||||
package.json.bak
|
||||
|
||||
# Generated files
|
||||
ts/embedded-ui.generated.ts
|
||||
ts_bundled/
|
||||
dist_ts_web/
|
||||
|
||||
# Deno
|
||||
.deno/
|
||||
@@ -64,8 +63,5 @@ stories/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Angular cache
|
||||
.angular/cache/
|
||||
|
||||
# TypeScript incremental compilation
|
||||
*.tsbuildinfo
|
||||
|
||||
181
changelog.md
181
changelog.md
@@ -1,63 +1,184 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-22 - 1.8.5 - fix(registry)
|
||||
restore protocol routing and test coverage for npm, oci, and api flows
|
||||
|
||||
- initialize and route REST API requests through ApiRouter alongside smartregistry
|
||||
- add org-aware npm path handling and OCI bearer token endpoint support in the registry server
|
||||
- enforce API token scopes in the auth provider instead of allowing all authenticated writes
|
||||
- start the test server in integration and e2e suites and update assertions to match current API responses
|
||||
- fix npm unpublish and OCI image test flows, including docker build loading and storage cleanup
|
||||
|
||||
## 2026-03-21 - 1.8.4 - fix(deps)
|
||||
bump @stack.gallery/catalog to ^1.0.2 and remove committed test fixture auth token
|
||||
|
||||
- Updates the @stack.gallery/catalog dependency from ^1.0.1 to ^1.0.2.
|
||||
- Removes the .npmrc auth token from the npm test fixture package to avoid keeping credentials in the repository.
|
||||
|
||||
## 2026-03-21 - 1.8.3 - fix(test-fixtures)
|
||||
update npm fixture registry configuration for scoped package installs
|
||||
|
||||
- refreshes the test fixture auth token in .npmrc
|
||||
- adds the @stack-test scoped registry mapping to the npm fixture configuration
|
||||
|
||||
## 2026-03-21 - 1.8.2 - fix(deps)
|
||||
replace local catalog dependency with published version and simplify npm fixture auth config
|
||||
|
||||
- switch @stack.gallery/catalog from a local file reference to the published ^1.0.1 release
|
||||
- update the npm test fixture .npmrc to use only an auth token entry
|
||||
|
||||
## 2026-03-21 - 1.8.1 - fix(release,test)
|
||||
streamline release UI bundling and add npm fixture registry configuration
|
||||
|
||||
- Update the release workflow to build the UI with tsbundle directly instead of installing UI-specific dependencies and running a separate bundling script
|
||||
- Add an .npmrc fixture for the demo npm package to configure the scoped registry and authentication token for local registry tests
|
||||
|
||||
## 2026-03-21 - 1.8.0 - feat(web)
|
||||
add public package browsing and organization redirect management
|
||||
|
||||
- introduces a public packages view and root route behavior for unauthenticated users
|
||||
- updates the app shell to support public browsing mode with an optional sign-in flow
|
||||
- adds organization redirect state, fetching, and deletion in the organization detail view
|
||||
|
||||
## 2026-03-20 - 1.7.0 - feat(organization)
|
||||
add organization rename redirects and redirect management endpoints
|
||||
|
||||
- add OrgRedirect model and resolve organizations by historical names
|
||||
- support renaming organizations while preserving the previous handle as a redirect alias
|
||||
- add typed requests to list and delete organization redirects with admin permission checks
|
||||
- allow organization update actions to send name changes
|
||||
|
||||
## 2026-03-20 - 1.6.0 - feat(web-organizations)
|
||||
add organization detail editing and isolate detail view state from global navigation
|
||||
|
||||
- adds an update organization action to persist organization detail edits from the detail view
|
||||
- updates organization and package views to track selected detail entities locally instead of mutating global ui state
|
||||
- preserves resolved app shell tabs for role-based filtering after async tab loading
|
||||
- includes type-cast fixes for admin auth provider responses and bundled file Response bodies
|
||||
|
||||
## 2026-03-20 - 1.5.1 - fix(web-app)
|
||||
update dashboard navigation to use the router directly and refresh admin tabs on login changes
|
||||
|
||||
- removes the global router workaround in the dashboard and imports appRouter directly
|
||||
- re-filters resolved view tabs when login state changes so the Admin tab matches system admin access
|
||||
- adds dashboard navigation support for the organizations view
|
||||
|
||||
## 2026-03-20 - 1.5.0 - feat(opsserver,web)
|
||||
replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
|
||||
|
||||
- add a new OpsServer with TypedRequest handlers for auth, organizations, repositories, packages, tokens, audit, admin, OAuth, and user settings flows
|
||||
- introduce shared TypedRequest contracts under ts_interfaces and wire the registry to serve POST /typedrequest requests
|
||||
- replace the embedded Angular build pipeline with tsbundle/tswatch-based web bundling, static html entrypoint, and new ts_web app state and shell views
|
||||
- remove the legacy Angular frontend, custom UI bundler script, reload websocket hot-reload path, and related build configuration
|
||||
|
||||
## 2026-03-20 - 1.4.2 - fix(registry)
|
||||
|
||||
align registry integrations with updated auth, storage, repository, and audit models
|
||||
|
||||
- update smartregistry auth and storage provider implementations to match the current request,
|
||||
token, and storage hook APIs
|
||||
- fix audit events for auth provider, platform settings, and external authentication flows to use
|
||||
dedicated event types
|
||||
- adapt repository, organization, user, and package handlers to renamed model fields and revised
|
||||
repository visibility/protocol data
|
||||
- add missing repository and team model fields plus helper methods needed by the updated API and
|
||||
permission flows
|
||||
- correct AES-GCM crypto buffer handling and package version checksum mapping
|
||||
|
||||
## 2026-03-20 - 1.4.1 - fix(repo)
|
||||
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-03-20 - 1.4.0 - feat(release,build,tests)
|
||||
|
||||
add automated multi-platform release pipeline and align runtime, model, and test updates
|
||||
|
||||
- add a Gitea release workflow that builds the UI, bundles embedded assets, cross-compiles binaries for Linux and macOS, generates checksums, and publishes release assets from version tags
|
||||
- switch compilation to tsdeno with compile targets defined in npmextra.json and simplify project scripts for check, lint, format, and compile tasks
|
||||
- add a Gitea release workflow that builds the UI, bundles embedded assets, cross-compiles binaries
|
||||
for Linux and macOS, generates checksums, and publishes release assets from version tags
|
||||
- switch compilation to tsdeno with compile targets defined in npmextra.json and simplify project
|
||||
scripts for check, lint, format, and compile tasks
|
||||
- improve CLI startup error handling in mod.ts and guard execution with import.meta.main
|
||||
- update test configuration to load MongoDB and S3 settings from qenv-based environment files and adjust tests for renamed model and token APIs
|
||||
- rename package search usage to searchPackages, update audit event names, and align package version fields and model name overrides with newer dependency behavior
|
||||
- update test configuration to load MongoDB and S3 settings from qenv-based environment files and
|
||||
adjust tests for renamed model and token APIs
|
||||
- rename package search usage to searchPackages, update audit event names, and align package version
|
||||
fields and model name overrides with newer dependency behavior
|
||||
|
||||
## 2025-12-03 - 1.3.0 - feat(auth)
|
||||
|
||||
Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
|
||||
|
||||
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to store provider configs, links, and platform auth settings
|
||||
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings
|
||||
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login
|
||||
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing
|
||||
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy
|
||||
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation
|
||||
- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags
|
||||
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler)
|
||||
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection
|
||||
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to
|
||||
store provider configs, links, and platform auth settings
|
||||
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage
|
||||
platform auth settings
|
||||
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling
|
||||
callbacks, and LDAP login
|
||||
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking,
|
||||
session/token generation, and provider testing
|
||||
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory
|
||||
to select appropriate strategy
|
||||
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key
|
||||
generation
|
||||
- Extend AuthService and session/user handling to support tokens/sessions created by external auth
|
||||
flows and user provisioning flags
|
||||
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login
|
||||
enhancements (SSO buttons, LDAP form, oauth-callback handler)
|
||||
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard
|
||||
for route protection
|
||||
- Register new API routes in ApiRouter and wire server-side handlers into the router
|
||||
- Implement safeguards: mask secrets in admin responses, validate provider configs, and track connection test results and audit logs
|
||||
- Implement safeguards: mask secrets in admin responses, validate provider configs, and track
|
||||
connection test results and audit logs
|
||||
|
||||
## 2025-11-28 - 1.2.0 - feat(tokens)
|
||||
|
||||
Add support for organization-owned API tokens and org-level token management
|
||||
|
||||
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and new static getOrgTokens method
|
||||
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate
|
||||
- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
|
||||
- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks org management permissions
|
||||
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and
|
||||
new static getOrgTokens method
|
||||
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById
|
||||
where appropriate
|
||||
- TokenService: create token options now accept organizationId and createdById; tokens store org and
|
||||
creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
|
||||
- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke
|
||||
org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks
|
||||
org management permissions
|
||||
- Router: PermissionService instantiated and passed to TokenApi
|
||||
- UI: api.service types and methods updated — IToken and ITokenScope include organizationId/createdById; getTokens and createToken now support an organizationId parameter and scoped scopes
|
||||
- UI: api.service types and methods updated — IToken and ITokenScope include
|
||||
organizationId/createdById; getTokens and createToken now support an organizationId parameter and
|
||||
scoped scopes
|
||||
- .gitignore: added stories/ to ignore
|
||||
|
||||
## 2025-11-28 - 1.1.0 - feat(registry)
|
||||
|
||||
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
|
||||
|
||||
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated embedded UI file.
|
||||
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server
|
||||
restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and
|
||||
compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated
|
||||
embedded UI file.
|
||||
|
||||
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected clients for hot-reload.
|
||||
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
|
||||
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS and trigger page reloads with exponential reconnect.
|
||||
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
|
||||
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected
|
||||
clients for hot-reload.
|
||||
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at
|
||||
/ws/reload.
|
||||
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS
|
||||
and trigger page reloads with exponential reconnect.
|
||||
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to
|
||||
index.html.
|
||||
- Ignore generated embedded UI file (ts/embedded-ui.generated.ts) in .gitignore.
|
||||
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and a release task to bundle + compile.
|
||||
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
|
||||
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and
|
||||
a release task to bundle + compile.
|
||||
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task
|
||||
bundle-ui:watch).
|
||||
|
||||
## 2025-11-28 - 1.0.1 - fix(smartdata)
|
||||
|
||||
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
|
||||
|
||||
- Updated deno.json imports mapping for @push.rocks/smartdata from ^7.0.9 to ^7.0.13
|
||||
|
||||
## 2025-11-28 - 1.0.0 - Initial release
|
||||
|
||||
Release with core features, UI, and project scaffolding.
|
||||
|
||||
- Implemented account settings and API tokens management.
|
||||
@@ -76,8 +197,10 @@ Release with core features, UI, and project scaffolding.
|
||||
- Dependency updates and fixes.
|
||||
|
||||
## 2025-11-27 - 2025-11-28 - unknown -> 1.0.0 - housekeeping / duplicate commits
|
||||
|
||||
Minor housekeeping and duplicate commits consolidated into the 1.0.0 release.
|
||||
|
||||
- Added initial README with project overview, features, and setup instructions.
|
||||
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown version) into the 1.0.0 release.
|
||||
- Miscellaneous UI tweaks and dependency updates.
|
||||
- Consolidated a duplicate "feat: add account settings and API tokens management" commit (unknown
|
||||
version) into the 1.0.0 release.
|
||||
- Miscellaneous UI tweaks and dependency updates.
|
||||
|
||||
12
deno.json
12
deno.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.4.1",
|
||||
"version": "1.8.5",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -12,9 +12,8 @@
|
||||
"test:e2e": "deno test --allow-all --no-check test/e2e/",
|
||||
"test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait",
|
||||
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
|
||||
"build": "cd ui && pnpm run build",
|
||||
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
|
||||
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
|
||||
"build-ui": "npx tsbundle",
|
||||
"watch": "npx tswatch",
|
||||
"compile": "tsdeno compile",
|
||||
"check": "deno check mod.ts",
|
||||
"fmt": "deno fmt",
|
||||
@@ -35,8 +34,11 @@
|
||||
"@push.rocks/smartdelay": "npm:@push.rocks/smartdelay@^3.0.5",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartcli": "npm:@push.rocks/smartcli@^4.0.20",
|
||||
"@push.rocks/smartarchive": "npm:@push.rocks/smartarchive@^5.2.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.3",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.1.10",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^3.0.53",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
# Stack.Gallery Design System
|
||||
|
||||
Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange/green accent colors.
|
||||
Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange/green accent
|
||||
colors.
|
||||
|
||||
## Colors (HSL)
|
||||
|
||||
### Dark Theme (Default)
|
||||
|
||||
| Token | HSL | Hex | Usage |
|
||||
|--------------------|---------------|---------|------------------------------------|
|
||||
| background | 0 0% 0% | #000000 | Page background |
|
||||
| foreground | 0 0% 100% | #FFFFFF | Primary text |
|
||||
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
|
||||
| primary-foreground | 0 0% 0% | #000000 | Text on primary buttons |
|
||||
| accent | 142 71% 45% | #22C55E | Terminal green, success states |
|
||||
| accent-foreground | 0 0% 0% | #000000 | Text on accent |
|
||||
| muted | 0 0% 8% | #141414 | Subtle backgrounds |
|
||||
| muted-foreground | 0 0% 55% | #8C8C8C | Secondary text, labels |
|
||||
| card | 0 0% 4% | #0A0A0A | Card backgrounds |
|
||||
| border | 0 0% 15% | #262626 | All borders, dividers |
|
||||
| destructive | 0 84% 60% | #EF4444 | Errors, terminal red dots |
|
||||
| Token | HSL | Hex | Usage |
|
||||
| ------------------ | ----------- | ------- | ---------------------------------- |
|
||||
| background | 0 0% 0% | #000000 | Page background |
|
||||
| foreground | 0 0% 100% | #FFFFFF | Primary text |
|
||||
| primary | 33 100% 50% | #FF8000 | Bloomberg orange, CTAs, highlights |
|
||||
| primary-foreground | 0 0% 0% | #000000 | Text on primary buttons |
|
||||
| accent | 142 71% 45% | #22C55E | Terminal green, success states |
|
||||
| accent-foreground | 0 0% 0% | #000000 | Text on accent |
|
||||
| muted | 0 0% 8% | #141414 | Subtle backgrounds |
|
||||
| muted-foreground | 0 0% 55% | #8C8C8C | Secondary text, labels |
|
||||
| card | 0 0% 4% | #0A0A0A | Card backgrounds |
|
||||
| border | 0 0% 15% | #262626 | All borders, dividers |
|
||||
| destructive | 0 84% 60% | #EF4444 | Errors, terminal red dots |
|
||||
|
||||
### Light Theme
|
||||
|
||||
| Token | HSL | Hex |
|
||||
|------------------|---------------|---------|
|
||||
| background | 0 0% 100% | #FFFFFF |
|
||||
| foreground | 0 0% 5% | #0D0D0D |
|
||||
| primary | 33 100% 45% | #E67300 |
|
||||
| accent | 142 71% 35% | #16A34A |
|
||||
| muted | 0 0% 96% | #F5F5F5 |
|
||||
| muted-foreground | 0 0% 40% | #666666 |
|
||||
| card | 0 0% 98% | #FAFAFA |
|
||||
| border | 0 0% 90% | #E5E5E5 |
|
||||
| Token | HSL | Hex |
|
||||
| ---------------- | ----------- | ------- |
|
||||
| background | 0 0% 100% | #FFFFFF |
|
||||
| foreground | 0 0% 5% | #0D0D0D |
|
||||
| primary | 33 100% 45% | #E67300 |
|
||||
| accent | 142 71% 35% | #16A34A |
|
||||
| muted | 0 0% 96% | #F5F5F5 |
|
||||
| muted-foreground | 0 0% 40% | #666666 |
|
||||
| card | 0 0% 98% | #FAFAFA |
|
||||
| border | 0 0% 90% | #E5E5E5 |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +53,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
|
||||
### Font Sizes
|
||||
|
||||
| Element | Size | Weight | Letter Spacing |
|
||||
|-----------------|------------------------|--------|-----------------|
|
||||
| --------------- | ---------------------- | ------ | --------------- |
|
||||
| H1 (Hero) | 3rem / 4rem (md) | 700 | -0.02em (tight) |
|
||||
| H2 (Section) | 1.5rem / 1.875rem (md) | 700 | -0.02em |
|
||||
| H3 (Card title) | 0.875rem | 600 | normal |
|
||||
@@ -72,7 +73,7 @@ Bloomberg terminal-inspired aesthetic with dark theme, sharp corners, and orange
|
||||
### Border Radius
|
||||
|
||||
```css
|
||||
--radius: 0px; /* All elements: sharp corners */
|
||||
--radius: 0px; /* All elements: sharp corners */
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
33
html/index.html
Normal file
33
html/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Stack.Gallery Registry</title>
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
<style>
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p style="color: #fff; text-align: center; margin-top: 100px">
|
||||
JavaScript is required to run the Stack.Gallery Registry.
|
||||
</p>
|
||||
</noscript>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
package.json
16
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.4.1",
|
||||
"version": "1.8.5",
|
||||
"private": true,
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"watch": "concurrently --kill-others --names \"BACKEND,UI,BUNDLER\" --prefix-colors \"cyan,magenta,yellow\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\" \"deno task bundle-ui:watch\"",
|
||||
"watch": "tswatch",
|
||||
"build": "deno task check",
|
||||
"test": "deno task test",
|
||||
"lint": "deno task lint",
|
||||
@@ -26,9 +26,19 @@
|
||||
],
|
||||
"author": "Stack.Gallery",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.1.10",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.2",
|
||||
"@design.estate/dees-catalog": "^3.43.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@stack.gallery/catalog": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsdeno": "^1.2.0",
|
||||
"concurrently": "^9.1.2"
|
||||
"@git.zone/tswatch": "^3.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||
}
|
||||
|
||||
2926
pnpm-lock.yaml
generated
2926
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
296
readme.md
296
readme.md
@@ -1,6 +1,8 @@
|
||||
# @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
|
||||
|
||||
@@ -8,14 +10,15 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔌 **7 Protocol Support** — NPM, OCI/Docker, Maven, Cargo, PyPI, Composer, RubyGems via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry)
|
||||
- 🔌 **7 Protocol Support** — NPM, OCI/Docker, Maven, Cargo, PyPI, Composer, RubyGems via
|
||||
[`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry)
|
||||
- 🏢 **Organizations & Teams** — Hierarchical access control: orgs → teams → repositories
|
||||
- 🔐 **Flexible Authentication** — Local JWT auth, OAuth/OIDC, and LDAP with JIT user provisioning
|
||||
- 🎫 **Scoped API Tokens** — Per-protocol, per-scope tokens (`srg_` prefix) for CI/CD pipelines
|
||||
- 🛡️ **RBAC Permissions** — Reader → Developer → Maintainer → Admin per repository
|
||||
- 🔍 **Upstream Caching** — Transparently proxy and cache packages from public registries
|
||||
- 📊 **Audit Logging** — Full audit trail on every action for compliance
|
||||
- 🎨 **Modern Web UI** — Angular 19 dashboard with Tailwind CSS, embedded in the binary
|
||||
- 🎨 **Modern Web UI** — Web Components dashboard built with [`@design.estate/dees-catalog`](https://code.foss.global/design.estate/dees-catalog), bundled into the binary
|
||||
- ⚡ **Single Binary** — Cross-compiled with `deno compile` for Linux and macOS (x64 + ARM64)
|
||||
- 🗄️ **MongoDB + S3** — Metadata in MongoDB, artifacts in any S3-compatible store
|
||||
|
||||
@@ -33,13 +36,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||
|
||||
# Install specific version
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.3.0
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.8.0
|
||||
|
||||
# Install + set up systemd service
|
||||
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --setup-service
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
- Detects your platform (Linux/macOS, x64/ARM64)
|
||||
- Downloads the pre-compiled binary from Gitea releases
|
||||
- Installs to `/opt/stack-gallery-registry/` with a symlink in `/usr/local/bin/`
|
||||
@@ -52,6 +56,9 @@ The installer:
|
||||
git clone https://code.foss.global/stack.gallery/registry.git
|
||||
cd registry
|
||||
|
||||
# Install Node dependencies (for tsbundle/tsdeno build tools)
|
||||
pnpm install
|
||||
|
||||
# Development mode (hot reload, reads .nogit/env.json)
|
||||
deno task dev
|
||||
|
||||
@@ -63,24 +70,25 @@ The registry is available at `http://localhost:3000`.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Configuration is loaded from **environment variables** (production) or from **`.nogit/env.json`** when using the `--ephemeral` flag (development).
|
||||
Configuration is loaded from **environment variables** (production) or from **`.nogit/env.json`**
|
||||
when using the `--ephemeral` flag (development).
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MONGODB_URL` | `mongodb://localhost:27017` | MongoDB connection string |
|
||||
| `MONGODB_DB` | `stackgallery` | Database name |
|
||||
| `S3_ENDPOINT` | `http://localhost:9000` | S3-compatible endpoint |
|
||||
| `S3_ACCESS_KEY` | `minioadmin` | S3 access key |
|
||||
| `S3_SECRET_KEY` | `minioadmin` | S3 secret key |
|
||||
| `S3_BUCKET` | `registry` | S3 bucket name |
|
||||
| `S3_REGION` | — | S3 region |
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `AUTH_ENCRYPTION_KEY` | *(ephemeral)* | 64-char hex for AES-256-GCM encryption of OAuth/LDAP secrets |
|
||||
| `STORAGE_PATH` | `packages` | Base path in S3 for artifacts |
|
||||
| `ENABLE_UPSTREAM_CACHE` | `true` | Cache packages from upstream registries |
|
||||
| `UPSTREAM_CACHE_EXPIRY` | `24` | Cache TTL in hours |
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | --------------------------- | ------------------------------------------------------------ |
|
||||
| `MONGODB_URL` | `mongodb://localhost:27017` | MongoDB connection string |
|
||||
| `MONGODB_DB` | `stackgallery` | Database name |
|
||||
| `S3_ENDPOINT` | `http://localhost:9000` | S3-compatible endpoint |
|
||||
| `S3_ACCESS_KEY` | `minioadmin` | S3 access key |
|
||||
| `S3_SECRET_KEY` | `minioadmin` | S3 secret key |
|
||||
| `S3_BUCKET` | `registry` | S3 bucket name |
|
||||
| `S3_REGION` | — | S3 region |
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `AUTH_ENCRYPTION_KEY` | _(ephemeral)_ | 64-char hex for AES-256-GCM encryption of OAuth/LDAP secrets |
|
||||
| `STORAGE_PATH` | `packages` | Base path in S3 for artifacts |
|
||||
| `ENABLE_UPSTREAM_CACHE` | `true` | Cache packages from upstream registries |
|
||||
| `UPSTREAM_CACHE_EXPIRY` | `24` | Cache TTL in hours |
|
||||
|
||||
**Example `.nogit/env.json`:**
|
||||
|
||||
@@ -99,33 +107,67 @@ Configuration is loaded from **environment variables** (production) or from **`.
|
||||
|
||||
## 🔌 Protocol Endpoints
|
||||
|
||||
Each protocol is handled natively via [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry). Point your package manager at the registry:
|
||||
Each protocol is handled natively via
|
||||
[`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry). Point your package
|
||||
manager at the registry:
|
||||
|
||||
| Protocol | Paths | Client Config Example |
|
||||
|----------|-------|-----------------------|
|
||||
| **NPM** | `/-/*`, `/@scope/*` | `npm config set registry http://registry:3000` |
|
||||
| **OCI/Docker** | `/v2/*` | `docker login registry:3000` |
|
||||
| **Maven** | `/maven2/*` | Add repository URL in `pom.xml` |
|
||||
| **Cargo** | `/api/v1/crates/*` | Configure in `.cargo/config.toml` |
|
||||
| **PyPI** | `/simple/*`, `/pypi/*` | `pip install --index-url http://registry:3000/simple/` |
|
||||
| **Composer** | `/packages.json`, `/p/*` | Add repository in `composer.json` |
|
||||
| **RubyGems** | `/api/v1/gems/*`, `/gems/*` | `gem sources -a http://registry:3000` |
|
||||
| Protocol | Paths | Client Config Example |
|
||||
| -------------- | --------------------------- | ------------------------------------------------------ |
|
||||
| **NPM** | `/-/npm/{org}/*` | `npm config set registry http://registry:3000/-/npm/myorg/` |
|
||||
| **OCI/Docker** | `/v2/*` | `docker login registry:3000` |
|
||||
| **Maven** | `/maven2/*` | Add repository URL in `pom.xml` |
|
||||
| **Cargo** | `/api/v1/crates/*` | Configure in `.cargo/config.toml` |
|
||||
| **PyPI** | `/simple/*`, `/pypi/*` | `pip install --index-url http://registry:3000/simple/` |
|
||||
| **Composer** | `/packages.json`, `/p/*` | Add repository in `composer.json` |
|
||||
| **RubyGems** | `/api/v1/gems/*`, `/gems/*` | `gem sources -a http://registry:3000` |
|
||||
|
||||
Authentication works with **Bearer tokens** (API tokens prefixed `srg_`) and **Basic auth** (email:password or username:token).
|
||||
Authentication works with **Bearer tokens** (API tokens prefixed `srg_`) and **Basic auth**
|
||||
(email:password or username:token).
|
||||
|
||||
### NPM Usage Example
|
||||
|
||||
```bash
|
||||
# Configure npm to use your org's registry
|
||||
npm config set @myorg:registry http://localhost:3000/-/npm/myorg/
|
||||
|
||||
# Authenticate
|
||||
echo "//localhost:3000/-/npm/myorg/:_authToken=srg_YOUR_TOKEN" >> ~/.npmrc
|
||||
|
||||
# Publish & install as usual
|
||||
npm publish
|
||||
npm install @myorg/my-package
|
||||
```
|
||||
|
||||
### Docker/OCI Usage Example
|
||||
|
||||
```bash
|
||||
# Login
|
||||
docker login localhost:3000
|
||||
|
||||
# Tag and push
|
||||
docker tag myimage:latest localhost:3000/myorg/myimage:1.0.0
|
||||
docker push localhost:3000/myorg/myimage:1.0.0
|
||||
|
||||
# Pull
|
||||
docker pull localhost:3000/myorg/myimage:1.0.0
|
||||
```
|
||||
|
||||
## 🔐 Authentication & Security
|
||||
|
||||
### Local Auth
|
||||
|
||||
- JWT-based with **15-minute access tokens** and **7-day refresh tokens** (HS256)
|
||||
- Session tracking — each login creates a session, tokens embed session IDs
|
||||
- Password hashing with PBKDF2 (10,000 rounds SHA-256 + random salt)
|
||||
|
||||
### External Auth (OAuth/OIDC & LDAP)
|
||||
|
||||
- **OAuth/OIDC** — Connect to any OIDC-compliant provider (Keycloak, Okta, Auth0, Azure AD, etc.)
|
||||
- **LDAP** — Bind + search authentication against Active Directory or OpenLDAP
|
||||
- **JIT Provisioning** — Users are auto-created on first external login
|
||||
- **Auto-linking** — External identities are linked to existing users by email match
|
||||
- **Encrypted secrets** — Provider client secrets and bind passwords are stored AES-256-GCM encrypted
|
||||
- **Encrypted secrets** — Provider client secrets and bind passwords are stored AES-256-GCM
|
||||
encrypted
|
||||
|
||||
### RBAC Permissions
|
||||
|
||||
@@ -143,6 +185,7 @@ Platform Admin (full access)
|
||||
### Scoped API Tokens
|
||||
|
||||
Tokens are prefixed with `srg_` and can be scoped to:
|
||||
|
||||
- Specific **protocols** (e.g., npm + oci only)
|
||||
- Specific **actions** (read / write / delete)
|
||||
- Specific **organizations**
|
||||
@@ -150,88 +193,98 @@ Tokens are prefixed with `srg_` and can be scoped to:
|
||||
|
||||
## 📡 REST API
|
||||
|
||||
All management endpoints live under `/api/v1/`. Authenticated via `Authorization: Bearer <jwt_or_api_token>`.
|
||||
All management endpoints live under `/api/v1/`. Authenticated via
|
||||
`Authorization: Bearer <jwt_or_api_token>`.
|
||||
|
||||
### Auth
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/v1/auth/login` | Login (email + password) |
|
||||
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
||||
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
||||
| `GET` | `/api/v1/auth/me` | Current user info |
|
||||
| `GET` | `/api/v1/auth/providers` | List active external auth providers |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/authorize` | Initiate OAuth flow |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/callback` | OAuth callback |
|
||||
| `POST` | `/api/v1/auth/ldap/:id/login` | LDAP login |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/v1/auth/login` | Login (email + password) |
|
||||
| `POST` | `/api/v1/auth/refresh` | Refresh access token |
|
||||
| `POST` | `/api/v1/auth/logout` | Logout (invalidate session) |
|
||||
| `GET` | `/api/v1/auth/me` | Current user info |
|
||||
| `GET` | `/api/v1/auth/providers` | List active external auth providers |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/authorize` | Initiate OAuth flow |
|
||||
| `GET` | `/api/v1/auth/oauth/:id/callback` | OAuth callback |
|
||||
| `POST` | `/api/v1/auth/ldap/:id/login` | LDAP login |
|
||||
|
||||
### Users
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/users` | List users |
|
||||
| `POST` | `/api/v1/users` | Create user |
|
||||
| `GET` | `/api/v1/users/:id` | Get user |
|
||||
| `PUT` | `/api/v1/users/:id` | Update user |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------- | ----------- |
|
||||
| `GET` | `/api/v1/users` | List users |
|
||||
| `POST` | `/api/v1/users` | Create user |
|
||||
| `GET` | `/api/v1/users/:id` | Get user |
|
||||
| `PUT` | `/api/v1/users/:id` | Update user |
|
||||
| `DELETE` | `/api/v1/users/:id` | Delete user |
|
||||
|
||||
### Organizations
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations` | List organizations |
|
||||
| `POST` | `/api/v1/organizations` | Create organization |
|
||||
| `GET` | `/api/v1/organizations/:id` | Get organization |
|
||||
| `PUT` | `/api/v1/organizations/:id` | Update organization |
|
||||
| `DELETE` | `/api/v1/organizations/:id` | Delete organization |
|
||||
| `GET` | `/api/v1/organizations/:id/members` | List members |
|
||||
| `POST` | `/api/v1/organizations/:id/members` | Add member |
|
||||
| `PUT` | `/api/v1/organizations/:id/members/:userId` | Update member role |
|
||||
| `DELETE` | `/api/v1/organizations/:id/members/:userId` | Remove member |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------------------------- | ------------------- |
|
||||
| `GET` | `/api/v1/organizations` | List organizations |
|
||||
| `POST` | `/api/v1/organizations` | Create organization |
|
||||
| `GET` | `/api/v1/organizations/:id` | Get organization |
|
||||
| `PUT` | `/api/v1/organizations/:id` | Update organization |
|
||||
| `DELETE` | `/api/v1/organizations/:id` | Delete organization |
|
||||
| `GET` | `/api/v1/organizations/:id/members` | List members |
|
||||
| `POST` | `/api/v1/organizations/:id/members` | Add member |
|
||||
| `PUT` | `/api/v1/organizations/:id/members/:userId` | Update member role |
|
||||
| `DELETE` | `/api/v1/organizations/:id/members/:userId` | Remove member |
|
||||
|
||||
### Repositories
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/:orgId/repositories` | List org repos |
|
||||
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repo |
|
||||
| `GET` | `/api/v1/repositories/:id` | Get repo |
|
||||
| `PUT` | `/api/v1/repositories/:id` | Update repo |
|
||||
| `DELETE` | `/api/v1/repositories/:id` | Delete repo |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------------------------- | -------------- |
|
||||
| `GET` | `/api/v1/organizations/:orgId/repositories` | List org repos |
|
||||
| `POST` | `/api/v1/organizations/:orgId/repositories` | Create repo |
|
||||
| `GET` | `/api/v1/repositories/:id` | Get repo |
|
||||
| `PUT` | `/api/v1/repositories/:id` | Update repo |
|
||||
| `DELETE` | `/api/v1/repositories/:id` | Delete repo |
|
||||
|
||||
### Packages
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/packages` | Search packages |
|
||||
| `GET` | `/api/v1/packages/:id` | Get package details |
|
||||
| `GET` | `/api/v1/packages/:id/versions` | List versions |
|
||||
| `DELETE` | `/api/v1/packages/:id` | Delete package |
|
||||
| `DELETE` | `/api/v1/packages/:id/versions/:version` | Delete version |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ---------------------------------------- | ------------------- |
|
||||
| `GET` | `/api/v1/packages` | Search packages |
|
||||
| `GET` | `/api/v1/packages/:id` | Get package details |
|
||||
| `GET` | `/api/v1/packages/:id/versions` | List versions |
|
||||
| `DELETE` | `/api/v1/packages/:id` | Delete package |
|
||||
| `DELETE` | `/api/v1/packages/:id/versions/:version` | Delete version |
|
||||
|
||||
### Tokens
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/tokens` | List your tokens |
|
||||
| `POST` | `/api/v1/tokens` | Create token |
|
||||
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | -------------------- | ---------------- |
|
||||
| `GET` | `/api/v1/tokens` | List your tokens |
|
||||
| `POST` | `/api/v1/tokens` | Create token |
|
||||
| `DELETE` | `/api/v1/tokens/:id` | Revoke token |
|
||||
|
||||
### Audit
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/audit` | Query audit logs |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------- | ---------------- |
|
||||
| `GET` | `/api/v1/audit` | Query audit logs |
|
||||
|
||||
### Admin (Platform Admins Only)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/admin/auth/providers` | List all auth providers |
|
||||
| `POST` | `/api/v1/admin/auth/providers` | Create auth provider |
|
||||
| `GET` | `/api/v1/admin/auth/providers/:id` | Get provider details |
|
||||
| `PUT` | `/api/v1/admin/auth/providers/:id` | Update provider |
|
||||
| `DELETE` | `/api/v1/admin/auth/providers/:id` | Disable provider |
|
||||
| `POST` | `/api/v1/admin/auth/providers/:id/test` | Test provider connection |
|
||||
| `GET` | `/api/v1/admin/auth/settings` | Get platform settings |
|
||||
| `PUT` | `/api/v1/admin/auth/settings` | Update platform settings |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------------- | ------------------------ |
|
||||
| `GET` | `/api/v1/admin/auth/providers` | List all auth providers |
|
||||
| `POST` | `/api/v1/admin/auth/providers` | Create auth provider |
|
||||
| `GET` | `/api/v1/admin/auth/providers/:id` | Get provider details |
|
||||
| `PUT` | `/api/v1/admin/auth/providers/:id` | Update provider |
|
||||
| `DELETE` | `/api/v1/admin/auth/providers/:id` | Disable provider |
|
||||
| `POST` | `/api/v1/admin/auth/providers/:id/test` | Test provider connection |
|
||||
| `GET` | `/api/v1/admin/auth/settings` | Get platform settings |
|
||||
| `PUT` | `/api/v1/admin/auth/settings` | Update platform settings |
|
||||
|
||||
### Health Check
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------------- | ------------------------------------------------ |
|
||||
| `GET` | `/health` or `/healthz` | Returns JSON status of MongoDB, S3, and registry |
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
@@ -239,11 +292,10 @@ All management endpoints live under `/api/v1/`. Authenticated via `Authorization
|
||||
registry/
|
||||
├── mod.ts # Deno entry point
|
||||
├── deno.json # Deno config, tasks, imports
|
||||
├── package.json # Node deps (tsbundle, tsdeno, tswatch)
|
||||
├── npmextra.json # tsdeno compile targets & gitzone config
|
||||
├── install.sh # Binary installer script
|
||||
├── .gitea/workflows/ # CI release pipeline
|
||||
├── scripts/
|
||||
│ └── bundle-ui.ts # Embeds Angular build as base64 TypeScript
|
||||
├── ts/
|
||||
│ ├── registry.ts # StackGalleryRegistry — main orchestrator
|
||||
│ ├── cli.ts # CLI commands (smartcli)
|
||||
@@ -251,6 +303,7 @@ registry/
|
||||
│ ├── api/
|
||||
│ │ ├── router.ts # REST API router with JWT/token auth
|
||||
│ │ └── handlers/ # auth, user, org, repo, package, token, audit, oauth, admin
|
||||
│ ├── opsserver/ # TypedRequest RPC handlers
|
||||
│ ├── models/ # MongoDB models via @push.rocks/smartdata
|
||||
│ │ ├── user.ts, organization.ts, team.ts
|
||||
│ │ ├── repository.ts, package.ts
|
||||
@@ -268,26 +321,25 @@ registry/
|
||||
│ │ ├── auth.provider.ts # IAuthProvider implementation
|
||||
│ │ └── storage.provider.ts # IStorageHooks for quota/audit
|
||||
│ └── interfaces/ # TypeScript interfaces & types
|
||||
└── ui/ # Angular 19 + Tailwind CSS frontend
|
||||
└── src/app/
|
||||
├── features/ # Login, dashboard, orgs, repos, packages, tokens, admin
|
||||
├── core/ # Services, guards, interceptors
|
||||
└── shared/ # Layout, UI components
|
||||
└── 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
|
||||
```
|
||||
|
||||
## 🔧 Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| **Runtime** | Deno 2.x |
|
||||
| **Language** | TypeScript (strict mode) |
|
||||
| **Database** | MongoDB via [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) |
|
||||
| **Storage** | S3 via [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) |
|
||||
| **Registry Core** | [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry) |
|
||||
| **Frontend** | Angular 19 (Signals, Zoneless) + Tailwind CSS |
|
||||
| **Auth** | JWT (HS256) + OAuth/OIDC + LDAP |
|
||||
| **Build** | [`@git.zone/tsdeno`](https://code.foss.global/git.zone/tsdeno) cross-compilation |
|
||||
| **CI/CD** | Gitea Actions → binary releases |
|
||||
| Component | Technology |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Runtime** | Deno 2.x |
|
||||
| **Language** | TypeScript (strict mode) |
|
||||
| **Database** | MongoDB via [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) |
|
||||
| **Storage** | S3 via [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) |
|
||||
| **Registry Core** | [`@push.rocks/smartregistry`](https://code.foss.global/push.rocks/smartregistry) |
|
||||
| **Frontend** | Web Components via [`@design.estate/dees-element`](https://code.foss.global/design.estate/dees-element) + [`@design.estate/dees-catalog`](https://code.foss.global/design.estate/dees-catalog) |
|
||||
| **UI Build** | [`@git.zone/tsbundle`](https://code.foss.global/git.zone/tsbundle) |
|
||||
| **Auth** | JWT (HS256) + OAuth/OIDC + LDAP |
|
||||
| **Build** | [`@git.zone/tsdeno`](https://code.foss.global/git.zone/tsdeno) cross-compilation |
|
||||
| **CI/CD** | Gitea Actions → binary releases |
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
@@ -300,11 +352,8 @@ deno task dev
|
||||
# Watch mode: backend + UI + bundler concurrently
|
||||
pnpm run watch
|
||||
|
||||
# Build Angular UI
|
||||
deno task build
|
||||
|
||||
# Bundle UI into embedded TypeScript
|
||||
deno task bundle-ui
|
||||
# Build UI (web components via tsbundle)
|
||||
deno task build-ui
|
||||
|
||||
# Cross-compile binaries for all platforms
|
||||
deno task compile
|
||||
@@ -326,8 +375,9 @@ deno task test:e2e # E2E tests (requires running server + services)
|
||||
Releases are automated via Gitea Actions (`.gitea/workflows/release.yml`):
|
||||
|
||||
1. Push a `v*` tag
|
||||
2. CI builds the Angular UI and bundles it into TypeScript
|
||||
3. `tsdeno compile` produces binaries for 4 platforms (linux-x64, linux-arm64, macos-x64, macos-arm64)
|
||||
2. CI builds the Web Components UI via `tsbundle`
|
||||
3. `tsdeno compile` produces binaries for 4 platforms (linux-x64, linux-arm64, macos-x64,
|
||||
macos-arm64)
|
||||
4. Binaries + SHA256 checksums are uploaded as Gitea release assets
|
||||
|
||||
Compile targets are configured in `npmextra.json` under `@git.zone/tsdeno`.
|
||||
@@ -337,10 +387,10 @@ Compile targets are configured in `npmextra.json` under `@git.zone/tsdeno`.
|
||||
Artifacts are stored in S3 at:
|
||||
|
||||
```
|
||||
{storagePath}/{protocol}/{orgName}/{packageName}/{version}/{filename}
|
||||
{storagePath}/{protocol}/packages/{packageName}/{version}/{filename}
|
||||
```
|
||||
|
||||
For example: `packages/npm/myorg/mypackage/1.0.0/mypackage-1.0.0.tgz`
|
||||
For example: `packages/npm/packages/@myorg/mypackage/mypackage-1.0.0.tgz`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
@@ -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:
|
||||
mongodb-test:
|
||||
image: mongo:7
|
||||
container_name: stack-gallery-test-mongo
|
||||
ports:
|
||||
- "27117:27017"
|
||||
- '27117:27017'
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: testadmin
|
||||
MONGO_INITDB_ROOT_PASSWORD: testpass
|
||||
tmpfs:
|
||||
- /data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
image: minio/minio:latest
|
||||
container_name: stack-gallery-test-minio
|
||||
ports:
|
||||
- "9100:9000"
|
||||
- "9101:9001"
|
||||
- '9100:9000'
|
||||
- '9101:9001'
|
||||
environment:
|
||||
MINIO_ROOT_USER: testadmin
|
||||
MINIO_ROOT_PASSWORD: testpassword
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
tmpfs:
|
||||
- /data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -6,28 +6,31 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
createOrgWithOwner,
|
||||
createTestApiToken,
|
||||
createTestRepository,
|
||||
createTestUser,
|
||||
getTestRegistry,
|
||||
runCommand,
|
||||
setupTestDb,
|
||||
skipIfMissing,
|
||||
startTestServer,
|
||||
stopTestServer,
|
||||
teardownTestDb,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/npm/@stack-test/demo-package'
|
||||
'../fixtures/npm/@stack-test/demo-package',
|
||||
);
|
||||
|
||||
describe('NPM E2E: Full lifecycle', () => {
|
||||
describe('NPM E2E: Full lifecycle', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
@@ -41,11 +44,13 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
|
||||
await setupTestDb();
|
||||
registryUrl = testConfig.registry.url;
|
||||
await startTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
await stopTestServer();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,6 +59,24 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
|
||||
await cleanupTestDb();
|
||||
|
||||
// Clean up S3 test packages from previous runs
|
||||
try {
|
||||
const bucket = getTestRegistry()?.getSmartBucket();
|
||||
if (bucket) {
|
||||
const b = await bucket.getBucketByName(testConfig.s3.bucket);
|
||||
if (b) {
|
||||
for (const key of [
|
||||
'npm/packages/@stack-test/demo-package/index.json',
|
||||
'npm/packages/@stack-test/demo-package/stack-test-demo-package-1.0.0.tgz',
|
||||
]) {
|
||||
await b.fastRemove({ path: key }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore S3 cleanup errors
|
||||
}
|
||||
|
||||
// Create test user and org
|
||||
const { user } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
@@ -98,7 +121,7 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
const result = await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
apiToken,
|
||||
);
|
||||
|
||||
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
|
||||
@@ -120,20 +143,28 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
const npmrcContent = `//${
|
||||
new URL(registryUrl).host
|
||||
}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
apiToken,
|
||||
);
|
||||
|
||||
// Fetch metadata via npm view
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
[
|
||||
'npm',
|
||||
'view',
|
||||
'@stack-test/demo-package',
|
||||
'--registry',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
],
|
||||
{ env: { npm_config__authToken: apiToken } },
|
||||
);
|
||||
|
||||
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
|
||||
@@ -159,32 +190,36 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
try {
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
const npmrcContent = `//${
|
||||
new URL(registryUrl).host
|
||||
}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
apiToken,
|
||||
);
|
||||
|
||||
// Create package.json in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test-install', version: '1.0.0' })
|
||||
JSON.stringify({ name: 'test-install', version: '1.0.0' }),
|
||||
);
|
||||
|
||||
// Create .npmrc in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, '.npmrc'),
|
||||
`@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`
|
||||
`@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${
|
||||
new URL(registryUrl).host
|
||||
}/-/npm/${testOrgName}/:_authToken=${apiToken}`,
|
||||
);
|
||||
|
||||
// Install
|
||||
const installResult = await clients.npm.install(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
tempDir
|
||||
tempDir,
|
||||
);
|
||||
|
||||
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
|
||||
@@ -213,33 +248,47 @@ describe('NPM E2E: Full lifecycle', () => {
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
const npmrcContent = `//${
|
||||
new URL(registryUrl).host
|
||||
}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
apiToken,
|
||||
);
|
||||
|
||||
// Unpublish
|
||||
const unpublishResult = await clients.npm.unpublish(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
// Unpublish (run from FIXTURE_DIR so .npmrc auth is picked up)
|
||||
const unpublishResult = await runCommand(
|
||||
[
|
||||
'npm',
|
||||
'unpublish',
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
'--registry',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
'--force',
|
||||
],
|
||||
{ cwd: FIXTURE_DIR },
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
unpublishResult.success,
|
||||
true,
|
||||
`npm unpublish failed: ${unpublishResult.stderr}`
|
||||
`npm unpublish failed: ${unpublishResult.stderr}`,
|
||||
);
|
||||
|
||||
// Verify package is gone
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
[
|
||||
'npm',
|
||||
'view',
|
||||
'@stack-test/demo-package',
|
||||
'--registry',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
],
|
||||
{ env: { npm_config__authToken: apiToken } },
|
||||
);
|
||||
|
||||
// Should fail since package was unpublished
|
||||
|
||||
@@ -6,27 +6,29 @@
|
||||
*/
|
||||
|
||||
import { assertEquals } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
createOrgWithOwner,
|
||||
createTestApiToken,
|
||||
createTestRepository,
|
||||
createTestUser,
|
||||
setupTestDb,
|
||||
skipIfMissing,
|
||||
startTestServer,
|
||||
stopTestServer,
|
||||
teardownTestDb,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/oci'
|
||||
'../fixtures/oci',
|
||||
);
|
||||
|
||||
describe('OCI E2E: Full lifecycle', () => {
|
||||
describe('OCI E2E: Full lifecycle', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
@@ -41,11 +43,13 @@ describe('OCI E2E: Full lifecycle', () => {
|
||||
await setupTestDb();
|
||||
const url = new URL(testConfig.registry.url);
|
||||
registryHost = url.host;
|
||||
await startTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
await stopTestServer();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,7 +89,7 @@ describe('OCI E2E: Full lifecycle', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const imageName = `${registryHost}/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
@@ -112,7 +116,7 @@ describe('OCI E2E: Full lifecycle', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const imageName = `${registryHost}/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
@@ -138,7 +142,7 @@ describe('OCI E2E: Full lifecycle', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/multi:1.0.0`;
|
||||
const imageName = `${registryHost}/${testOrgName}/multi:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.multi-layer');
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "stacktest/demo-package",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"version": "1.0.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Stack.Gallery Test",
|
||||
"email": "test@stack.gallery"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"StackTest\\DemoPackage\\": "src/"
|
||||
}
|
||||
"name": "stacktest/demo-package",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"version": "1.0.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Stack.Gallery Test",
|
||||
"email": "test@stack.gallery"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"StackTest\\DemoPackage\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.stacktest</groupId>
|
||||
<artifactId>demo-artifact</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>Stack.Gallery Demo Artifact</name>
|
||||
<description>Demo Maven artifact for e2e tests</description>
|
||||
<url>https://github.com/stack-gallery/demo-artifact</url>
|
||||
<project
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.stacktest</groupId>
|
||||
<artifactId>demo-artifact</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>Stack.Gallery Demo Artifact</name>
|
||||
<description>Demo Maven artifact for e2e tests</description>
|
||||
<url>https://github.com/stack-gallery/demo-artifact</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
<url>https://opensource.org/licenses/MIT</url>
|
||||
</license>
|
||||
</licenses>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
<url>https://opensource.org/licenses/MIT</url>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<name>Stack.Gallery Test</name>
|
||||
<email>test@stack.gallery</email>
|
||||
</developer>
|
||||
</developers>
|
||||
<developers>
|
||||
<developer>
|
||||
<name>Stack.Gallery Test</name>
|
||||
<email>test@stack.gallery</email>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
module.exports = {
|
||||
name: 'demo-package',
|
||||
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 { AuthService } from '../../ts/services/auth.service.ts';
|
||||
import { TokenService } from '../../ts/services/token.service.ts';
|
||||
import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts';
|
||||
import type {
|
||||
ITokenScope,
|
||||
TRegistryProtocol,
|
||||
TUserStatus,
|
||||
} from '../../ts/interfaces/auth.interfaces.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
const TEST_PASSWORD = 'TestPassword123!';
|
||||
@@ -25,7 +29,7 @@ export interface ICreateTestUserOptions {
|
||||
* Create a test user with sensible defaults
|
||||
*/
|
||||
export async function createTestUser(
|
||||
overrides: ICreateTestUserOptions = {}
|
||||
overrides: ICreateTestUserOptions = {},
|
||||
): Promise<{ user: User; password: string }> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
const password = overrides.password || TEST_PASSWORD;
|
||||
@@ -61,7 +65,7 @@ export async function createAdminUser(): Promise<{ user: User; password: string
|
||||
*/
|
||||
export async function loginUser(
|
||||
email: string,
|
||||
password: string
|
||||
password: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
||||
const authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
@@ -96,7 +100,7 @@ export interface ICreateTestApiTokenOptions {
|
||||
* Create test API token
|
||||
*/
|
||||
export async function createTestApiToken(
|
||||
options: ICreateTestApiTokenOptions
|
||||
options: ICreateTestApiTokenOptions,
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
const tokenService = new TokenService();
|
||||
|
||||
@@ -127,7 +131,7 @@ export function createAuthHeader(token: string): { Authorization: string } {
|
||||
*/
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
): { Authorization: string } {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
|
||||
@@ -15,7 +15,7 @@ let isConnected = false;
|
||||
|
||||
// We need to patch the global db export since models reference it
|
||||
// This is done by re-initializing with the test config
|
||||
import { initDb, closeDb } from '../../ts/models/db.ts';
|
||||
import { closeDb, initDb } from '../../ts/models/db.ts';
|
||||
|
||||
/**
|
||||
* Initialize test database with unique name per test run
|
||||
|
||||
@@ -11,10 +11,10 @@ import { Package } from '../../ts/models/package.ts';
|
||||
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRegistryProtocol,
|
||||
TRepositoryRole,
|
||||
TRepositoryVisibility,
|
||||
TRegistryProtocol,
|
||||
TTeamRole,
|
||||
} from '../../ts/interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface ICreateTestOrganizationOptions {
|
||||
@@ -29,7 +29,7 @@ export interface ICreateTestOrganizationOptions {
|
||||
* Create test organization
|
||||
*/
|
||||
export async function createTestOrganization(
|
||||
options: ICreateTestOrganizationOptions
|
||||
options: ICreateTestOrganizationOptions,
|
||||
): Promise<Organization> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function createTestOrganization(
|
||||
*/
|
||||
export async function createOrgWithOwner(
|
||||
ownerId: string,
|
||||
orgOptions?: Partial<ICreateTestOrganizationOptions>
|
||||
orgOptions?: Partial<ICreateTestOrganizationOptions>,
|
||||
): Promise<{
|
||||
organization: Organization;
|
||||
membership: OrganizationMember;
|
||||
@@ -82,7 +82,7 @@ export async function addOrgMember(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
role: TOrganizationRole = 'member',
|
||||
invitedBy?: string
|
||||
invitedBy?: string,
|
||||
): Promise<OrganizationMember> {
|
||||
const membership = await OrganizationMember.addMember({
|
||||
organizationId,
|
||||
@@ -113,7 +113,7 @@ export interface ICreateTestRepositoryOptions {
|
||||
* Create test repository
|
||||
*/
|
||||
export async function createTestRepository(
|
||||
options: ICreateTestRepositoryOptions
|
||||
options: ICreateTestRepositoryOptions,
|
||||
): Promise<Repository> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function createTestTeam(options: ICreateTestTeamOptions): Promise<T
|
||||
export async function addTeamMember(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: TTeamRole = 'member'
|
||||
role: TTeamRole = 'member',
|
||||
): Promise<TeamMember> {
|
||||
const member = new TeamMember();
|
||||
member.id = await TeamMember.getNewId();
|
||||
@@ -176,7 +176,7 @@ export interface IGrantRepoPermissionOptions {
|
||||
* Grant repository permission
|
||||
*/
|
||||
export async function grantRepoPermission(
|
||||
options: IGrantRepoPermissionOptions
|
||||
options: IGrantRepoPermissionOptions,
|
||||
): Promise<RepositoryPermission> {
|
||||
const perm = new RepositoryPermission();
|
||||
perm.id = await RepositoryPermission.getNewId();
|
||||
|
||||
@@ -75,7 +75,7 @@ export const del = (path: string, headers?: Record<string, string>) =>
|
||||
export function assertStatus(response: ITestResponse, expected: number): void {
|
||||
if (response.status !== expected) {
|
||||
throw new Error(
|
||||
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function assertBodyHas(response: ITestResponse, keys: string[]): void {
|
||||
export function assertSuccess(response: ITestResponse): void {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,82 +4,85 @@
|
||||
|
||||
// Database helpers
|
||||
export {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
teardownTestDb,
|
||||
clearCollections,
|
||||
getTestDbName,
|
||||
getTestDb,
|
||||
getTestDbName,
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
} from './db.helper.ts';
|
||||
|
||||
// Auth helpers
|
||||
export {
|
||||
createTestUser,
|
||||
createAdminUser,
|
||||
loginUser,
|
||||
createTestApiToken,
|
||||
createAuthHeader,
|
||||
createBasicAuthHeader,
|
||||
createTestApiToken,
|
||||
createTestUser,
|
||||
getTestPassword,
|
||||
type ICreateTestUserOptions,
|
||||
type ICreateTestApiTokenOptions,
|
||||
type ICreateTestUserOptions,
|
||||
loginUser,
|
||||
} from './auth.helper.ts';
|
||||
|
||||
// Factory helpers
|
||||
export {
|
||||
createTestOrganization,
|
||||
createOrgWithOwner,
|
||||
addOrgMember,
|
||||
addTeamMember,
|
||||
createFullTestScenario,
|
||||
createOrgWithOwner,
|
||||
createTestOrganization,
|
||||
createTestPackage,
|
||||
createTestRepository,
|
||||
createTestTeam,
|
||||
addTeamMember,
|
||||
grantRepoPermission,
|
||||
createTestPackage,
|
||||
createFullTestScenario,
|
||||
type ICreateTestOrganizationOptions,
|
||||
type ICreateTestPackageOptions,
|
||||
type ICreateTestRepositoryOptions,
|
||||
type ICreateTestTeamOptions,
|
||||
type IGrantRepoPermissionOptions,
|
||||
type ICreateTestPackageOptions,
|
||||
} from './factory.helper.ts';
|
||||
|
||||
// HTTP helpers
|
||||
export {
|
||||
testRequest,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
patch,
|
||||
del,
|
||||
assertStatus,
|
||||
assertBodyHas,
|
||||
assertSuccess,
|
||||
assertError,
|
||||
assertStatus,
|
||||
assertSuccess,
|
||||
del,
|
||||
get,
|
||||
type ITestRequest,
|
||||
type ITestResponse,
|
||||
patch,
|
||||
post,
|
||||
put,
|
||||
testRequest,
|
||||
} from './http.helper.ts';
|
||||
|
||||
// Subprocess helpers
|
||||
export {
|
||||
runCommand,
|
||||
commandExists,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
type ICommandResult,
|
||||
commandExists,
|
||||
type ICommandOptions,
|
||||
type ICommandResult,
|
||||
runCommand,
|
||||
skipIfMissing,
|
||||
} from './subprocess.helper.ts';
|
||||
|
||||
// Storage helpers
|
||||
export {
|
||||
setupTestStorage,
|
||||
checkStorageAvailable,
|
||||
objectExists,
|
||||
listObjects,
|
||||
cleanupTestStorage,
|
||||
deleteObject,
|
||||
deletePrefix,
|
||||
cleanupTestStorage,
|
||||
isStorageAvailable,
|
||||
listObjects,
|
||||
objectExists,
|
||||
setupTestStorage,
|
||||
} from './storage.helper.ts';
|
||||
|
||||
// Server helpers
|
||||
export { getTestRegistry, startTestServer, stopTestServer } from './server.helper.ts';
|
||||
|
||||
// Re-export test config
|
||||
export { testConfig, getTestConfig } from '../test.config.ts';
|
||||
export { getTestConfig, testConfig } from '../test.config.ts';
|
||||
|
||||
49
test/helpers/server.helper.ts
Normal file
49
test/helpers/server.helper.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Server helper - starts/stops the registry server for integration and E2E tests
|
||||
*/
|
||||
|
||||
import { StackGalleryRegistry } from '../../ts/registry.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
let registry: StackGalleryRegistry | null = null;
|
||||
|
||||
/**
|
||||
* Start the registry server for testing
|
||||
*/
|
||||
export async function startTestServer(): Promise<StackGalleryRegistry> {
|
||||
if (registry) return registry;
|
||||
|
||||
// Set JWT_SECRET env var so ApiRouter's AuthService uses the same secret
|
||||
Deno.env.set('JWT_SECRET', testConfig.jwt.secret);
|
||||
|
||||
registry = new StackGalleryRegistry({
|
||||
mongoUrl: testConfig.mongodb.url,
|
||||
mongoDb: testConfig.mongodb.name,
|
||||
s3Endpoint: testConfig.s3.endpoint,
|
||||
s3AccessKey: testConfig.s3.accessKey,
|
||||
s3SecretKey: testConfig.s3.secretKey,
|
||||
s3Bucket: testConfig.s3.bucket,
|
||||
s3Region: testConfig.s3.region,
|
||||
port: testConfig.registry.port,
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
});
|
||||
await registry.start();
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the registry server
|
||||
*/
|
||||
export async function stopTestServer(): Promise<void> {
|
||||
if (registry) {
|
||||
await registry.stop();
|
||||
registry = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current registry instance
|
||||
*/
|
||||
export function getTestRegistry(): StackGalleryRegistry | null {
|
||||
return registry;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export interface ICommandOptions {
|
||||
*/
|
||||
export async function runCommand(
|
||||
cmd: string[],
|
||||
options: ICommandOptions = {}
|
||||
options: ICommandOptions = {},
|
||||
): Promise<ICommandResult> {
|
||||
const { cwd, env, timeout = 60000, stdin } = options;
|
||||
|
||||
@@ -98,7 +98,7 @@ export const clients = {
|
||||
docker: {
|
||||
check: () => commandExists('docker'),
|
||||
build: (dockerfile: string, tag: string, context: string) =>
|
||||
runCommand(['docker', 'build', '-f', dockerfile, '-t', tag, context]),
|
||||
runCommand(['docker', 'build', '--load', '-f', dockerfile, '-t', tag, context]),
|
||||
push: (image: string) => runCommand(['docker', 'push', image]),
|
||||
pull: (image: string) => runCommand(['docker', 'pull', image]),
|
||||
rmi: (image: string, force = false) =>
|
||||
@@ -116,7 +116,7 @@ export const clients = {
|
||||
publish: (dir: string, registry: string, token: string) =>
|
||||
runCommand(
|
||||
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
|
||||
{ cwd: dir }
|
||||
{ cwd: dir },
|
||||
),
|
||||
yank: (crate: string, version: string, token: string) =>
|
||||
runCommand([
|
||||
@@ -164,7 +164,7 @@ export const clients = {
|
||||
'--repository',
|
||||
JSON.stringify({ type: 'composer', url: repository }),
|
||||
],
|
||||
{ cwd: dir }
|
||||
{ cwd: dir },
|
||||
),
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ export const clients = {
|
||||
`-Dusername=${username}`,
|
||||
`-Dpassword=${password}`,
|
||||
],
|
||||
{ cwd: dir }
|
||||
{ cwd: dir },
|
||||
),
|
||||
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
|
||||
},
|
||||
|
||||
@@ -4,25 +4,29 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
post,
|
||||
get,
|
||||
assertStatus,
|
||||
cleanupTestDb,
|
||||
createAuthHeader,
|
||||
createTestUser,
|
||||
get,
|
||||
post,
|
||||
setupTestDb,
|
||||
startTestServer,
|
||||
stopTestServer,
|
||||
teardownTestDb,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Auth API Integration', () => {
|
||||
describe('Auth API Integration', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
await startTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
await stopTestServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -56,7 +60,7 @@ describe('Auth API Integration', () => {
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'INVALID_CREDENTIALS');
|
||||
assertEquals(body.code, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should return 401 for inactive user', async () => {
|
||||
@@ -72,7 +76,7 @@ describe('Auth API Integration', () => {
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'ACCOUNT_INACTIVE');
|
||||
assertEquals(body.code, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +130,7 @@ describe('Auth API Integration', () => {
|
||||
// Get current user
|
||||
const meResponse = await get(
|
||||
'/api/v1/auth/me',
|
||||
createAuthHeader(loginBody.accessToken as string)
|
||||
createAuthHeader(loginBody.accessToken as string),
|
||||
);
|
||||
|
||||
assertStatus(meResponse, 200);
|
||||
@@ -155,9 +159,14 @@ describe('Auth API Integration', () => {
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
const token = loginBody.accessToken as string;
|
||||
const sessionId = loginBody.sessionId as string;
|
||||
|
||||
// Logout
|
||||
const logoutResponse = await post('/api/v1/auth/logout', {}, createAuthHeader(token));
|
||||
// Logout with sessionId
|
||||
const logoutResponse = await post(
|
||||
'/api/v1/auth/logout',
|
||||
{ sessionId },
|
||||
createAuthHeader(token),
|
||||
);
|
||||
|
||||
assertStatus(logoutResponse, 200);
|
||||
|
||||
|
||||
@@ -4,31 +4,35 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
assertStatus,
|
||||
cleanupTestDb,
|
||||
createAuthHeader,
|
||||
createTestUser,
|
||||
del,
|
||||
get,
|
||||
loginUser,
|
||||
post,
|
||||
get,
|
||||
put,
|
||||
del,
|
||||
assertStatus,
|
||||
createAuthHeader,
|
||||
setupTestDb,
|
||||
startTestServer,
|
||||
stopTestServer,
|
||||
teardownTestDb,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Organization API Integration', () => {
|
||||
describe('Organization API Integration', { sanitizeResources: false, sanitizeOps: false }, () => {
|
||||
let accessToken: string;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
await startTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
await stopTestServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -48,7 +52,7 @@ describe('Organization API Integration', () => {
|
||||
displayName: 'My Organization',
|
||||
description: 'A test organization',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
@@ -64,7 +68,7 @@ describe('Organization API Integration', () => {
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
@@ -76,13 +80,13 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'First' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'Second' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 409);
|
||||
@@ -92,7 +96,7 @@ describe('Organization API Integration', () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: '.invalid', displayName: 'Invalid' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 400);
|
||||
@@ -105,19 +109,19 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org1', displayName: 'Org 1' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org2', displayName: 'Org 2' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 2, true);
|
||||
const body = response.body as { organizations: Record<string, unknown>[] };
|
||||
assertEquals(body.organizations.length >= 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +130,7 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'get-me', displayName: 'Get Me' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken));
|
||||
@@ -139,7 +143,7 @@ describe('Organization API Integration', () => {
|
||||
it('should return 404 for non-existent org', async () => {
|
||||
const response = await get(
|
||||
'/api/v1/organizations/non-existent',
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 404);
|
||||
@@ -151,13 +155,13 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'update-me', displayName: 'Original' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await put(
|
||||
'/api/v1/organizations/update-me',
|
||||
{ displayName: 'Updated', description: 'New description' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
@@ -172,7 +176,7 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'delete-me', displayName: 'Delete Me' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
|
||||
@@ -182,7 +186,7 @@ describe('Organization API Integration', () => {
|
||||
// Verify deleted
|
||||
const getResponse = await get(
|
||||
'/api/v1/organizations/delete-me',
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
assertStatus(getResponse, 404);
|
||||
});
|
||||
@@ -193,17 +197,17 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'members-org', displayName: 'Members Org' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await get(
|
||||
'/api/v1/organizations/members-org/members',
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 1, true); // At least the creator
|
||||
const body = response.body as { members: Record<string, unknown>[] };
|
||||
assertEquals(body.members.length >= 1, true); // At least the creator
|
||||
});
|
||||
|
||||
it('should add member to organization', async () => {
|
||||
@@ -213,13 +217,13 @@ describe('Organization API Integration', () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'add-member-org', displayName: 'Add Member Org' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations/add-member-org/members',
|
||||
{ userId: newUser.id, role: 'member' },
|
||||
createAuthHeader(accessToken)
|
||||
createAuthHeader(accessToken),
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Qenv } from '@push.rocks/qenv';
|
||||
|
||||
const testQenv = new Qenv('./', '.nogit/', false);
|
||||
|
||||
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL')
|
||||
|| 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
|
||||
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME')
|
||||
|| 'test-registry';
|
||||
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL') ||
|
||||
'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
|
||||
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') ||
|
||||
'test-registry';
|
||||
|
||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT') || 'localhost';
|
||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT') || '9100';
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('ApiToken Model', () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { Organization } from '../../../ts/models/organization.ts';
|
||||
|
||||
describe('Organization Model', () => {
|
||||
@@ -73,7 +73,7 @@ describe('Organization Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
'lowercase alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Organization Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
'lowercase alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('Organization Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
'lowercase alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('Organization Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
'lowercase alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestUser,
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Package } from '../../../ts/models/package.ts';
|
||||
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
cleanupTestDb,
|
||||
createOrgWithOwner,
|
||||
createTestUser,
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Repository } from '../../../ts/models/repository.ts';
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Repository Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'already exists'
|
||||
'already exists',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('Repository Model', () => {
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
'lowercase alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
|
||||
describe('Session Model', () => {
|
||||
@@ -70,9 +70,21 @@ describe('Session Model', () => {
|
||||
|
||||
describe('getUserSessions', () => {
|
||||
it('should return all valid sessions for user', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 1',
|
||||
ipAddress: '1.1.1.1',
|
||||
});
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 2',
|
||||
ipAddress: '2.2.2.2',
|
||||
});
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 3',
|
||||
ipAddress: '3.3.3.3',
|
||||
});
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 3);
|
||||
@@ -110,9 +122,21 @@ describe('Session Model', () => {
|
||||
|
||||
describe('invalidateAllUserSessions', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 1',
|
||||
ipAddress: '1.1.1.1',
|
||||
});
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 2',
|
||||
ipAddress: '2.2.2.2',
|
||||
});
|
||||
await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Agent 3',
|
||||
ipAddress: '3.3.3.3',
|
||||
});
|
||||
|
||||
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
|
||||
assertEquals(count, 3);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { User } from '../../../ts/models/user.ts';
|
||||
|
||||
describe('User Model', () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { AuthService } from '../../../ts/services/auth.service.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
import { testConfig } from '../../test.config.ts';
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
|
||||
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
|
||||
import { TokenService } from '../../../ts/services/token.service.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('TokenService', () => {
|
||||
assertExists(result.rawToken);
|
||||
assertExists(result.token);
|
||||
|
||||
// Check token format: srg_{prefix}_{random}
|
||||
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
|
||||
// Check token format: srg_ + 64 hex chars
|
||||
assertMatch(result.rawToken, /^srg_[a-f0-9]{64}$/);
|
||||
assertEquals(result.token.name, 'test-token');
|
||||
assertEquals(result.token.protocols.includes('npm'), true);
|
||||
assertEquals(result.token.protocols.includes('oci'), true);
|
||||
@@ -111,13 +111,19 @@ describe('TokenService', () => {
|
||||
it('should reject invalid token format', async () => {
|
||||
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
assertEquals(validation.valid, false);
|
||||
assertEquals(validation.errorCode, 'INVALID_TOKEN_FORMAT');
|
||||
});
|
||||
|
||||
it('should reject non-existent token', async () => {
|
||||
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
|
||||
// Must match srg_ prefix + 64 hex chars = 68 total
|
||||
const validation = await tokenService.validateToken(
|
||||
'srg_0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'127.0.0.1',
|
||||
);
|
||||
|
||||
assertEquals(validation, null);
|
||||
assertEquals(validation.valid, false);
|
||||
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should reject revoked token', async () => {
|
||||
@@ -132,7 +138,9 @@ describe('TokenService', () => {
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
assertEquals(validation.valid, false);
|
||||
// findByHash excludes revoked tokens, so the token is not found
|
||||
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
@@ -150,7 +158,8 @@ describe('TokenService', () => {
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
assertEquals(validation.valid, false);
|
||||
assertEquals(validation.errorCode, 'TOKEN_EXPIRED');
|
||||
});
|
||||
|
||||
it('should record usage on validation', async () => {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.4.1',
|
||||
version: '1.8.5',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
@@ -109,11 +109,13 @@ export class AdminAuthApi {
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
createdById: ctx.actor!.userId!,
|
||||
});
|
||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
|
||||
const encryptedPassword = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name: body.name,
|
||||
@@ -124,7 +126,7 @@ export class AdminAuthApi {
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
createdById: ctx.actor!.userId!,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
@@ -138,11 +140,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_CREATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_created',
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
@@ -229,7 +230,7 @@ export class AdminAuthApi {
|
||||
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
body.oauthConfig.clientSecretEncrypted
|
||||
body.oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ export class AdminAuthApi {
|
||||
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted
|
||||
body.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,11 +271,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_updated',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
@@ -321,11 +321,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_DELETED', 'system', {
|
||||
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_disabled',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
@@ -360,11 +359,10 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
|
||||
resourceId: id,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
action: 'auth_provider_tested',
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
@@ -433,12 +431,9 @@ export class AdminAuthApi {
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'platform_settings_updated',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,7 +26,9 @@ export class AuditApi {
|
||||
// Parse query parameters
|
||||
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
||||
const repositoryId = ctx.url.searchParams.get('repositoryId') || undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as TAuditResourceType | undefined;
|
||||
const resourceType = ctx.url.searchParams.get('resourceType') as
|
||||
| TAuditResourceType
|
||||
| undefined;
|
||||
const actionsParam = ctx.url.searchParams.get('actions');
|
||||
const actions = actionsParam ? (actionsParam.split(',') as TAuditAction[]) : undefined;
|
||||
const success = ctx.url.searchParams.has('success')
|
||||
@@ -54,7 +56,7 @@ export class AuditApi {
|
||||
// Check if user can manage this org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
// User can only see their own actions in this org
|
||||
|
||||
@@ -93,7 +93,7 @@ export class OAuthApi {
|
||||
|
||||
const result = await externalAuthService.handleOAuthCallback(
|
||||
{ code, state },
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -39,7 +39,13 @@ export class OrganizationApi {
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
const memberships = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||||
const orgs: Organization[] = [];
|
||||
for (const m of memberships) {
|
||||
const org = await Organization.findById(m.organizationId);
|
||||
if (org) orgs.push(org);
|
||||
}
|
||||
organizations = orgs;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -155,8 +161,8 @@ export class OrganizationApi {
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = ctx.actor.userId;
|
||||
membership.role = 'owner';
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
membership.invitedBy = ctx.actor.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
@@ -202,7 +208,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission using org.id
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -310,16 +319,16 @@ export class OrganizationApi {
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
addedAt: m.addedAt,
|
||||
addedAt: m.joinedAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -350,7 +359,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -384,8 +396,8 @@ export class OrganizationApi {
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
membership.addedAt = new Date();
|
||||
membership.invitedBy = ctx.actor.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
@@ -398,7 +410,7 @@ export class OrganizationApi {
|
||||
body: {
|
||||
userId: membership.userId,
|
||||
role: membership.role,
|
||||
addedAt: membership.addedAt,
|
||||
addedAt: membership.joinedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -425,7 +437,10 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
@@ -486,7 +501,10 @@ export class OrganizationApi {
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
@@ -106,7 +106,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
@@ -161,7 +161,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read'
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
@@ -174,7 +174,7 @@ export class PackageApi {
|
||||
publishedAt: data.publishedAt,
|
||||
size: data.size,
|
||||
downloads: data.downloads,
|
||||
checksum: data.checksum,
|
||||
checksum: data.metadata?.checksum,
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -213,7 +213,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
@@ -267,7 +267,7 @@ export class PackageApi {
|
||||
ctx.actor.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete'
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import { Repository, Organization } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
import { Organization, Repository } from '../../models/index.ts';
|
||||
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class RepositoryApi {
|
||||
private permissionService: PermissionService;
|
||||
@@ -26,10 +26,9 @@ export class RepositoryApi {
|
||||
const { orgId } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get accessible repositories
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
ctx.actor.userId,
|
||||
orgId
|
||||
orgId,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -38,9 +37,9 @@ export class RepositoryApi {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
createdAt: repo.createdAt,
|
||||
@@ -84,11 +83,10 @@ export class RepositoryApi {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes,
|
||||
createdAt: repo.createdAt,
|
||||
@@ -118,17 +116,25 @@ export class RepositoryApi {
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, displayName, description, protocols, isPublic, settings } = body;
|
||||
const { name, description, protocol, visibility } = body as {
|
||||
name: string;
|
||||
description?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return { status: 400, body: { error: 'Repository name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
body: {
|
||||
error:
|
||||
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,30 +144,15 @@ export class RepositoryApi {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check if name is taken in this org
|
||||
const existing = await Repository.findByName(orgId, name);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = new Repository();
|
||||
repo.id = await Repository.getNewId();
|
||||
repo.organizationId = orgId;
|
||||
repo.name = name;
|
||||
repo.displayName = displayName || name;
|
||||
repo.description = description;
|
||||
repo.protocols = protocols || ['npm'];
|
||||
repo.isPublic = isPublic ?? false;
|
||||
repo.settings = settings || {
|
||||
allowOverwrite: false,
|
||||
immutableTags: false,
|
||||
retentionDays: 0,
|
||||
};
|
||||
repo.createdAt = new Date();
|
||||
repo.createdById = ctx.actor.userId;
|
||||
|
||||
await repo.save();
|
||||
// Create repository using the model's factory method
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: orgId,
|
||||
name,
|
||||
description,
|
||||
protocol: protocol || 'npm',
|
||||
visibility: visibility || 'private',
|
||||
createdById: ctx.actor.userId,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
@@ -177,9 +168,9 @@ export class RepositoryApi {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
createdAt: repo.createdAt,
|
||||
},
|
||||
@@ -210,20 +201,20 @@ export class RepositoryApi {
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, protocols, isPublic, settings } = body;
|
||||
const { description, visibility } = body as {
|
||||
description?: string;
|
||||
visibility?: TRepositoryVisibility;
|
||||
};
|
||||
|
||||
if (displayName !== undefined) repo.displayName = displayName;
|
||||
if (description !== undefined) repo.description = description;
|
||||
if (protocols !== undefined) repo.protocols = protocols;
|
||||
if (isPublic !== undefined) repo.isPublic = isPublic;
|
||||
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
|
||||
if (visibility !== undefined) repo.visibility = visibility;
|
||||
|
||||
await repo.save();
|
||||
|
||||
@@ -232,11 +223,10 @@ export class RepositoryApi {
|
||||
body: {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName,
|
||||
description: repo.description,
|
||||
protocols: repo.protocols,
|
||||
protocol: repo.protocol,
|
||||
visibility: repo.visibility,
|
||||
isPublic: repo.isPublic,
|
||||
settings: repo.settings,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -265,7 +255,7 @@ export class RepositoryApi {
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
ctx.actor.userId,
|
||||
repo.organizationId,
|
||||
id
|
||||
id,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
|
||||
@@ -33,7 +33,10 @@ export class TokenApi {
|
||||
let tokens;
|
||||
if (organizationId) {
|
||||
// Check if user can manage org
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
|
||||
}
|
||||
@@ -119,7 +122,10 @@ export class TokenApi {
|
||||
|
||||
// If creating org token, verify permission
|
||||
if (organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
|
||||
}
|
||||
@@ -181,7 +187,7 @@ export class TokenApi {
|
||||
if (anyToken?.organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
ctx.actor.userId,
|
||||
anyToken.organizationId
|
||||
anyToken.organizationId,
|
||||
);
|
||||
if (canManage) {
|
||||
token = anyToken;
|
||||
|
||||
@@ -137,8 +137,8 @@ export class UserApi {
|
||||
user.username = username;
|
||||
user.passwordHash = passwordHash;
|
||||
user.displayName = displayName || username;
|
||||
user.isSystemAdmin = isSystemAdmin || false;
|
||||
user.isActive = true;
|
||||
user.isPlatformAdmin = isSystemAdmin || false;
|
||||
user.status = 'active';
|
||||
user.createdAt = new Date();
|
||||
|
||||
await user.save();
|
||||
@@ -189,8 +189,8 @@ export class UserApi {
|
||||
|
||||
// Only admins can change these
|
||||
if (ctx.actor.user?.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.isActive = isActive;
|
||||
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
|
||||
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
|
||||
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
@@ -245,7 +245,7 @@ export class UserApi {
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.isActive = false;
|
||||
user.status = 'suspended';
|
||||
await user.save();
|
||||
|
||||
return {
|
||||
|
||||
@@ -104,24 +104,56 @@ export class ApiRouter {
|
||||
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
|
||||
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
|
||||
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/organizations/:id/members',
|
||||
(ctx) => this.organizationApi.listMembers(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/organizations/:id/members',
|
||||
(ctx) => this.organizationApi.addMember(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/organizations/:id/members/:userId',
|
||||
(ctx) => this.organizationApi.updateMember(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/organizations/:id/members/:userId',
|
||||
(ctx) => this.organizationApi.removeMember(ctx),
|
||||
);
|
||||
|
||||
// Repository routes
|
||||
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/organizations/:orgId/repositories',
|
||||
(ctx) => this.repositoryApi.list(ctx),
|
||||
);
|
||||
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
|
||||
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/organizations/:orgId/repositories',
|
||||
(ctx) => this.repositoryApi.create(ctx),
|
||||
);
|
||||
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
|
||||
|
||||
// Package routes
|
||||
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
|
||||
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/packages/:id/versions',
|
||||
(ctx) => this.packageApi.listVersions(ctx),
|
||||
);
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/packages/:id/versions/:version',
|
||||
(ctx) => this.packageApi.deleteVersion(ctx),
|
||||
);
|
||||
|
||||
// Token routes
|
||||
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
|
||||
@@ -138,14 +170,46 @@ export class ApiRouter {
|
||||
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
|
||||
|
||||
// Admin auth routes (platform admin only)
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/providers',
|
||||
(ctx) => this.adminAuthApi.listProviders(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/admin/auth/providers',
|
||||
(ctx) => this.adminAuthApi.createProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.getProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.updateProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'DELETE',
|
||||
'/api/v1/admin/auth/providers/:id',
|
||||
(ctx) => this.adminAuthApi.deleteProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'POST',
|
||||
'/api/v1/admin/auth/providers/:id/test',
|
||||
(ctx) => this.adminAuthApi.testProvider(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'GET',
|
||||
'/api/v1/admin/auth/settings',
|
||||
(ctx) => this.adminAuthApi.getSettings(ctx),
|
||||
);
|
||||
this.addRoute(
|
||||
'PUT',
|
||||
'/api/v1/admin/auth/settings',
|
||||
(ctx) => this.adminAuthApi.updateSettings(ctx),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
12
ts/cli.ts
12
ts/cli.ts
@@ -3,9 +3,13 @@
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { StackGalleryRegistry, createRegistryFromEnv, createRegistryFromEnvFile } from './registry.ts';
|
||||
import {
|
||||
createRegistryFromEnv,
|
||||
createRegistryFromEnvFile,
|
||||
StackGalleryRegistry,
|
||||
} from './registry.ts';
|
||||
import { initDb } from './models/db.ts';
|
||||
import { User, Organization, OrganizationMember, Repository } from './models/index.ts';
|
||||
import { Organization, OrganizationMember, Repository, User } from './models/index.ts';
|
||||
import { AuthService } from './services/auth.service.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
@@ -21,9 +25,7 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
|
||||
// Use env file in ephemeral/dev mode, otherwise use environment variables
|
||||
const registry = isEphemeral
|
||||
? await createRegistryFromEnvFile()
|
||||
: createRegistryFromEnv();
|
||||
const registry = isEphemeral ? await createRegistryFromEnvFile() : createRegistryFromEnv();
|
||||
await registry.start();
|
||||
|
||||
// Handle shutdown gracefully
|
||||
|
||||
@@ -51,6 +51,13 @@ export type TAuditAction =
|
||||
| 'PACKAGE_PULLED'
|
||||
| 'PACKAGE_DELETED'
|
||||
| 'PACKAGE_DEPRECATED'
|
||||
// Auth Provider Management
|
||||
| 'AUTH_PROVIDER_CREATED'
|
||||
| 'AUTH_PROVIDER_UPDATED'
|
||||
| 'AUTH_PROVIDER_DELETED'
|
||||
| 'AUTH_PROVIDER_TESTED'
|
||||
// Platform Settings
|
||||
| 'PLATFORM_SETTINGS_UPDATED'
|
||||
// Security Events
|
||||
| 'SECURITY_SCAN_COMPLETED'
|
||||
| 'SECURITY_VULNERABILITY_FOUND'
|
||||
@@ -65,6 +72,8 @@ export type TAuditResourceType =
|
||||
| 'package'
|
||||
| 'api_token'
|
||||
| 'session'
|
||||
| 'auth_provider'
|
||||
| 'platform_settings'
|
||||
| 'system';
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -103,7 +103,14 @@ export interface ITeamMember {
|
||||
|
||||
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
|
||||
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
|
||||
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
|
||||
export type TRegistryProtocol =
|
||||
| 'oci'
|
||||
| 'npm'
|
||||
| 'maven'
|
||||
| 'cargo'
|
||||
| 'composer'
|
||||
| 'pypi'
|
||||
| 'rubygems';
|
||||
|
||||
export interface IRepository {
|
||||
id: string;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { IApiToken, ITokenScope, TRegistryProtocol } from '../interfaces/au
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken> implements IApiToken {
|
||||
export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToken>
|
||||
implements IApiToken {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -150,7 +151,7 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
action?: string,
|
||||
): boolean {
|
||||
for (const scope of this.scopes) {
|
||||
// Check protocol
|
||||
@@ -163,7 +164,9 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
if (scope.repositoryId && scope.repositoryId !== repositoryId) continue;
|
||||
|
||||
// Check action
|
||||
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) continue;
|
||||
if (action && !scope.actions.includes('*') && !scope.actions.includes(action as never)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IAuditLog, TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
|
||||
import type {
|
||||
IAuditLog,
|
||||
TAuditAction,
|
||||
TAuditResourceType,
|
||||
} from '../interfaces/audit.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog> implements IAuditLog {
|
||||
export class AuditLog extends plugins.smartdata.SmartDataDbDoc<AuditLog, AuditLog>
|
||||
implements IAuditLog {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
TAuthProviderType,
|
||||
TAuthProviderStatus,
|
||||
IOAuthConfig,
|
||||
ILdapConfig,
|
||||
IAttributeMapping,
|
||||
IAuthProvider,
|
||||
ILdapConfig,
|
||||
IOAuthConfig,
|
||||
IProvisioningSettings,
|
||||
TAuthProviderStatus,
|
||||
TAuthProviderType,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@@ -27,10 +27,8 @@ const DEFAULT_PROVISIONING: IProvisioningSettings = {
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuthProvider
|
||||
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider
|
||||
{
|
||||
export class AuthProvider extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ let isInitialized = false;
|
||||
*/
|
||||
export async function initDb(
|
||||
mongoDbUrl: string,
|
||||
mongoDbName?: string
|
||||
mongoDbName?: string,
|
||||
): Promise<plugins.smartdata.SmartdataDb> {
|
||||
if (isInitialized && db) {
|
||||
return db;
|
||||
|
||||
@@ -10,8 +10,7 @@ import { db } from './db.ts';
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ExternalIdentity
|
||||
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
||||
implements IExternalIdentity
|
||||
{
|
||||
implements IExternalIdentity {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -55,7 +54,7 @@ export class ExternalIdentity
|
||||
*/
|
||||
public static async findByExternalId(
|
||||
providerId: string,
|
||||
externalId: string
|
||||
externalId: string,
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||
}
|
||||
@@ -72,7 +71,7 @@ export class ExternalIdentity
|
||||
*/
|
||||
public static async findByUserAndProvider(
|
||||
userId: string,
|
||||
providerId: string
|
||||
providerId: string,
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ userId, providerId });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Model exports
|
||||
*/
|
||||
|
||||
export { initDb, getDb, closeDb, isDbConnected } from './db.ts';
|
||||
export { closeDb, getDb, initDb, isDbConnected } from './db.ts';
|
||||
export { User } from './user.ts';
|
||||
export { Organization } from './organization.ts';
|
||||
export { OrganizationMember } from './organization.member.ts';
|
||||
@@ -15,6 +15,9 @@ export { ApiToken } from './apitoken.ts';
|
||||
export { Session } from './session.ts';
|
||||
export { AuditLog } from './auditlog.ts';
|
||||
|
||||
// Organization redirects
|
||||
export { OrgRedirect } from './org.redirect.ts';
|
||||
|
||||
// External authentication models
|
||||
export { AuthProvider } from './auth.provider.ts';
|
||||
export { ExternalIdentity } from './external.identity.ts';
|
||||
|
||||
59
ts/models/org.redirect.ts
Normal file
59
ts/models/org.redirect.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* OrgRedirect model - stores old org handles as redirect aliases
|
||||
* When an org is renamed, the old name becomes a redirect pointing to the org.
|
||||
* Redirects can be explicitly deleted by org admins.
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class OrgRedirect extends plugins.smartdata.SmartDataDbDoc<OrgRedirect, OrgRedirect> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public oldName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public organizationId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Create a redirect from an old org name to the current org
|
||||
*/
|
||||
public static async create(oldName: string, organizationId: string): Promise<OrgRedirect> {
|
||||
const redirect = new OrgRedirect();
|
||||
redirect.id = `redirect:${oldName}`;
|
||||
redirect.oldName = oldName;
|
||||
redirect.organizationId = organizationId;
|
||||
redirect.createdAt = new Date();
|
||||
await redirect.save();
|
||||
return redirect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a redirect by the old name
|
||||
*/
|
||||
public static async findByName(name: string): Promise<OrgRedirect | null> {
|
||||
return await OrgRedirect.getInstance({ oldName: name } as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all redirects for an organization
|
||||
*/
|
||||
public static async getByOrgId(organizationId: string): Promise<OrgRedirect[]> {
|
||||
return await OrgRedirect.getInstances({ organizationId } as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a redirect by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<OrgRedirect | null> {
|
||||
return await OrgRedirect.getInstance({ id } as any);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import type { IOrganizationMember, TOrganizationRole } from '../interfaces/auth.
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember> implements IOrganizationMember {
|
||||
export class OrganizationMember
|
||||
extends plugins.smartdata.SmartDataDbDoc<OrganizationMember, OrganizationMember>
|
||||
implements IOrganizationMember {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -69,7 +71,7 @@ export class OrganizationMember extends plugins.smartdata.SmartDataDbDoc<Organiz
|
||||
*/
|
||||
public static async findMembership(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<OrganizationMember | null> {
|
||||
return await OrganizationMember.getInstance({
|
||||
organizationId,
|
||||
|
||||
@@ -18,7 +18,8 @@ const DEFAULT_SETTINGS: IOrganizationSettings = {
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization> implements IOrganization {
|
||||
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
|
||||
implements IOrganization {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -92,7 +93,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package> implements IPackage {
|
||||
export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
implements IPackage {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = ''; // {protocol}:{org}:{name}
|
||||
|
||||
@@ -94,7 +95,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
public static async findByName(
|
||||
protocol: TRegistryProtocol,
|
||||
orgName: string,
|
||||
name: string
|
||||
name: string,
|
||||
): Promise<Package | null> {
|
||||
const id = Package.generateId(protocol, orgName, name);
|
||||
return await Package.findById(id);
|
||||
@@ -118,7 +119,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
isPrivate?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
},
|
||||
): Promise<Package[]> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (options?.protocol) filter.protocol = options.protocol;
|
||||
@@ -133,7 +134,7 @@ export class Package extends plugins.smartdata.SmartDataDbDoc<Package, Package>
|
||||
const filtered = allPackages.filter(
|
||||
(pkg) =>
|
||||
pkg.name.toLowerCase().includes(lowerQuery) ||
|
||||
pkg.description?.toLowerCase().includes(lowerQuery)
|
||||
pkg.description?.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
|
||||
// Apply pagination
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import type { IPlatformAuthSettings, IPlatformSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
@@ -16,8 +16,7 @@ const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class PlatformSettings
|
||||
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
||||
implements IPlatformSettings
|
||||
{
|
||||
implements IPlatformSettings {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = 'singleton';
|
||||
|
||||
@@ -51,7 +50,7 @@ export class PlatformSettings
|
||||
*/
|
||||
public async updateAuthSettings(
|
||||
settings: Partial<IPlatformAuthSettings>,
|
||||
updatedById?: string
|
||||
updatedById?: string,
|
||||
): Promise<void> {
|
||||
this.auth = { ...this.auth, ...settings };
|
||||
this.updatedAt = new Date();
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { IRepositoryPermission, TRepositoryRole } from '../interfaces/auth.
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission> implements IRepositoryPermission {
|
||||
export class RepositoryPermission
|
||||
extends plugins.smartdata.SmartDataDbDoc<RepositoryPermission, RepositoryPermission>
|
||||
implements IRepositoryPermission {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -99,12 +101,22 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
return perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find permission for a user on a repository (alias for getUserPermission)
|
||||
*/
|
||||
public static async findPermission(
|
||||
repositoryId: string,
|
||||
userId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getUserPermission(repositoryId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's direct permission on repository
|
||||
*/
|
||||
public static async getUserPermission(
|
||||
repositoryId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
@@ -117,7 +129,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async getTeamPermission(
|
||||
repositoryId: string,
|
||||
teamId: string
|
||||
teamId: string,
|
||||
): Promise<RepositoryPermission | null> {
|
||||
return await RepositoryPermission.getInstance({
|
||||
repositoryId,
|
||||
@@ -139,7 +151,7 @@ export class RepositoryPermission extends plugins.smartdata.SmartDataDbDoc<Repos
|
||||
*/
|
||||
public static async getTeamPermissionsForRepo(
|
||||
repositoryId: string,
|
||||
teamIds: string[]
|
||||
teamIds: string[],
|
||||
): Promise<RepositoryPermission[]> {
|
||||
if (teamIds.length === 0) return [];
|
||||
return await RepositoryPermission.getInstances({
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import type {
|
||||
IRepository,
|
||||
TRegistryProtocol,
|
||||
TRepositoryVisibility,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository> implements IRepository {
|
||||
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
|
||||
implements IRepository {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -39,6 +44,12 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
@plugins.smartdata.svDb()
|
||||
public starCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public packageCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public storageBytes: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
@@ -64,7 +75,9 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
// Validate name
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name.toLowerCase())) {
|
||||
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
|
||||
throw new Error(
|
||||
'Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate name in org + protocol
|
||||
@@ -99,7 +112,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
public static async findByName(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
protocol: TRegistryProtocol
|
||||
protocol: TRegistryProtocol,
|
||||
): Promise<Repository | null> {
|
||||
return await Repository.getInstance({
|
||||
organizationId,
|
||||
@@ -128,6 +141,20 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
|
||||
return await Repository.getInstances(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this repository is public
|
||||
*/
|
||||
public get isPublic(): boolean {
|
||||
return this.visibility === 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find repository by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Repository | null> {
|
||||
return await Repository.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ISession } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session> implements ISession {
|
||||
export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
||||
implements ISession {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@@ -94,7 +95,7 @@ export class Session extends plugins.smartdata.SmartDataDbDoc<Session, Session>
|
||||
*/
|
||||
public static async invalidateAllUserSessions(
|
||||
userId: string,
|
||||
reason: string = 'logout_all'
|
||||
reason: string = 'logout_all',
|
||||
): Promise<number> {
|
||||
const sessions = await Session.getUserSessions(userId);
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ITeamMember, TTeamRole } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember> implements ITeamMember {
|
||||
export class TeamMember extends plugins.smartdata.SmartDataDbDoc<TeamMember, TeamMember>
|
||||
implements ITeamMember {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implement
|
||||
@plugins.smartdata.svDb()
|
||||
public isDefaultTeam: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public repositoryIds: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
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() as unknown as interfaces.data.IAuthProvider),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list providers');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateAdminProvider>(
|
||||
'createAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, displayName, type, oauthConfig, ldapConfig, attributeMapping, provisioning } = dataArg;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !displayName || !type) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'name, displayName, and type are required',
|
||||
);
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
const existing = await AuthProvider.findByName(name);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider name already exists');
|
||||
}
|
||||
|
||||
// Validate type-specific config
|
||||
if (type === 'oidc' && !oauthConfig) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'oauthConfig is required for OIDC provider',
|
||||
);
|
||||
}
|
||||
if (type === 'ldap' && !ldapConfig) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'ldapConfig is required for LDAP provider',
|
||||
);
|
||||
}
|
||||
|
||||
let provider: AuthProvider;
|
||||
|
||||
if (type === 'oidc' && oauthConfig) {
|
||||
// Encrypt client secret
|
||||
const encryptedSecret = await cryptoService.encrypt(
|
||||
oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createOAuthProvider({
|
||||
name,
|
||||
displayName,
|
||||
oauthConfig: {
|
||||
...oauthConfig,
|
||||
clientSecretEncrypted: encryptedSecret,
|
||||
},
|
||||
attributeMapping,
|
||||
provisioning,
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
} else if (type === 'ldap' && ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(
|
||||
ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name,
|
||||
displayName,
|
||||
ldapConfig: {
|
||||
...ldapConfig,
|
||||
bindPasswordEncrypted: encryptedPassword,
|
||||
},
|
||||
attributeMapping,
|
||||
provisioning,
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
} else {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid provider type');
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_CREATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
|
||||
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminProvider>(
|
||||
'getAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAdminProvider>(
|
||||
'updateAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
// Update basic fields
|
||||
if (dataArg.displayName !== undefined) provider.displayName = dataArg.displayName;
|
||||
if (dataArg.status !== undefined) provider.status = dataArg.status as any;
|
||||
if (dataArg.priority !== undefined) provider.priority = dataArg.priority;
|
||||
|
||||
// Update OAuth config
|
||||
if (dataArg.oauthConfig && provider.oauthConfig) {
|
||||
const newOAuthConfig = { ...provider.oauthConfig, ...dataArg.oauthConfig };
|
||||
|
||||
// Encrypt new client secret if provided and not already encrypted
|
||||
if (
|
||||
dataArg.oauthConfig.clientSecretEncrypted &&
|
||||
!cryptoService.isEncrypted(dataArg.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
dataArg.oauthConfig.clientSecretEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
provider.oauthConfig = newOAuthConfig;
|
||||
}
|
||||
|
||||
// Update LDAP config
|
||||
if (dataArg.ldapConfig && provider.ldapConfig) {
|
||||
const newLdapConfig = { ...provider.ldapConfig, ...dataArg.ldapConfig };
|
||||
|
||||
// Encrypt new bind password if provided and not already encrypted
|
||||
if (
|
||||
dataArg.ldapConfig.bindPasswordEncrypted &&
|
||||
!cryptoService.isEncrypted(dataArg.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
dataArg.ldapConfig.bindPasswordEncrypted,
|
||||
);
|
||||
}
|
||||
|
||||
provider.ldapConfig = newLdapConfig;
|
||||
}
|
||||
|
||||
// Update attribute mapping
|
||||
if (dataArg.attributeMapping) {
|
||||
provider.attributeMapping = {
|
||||
...provider.attributeMapping,
|
||||
...dataArg.attributeMapping,
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Update provisioning settings
|
||||
if (dataArg.provisioning) {
|
||||
provider.provisioning = {
|
||||
...provider.provisioning,
|
||||
...dataArg.provisioning,
|
||||
} as any;
|
||||
}
|
||||
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_UPDATED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: { providerName: provider.name },
|
||||
});
|
||||
|
||||
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAdminProvider>(
|
||||
'deleteAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const provider = await AuthProvider.findById(dataArg.providerId);
|
||||
if (!provider) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Provider not found');
|
||||
}
|
||||
|
||||
// Soft delete - disable instead of removing
|
||||
provider.status = 'disabled';
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_DELETED', 'auth_provider', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: { providerName: provider.name },
|
||||
});
|
||||
|
||||
return { message: 'Provider disabled' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Test Admin Provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestAdminProvider>(
|
||||
'testAdminProvider',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const result = await externalAuthService.testConnection(dataArg.providerId);
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('AUTH_PROVIDER_TESTED', 'auth_provider', {
|
||||
resourceId: dataArg.providerId,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
|
||||
return { result };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to test provider');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Platform Settings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformSettings>(
|
||||
'getPlatformSettings',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
return {
|
||||
settings: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get settings');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Platform Settings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdatePlatformSettings>(
|
||||
'updatePlatformSettings',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
|
||||
if (dataArg.auth) {
|
||||
await settings.updateAuthSettings(dataArg.auth as any, dataArg.identity.userId);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).log('PLATFORM_SETTINGS_UPDATED', 'platform_settings', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
settings: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt instanceof Date ? settings.updatedAt.toISOString() : String(settings.updatedAt),
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update settings');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
651
ts/opsserver/handlers/organization.handler.ts
Normal file
651
ts/opsserver/handlers/organization.handler.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Organization, OrganizationMember, OrgRedirect, User } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
|
||||
export class OrganizationHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve organization by ID or name
|
||||
*/
|
||||
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||
if (idOrName.startsWith('Organization:')) {
|
||||
return await Organization.findById(idOrName);
|
||||
}
|
||||
// Try direct name lookup first
|
||||
const org = await Organization.findByName(idOrName);
|
||||
if (org) return org;
|
||||
// Check redirects
|
||||
const redirect = await OrgRedirect.findByName(idOrName);
|
||||
if (redirect) {
|
||||
return await Organization.findById(redirect.organizationId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Organizations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizations>(
|
||||
'getOrganizations',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const userId = dataArg.identity.userId;
|
||||
let organizations: Organization[];
|
||||
|
||||
if (dataArg.identity.isSystemAdmin) {
|
||||
organizations = await Organization.getInstances({});
|
||||
} else {
|
||||
const memberships = await OrganizationMember.getUserOrganizations(userId);
|
||||
const orgs: Organization[] = [];
|
||||
for (const m of memberships) {
|
||||
const org = await Organization.findById(m.organizationId);
|
||||
if (org) orgs.push(org);
|
||||
}
|
||||
organizations = orgs;
|
||||
}
|
||||
|
||||
return {
|
||||
organizations: organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list organizations');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganization>(
|
||||
'getOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check access - public orgs visible to all, private requires membership
|
||||
if (!org.isPublic) {
|
||||
const isMember = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const orgData: interfaces.data.IOrganizationDetail = {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
};
|
||||
|
||||
// Include settings for admins
|
||||
if (dataArg.identity.isSystemAdmin && org.settings) {
|
||||
orgData.settings = org.settings as any;
|
||||
}
|
||||
|
||||
return { organization: orgData };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateOrganization>(
|
||||
'createOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, displayName, description, isPublic } = dataArg;
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name is required');
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name must be lowercase alphanumeric with optional hyphens and dots',
|
||||
);
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
const existing = await Organization.findByName(name);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const org = new Organization();
|
||||
org.id = await Organization.getNewId();
|
||||
org.name = name;
|
||||
org.displayName = displayName || name;
|
||||
org.description = description;
|
||||
org.isPublic = isPublic ?? false;
|
||||
org.memberCount = 1;
|
||||
org.createdAt = new Date();
|
||||
org.createdById = dataArg.identity.userId;
|
||||
|
||||
await org.save();
|
||||
|
||||
// Add creator as owner
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = dataArg.identity.userId;
|
||||
membership.role = 'owner';
|
||||
membership.invitedBy = dataArg.identity.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
}).logOrganizationCreated(org.id, org.name);
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganization>(
|
||||
'updateOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
// Handle rename
|
||||
if (dataArg.name && dataArg.name !== org.name) {
|
||||
const newName = dataArg.name;
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(newName)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name must be lowercase alphanumeric with optional hyphens and dots',
|
||||
);
|
||||
}
|
||||
// Check new name not taken by another org
|
||||
const existingOrg = await Organization.findByName(newName);
|
||||
if (existingOrg && existingOrg.id !== org.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
|
||||
}
|
||||
// Check new name not taken by a redirect pointing elsewhere
|
||||
const existingRedirect = await OrgRedirect.findByName(newName);
|
||||
if (existingRedirect && existingRedirect.organizationId !== org.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name is reserved as a redirect for another organization',
|
||||
);
|
||||
}
|
||||
// If new name is one of our own redirects, delete that redirect
|
||||
if (existingRedirect && existingRedirect.organizationId === org.id) {
|
||||
await existingRedirect.delete();
|
||||
}
|
||||
// Create redirect from old name
|
||||
await OrgRedirect.create(org.name, org.id);
|
||||
org.name = newName;
|
||||
}
|
||||
|
||||
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
|
||||
if (dataArg.description !== undefined) org.description = dataArg.description;
|
||||
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
|
||||
if (dataArg.website !== undefined) org.website = dataArg.website;
|
||||
if (dataArg.isPublic !== undefined) org.isPublic = dataArg.isPublic;
|
||||
|
||||
// Only system admins can change settings
|
||||
if (dataArg.settings && dataArg.identity.isSystemAdmin) {
|
||||
org.settings = { ...org.settings, ...dataArg.settings } as any;
|
||||
}
|
||||
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayName: org.displayName,
|
||||
description: org.description,
|
||||
avatarUrl: org.avatarUrl,
|
||||
website: org.website,
|
||||
isPublic: org.isPublic,
|
||||
memberCount: org.memberCount,
|
||||
plan: (org as any).plan || 'free',
|
||||
usedStorageBytes: org.usedStorageBytes || 0,
|
||||
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
||||
createdAt: org.createdAt instanceof Date
|
||||
? org.createdAt.toISOString()
|
||||
: String(org.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrganization>(
|
||||
'deleteOrganization',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (membership?.role !== 'owner' && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Owner access required');
|
||||
}
|
||||
|
||||
await org.delete();
|
||||
|
||||
return { message: 'Organization deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete organization');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Organization Members
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizationMembers>(
|
||||
'getOrganizationMembers',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(
|
||||
org.id,
|
||||
dataArg.identity.userId,
|
||||
);
|
||||
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
|
||||
const membersWithUsers = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const user = await User.findById(m.userId);
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role as interfaces.data.TOrganizationRole,
|
||||
addedAt: m.joinedAt instanceof Date
|
||||
? m.joinedAt.toISOString()
|
||||
: String(m.joinedAt),
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { members: membersWithUsers };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list members');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Add Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddOrganizationMember>(
|
||||
'addOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
const { userId, role } = dataArg;
|
||||
|
||||
if (!userId || !role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('userId and role are required');
|
||||
}
|
||||
|
||||
if (!['owner', 'admin', 'member'].includes(role)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid role');
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (existing) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User is already a member');
|
||||
}
|
||||
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.invitedBy = dataArg.identity.userId;
|
||||
membership.joinedAt = new Date();
|
||||
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
member: {
|
||||
userId: membership.userId,
|
||||
role: membership.role as interfaces.data.TOrganizationRole,
|
||||
addedAt: membership.joinedAt instanceof Date
|
||||
? membership.joinedAt.toISOString()
|
||||
: String(membership.joinedAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to add member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganizationMember>(
|
||||
'updateOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
const { userId, role } = dataArg;
|
||||
|
||||
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid role is required');
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||
}
|
||||
}
|
||||
|
||||
membership.role = role;
|
||||
await membership.save();
|
||||
|
||||
return {
|
||||
member: {
|
||||
userId: membership.userId,
|
||||
role: membership.role as interfaces.data.TOrganizationRole,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove Organization Member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveOrganizationMember>(
|
||||
'removeOrganizationMember',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (dataArg.userId !== dataArg.identity.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
org.id,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, dataArg.userId);
|
||||
if (!membership) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
||||
}
|
||||
}
|
||||
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
|
||||
return { message: 'Member removed successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to remove member');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Org Redirects
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrgRedirects>(
|
||||
'getOrgRedirects',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(dataArg.organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
const redirects = await OrgRedirect.getByOrgId(org.id);
|
||||
return {
|
||||
redirects: redirects.map((r) => ({
|
||||
id: r.id,
|
||||
oldName: r.oldName,
|
||||
organizationId: r.organizationId,
|
||||
createdAt: r.createdAt instanceof Date
|
||||
? r.createdAt.toISOString()
|
||||
: String(r.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get redirects');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Org Redirect
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrgRedirect>(
|
||||
'deleteOrgRedirect',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const redirect = await OrgRedirect.findById(dataArg.redirectId);
|
||||
if (!redirect) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Redirect not found');
|
||||
}
|
||||
|
||||
// Check permission on the org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
redirect.organizationId,
|
||||
);
|
||||
if (!canManage && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
await redirect.delete();
|
||||
return { message: 'Redirect deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete redirect');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
ts/opsserver/handlers/package.handler.ts
Normal file
315
ts/opsserver/handlers/package.handler.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Package, Repository } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
|
||||
export class PackageHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Search Packages
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SearchPackages>(
|
||||
'searchPackages',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const query = dataArg.query || '';
|
||||
const protocol = dataArg.protocol;
|
||||
const organizationId = dataArg.organizationId;
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
|
||||
// Determine visibility: anonymous users see only public packages
|
||||
const hasIdentity = !!dataArg.identity?.jwt;
|
||||
const isPrivate = hasIdentity ? undefined : false;
|
||||
|
||||
const packages = await Package.searchPackages(query, {
|
||||
protocol,
|
||||
organizationId,
|
||||
isPrivate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// Filter out packages user doesn't have access to
|
||||
const accessiblePackages: typeof packages = [];
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.isPrivate) {
|
||||
accessiblePackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasIdentity && dataArg.identity) {
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
if (canAccess) {
|
||||
accessiblePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packages: accessiblePackages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags?.['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount || 0,
|
||||
starCount: pkg.starCount || 0,
|
||||
storageBytes: pkg.storageBytes || 0,
|
||||
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||
})),
|
||||
total: accessiblePackages.length,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to search packages');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Package
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackage>(
|
||||
'getPackage',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check access for private packages
|
||||
if (pkg.isPrivate) {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
package: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
protocol: pkg.protocol as interfaces.data.TRegistryProtocol,
|
||||
organizationId: pkg.organizationId,
|
||||
repositoryId: pkg.repositoryId,
|
||||
latestVersion: pkg.distTags?.['latest'],
|
||||
isPrivate: pkg.isPrivate,
|
||||
downloadCount: pkg.downloadCount || 0,
|
||||
starCount: pkg.starCount || 0,
|
||||
storageBytes: pkg.storageBytes || 0,
|
||||
distTags: pkg.distTags || {},
|
||||
versions: Object.keys(pkg.versions || {}),
|
||||
updatedAt: pkg.updatedAt instanceof Date ? pkg.updatedAt.toISOString() : String(pkg.updatedAt),
|
||||
createdAt: pkg.createdAt instanceof Date ? pkg.createdAt.toISOString() : String(pkg.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get package');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Package Versions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPackageVersions>(
|
||||
'getPackageVersions',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check access for private packages
|
||||
if (pkg.isPrivate) {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Authentication required');
|
||||
}
|
||||
|
||||
const canAccess = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'read',
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const versions = Object.entries(pkg.versions || {}).map(([version, data]) => ({
|
||||
version,
|
||||
publishedAt: data.publishedAt instanceof Date ? data.publishedAt.toISOString() : String(data.publishedAt || ''),
|
||||
size: data.size || 0,
|
||||
downloads: data.downloads || 0,
|
||||
checksum: data.metadata?.checksum as interfaces.data.IPackageVersion['checksum'],
|
||||
}));
|
||||
|
||||
return {
|
||||
packageId: pkg.id,
|
||||
packageName: pkg.name,
|
||||
distTags: pkg.distTags || {},
|
||||
versions,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list versions');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Package
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackage>(
|
||||
'deletePackage',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||
}
|
||||
|
||||
// Update repository counts before deleting
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
||||
repo.storageBytes -= pkg.storageBytes;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
await pkg.delete();
|
||||
|
||||
return { message: 'Package deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete package');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Package Version
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeletePackageVersion>(
|
||||
'deletePackageVersion',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const pkg = await Package.findById(dataArg.packageId);
|
||||
if (!pkg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Package not found');
|
||||
}
|
||||
|
||||
const versionData = pkg.versions?.[dataArg.version];
|
||||
if (!versionData) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Version not found');
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const canDelete = await this.permissionService.canAccessPackage(
|
||||
dataArg.identity.userId,
|
||||
pkg.organizationId,
|
||||
pkg.repositoryId,
|
||||
'delete',
|
||||
);
|
||||
|
||||
if (!canDelete) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Delete permission required');
|
||||
}
|
||||
|
||||
// Check if this is the only version
|
||||
if (Object.keys(pkg.versions).length === 1) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Cannot delete the only version. Delete the entire package instead.',
|
||||
);
|
||||
}
|
||||
|
||||
// Remove version
|
||||
const sizeReduction = versionData.size || 0;
|
||||
delete pkg.versions[dataArg.version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, tagVersion] of Object.entries(pkg.distTags || {})) {
|
||||
if (tagVersion === dataArg.version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// Set new latest if needed
|
||||
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
||||
const versions = Object.keys(pkg.versions).sort();
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update repository storage
|
||||
const repo = await Repository.findById(pkg.repositoryId);
|
||||
if (repo) {
|
||||
repo.storageBytes -= sizeReduction;
|
||||
await repo.save();
|
||||
}
|
||||
|
||||
return { message: 'Version deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete version');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
ts/opsserver/handlers/repository.handler.ts
Normal file
272
ts/opsserver/handlers/repository.handler.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { Organization, Repository } from '../../models/index.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
|
||||
export class RepositoryHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Repositories
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepositories>(
|
||||
'getRepositories',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repositories = await this.permissionService.getAccessibleRepositories(
|
||||
dataArg.identity.userId,
|
||||
dataArg.organizationId,
|
||||
);
|
||||
|
||||
return {
|
||||
repositories: repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list repositories');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRepository>(
|
||||
'getRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!repo.isPublic) {
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: dataArg.identity.userId,
|
||||
organizationId: repo.organizationId,
|
||||
repositoryId: repo.id,
|
||||
});
|
||||
|
||||
if (!permissions.canRead) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRepository>(
|
||||
'createRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { organizationId, name, description, protocol, visibility } = dataArg;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository name is required');
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check org exists
|
||||
const org = await Organization.findById(organizationId);
|
||||
if (!org) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
// Create repository
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId,
|
||||
name,
|
||||
description,
|
||||
protocol: protocol || 'npm',
|
||||
visibility: visibility || 'private',
|
||||
createdById: dataArg.identity.userId,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: dataArg.identity.userId,
|
||||
actorType: 'user',
|
||||
organizationId,
|
||||
}).logRepositoryCreated(repo.id, repo.name, organizationId);
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRepository>(
|
||||
'updateRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
dataArg.identity.userId,
|
||||
repo.organizationId,
|
||||
dataArg.repositoryId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
if (dataArg.description !== undefined) repo.description = dataArg.description;
|
||||
if (dataArg.visibility !== undefined) repo.visibility = dataArg.visibility as any;
|
||||
|
||||
await repo.save();
|
||||
|
||||
return {
|
||||
repository: {
|
||||
id: repo.id,
|
||||
organizationId: repo.organizationId,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
protocol: repo.protocol as interfaces.data.TRegistryProtocol,
|
||||
visibility: repo.visibility as interfaces.data.TRepositoryVisibility,
|
||||
isPublic: repo.isPublic,
|
||||
packageCount: repo.packageCount,
|
||||
storageBytes: repo.storageBytes || 0,
|
||||
downloadCount: (repo as any).downloadCount || 0,
|
||||
createdAt: repo.createdAt instanceof Date ? repo.createdAt.toISOString() : String(repo.createdAt),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Repository
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRepository>(
|
||||
'deleteRepository',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const repo = await Repository.findById(dataArg.repositoryId);
|
||||
if (!repo) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Repository not found');
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageRepository(
|
||||
dataArg.identity.userId,
|
||||
repo.organizationId,
|
||||
dataArg.repositoryId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
|
||||
// Check for packages
|
||||
if (repo.packageCount > 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Cannot delete repository with packages. Remove all packages first.',
|
||||
);
|
||||
}
|
||||
|
||||
await repo.delete();
|
||||
|
||||
return { message: 'Repository deleted successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete repository');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
ts/opsserver/handlers/token.handler.ts
Normal file
198
ts/opsserver/handlers/token.handler.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import { ApiToken } from '../../models/index.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
|
||||
export class TokenHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private tokenService = new TokenService();
|
||||
private permissionService = new PermissionService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Tokens
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTokens>(
|
||||
'getTokens',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
let tokens;
|
||||
if (dataArg.organizationId) {
|
||||
// Check if user can manage org
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
dataArg.organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Not authorized to view organization tokens',
|
||||
);
|
||||
}
|
||||
tokens = await this.tokenService.getOrgTokens(dataArg.organizationId);
|
||||
} else {
|
||||
tokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols as interfaces.data.TRegistryProtocol[],
|
||||
scopes: t.scopes as interfaces.data.ITokenScope[],
|
||||
organizationId: t.organizationId,
|
||||
createdById: t.createdById,
|
||||
expiresAt: t.expiresAt instanceof Date ? t.expiresAt.toISOString() : t.expiresAt ? String(t.expiresAt) : undefined,
|
||||
lastUsedAt: t.lastUsedAt instanceof Date ? t.lastUsedAt.toISOString() : t.lastUsedAt ? String(t.lastUsedAt) : undefined,
|
||||
usageCount: t.usageCount,
|
||||
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list tokens');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create Token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateToken>(
|
||||
'createToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { name, organizationId, protocols, scopes, expiresInDays } = dataArg;
|
||||
|
||||
if (!name) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Token name is required');
|
||||
}
|
||||
|
||||
if (!protocols || protocols.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one protocol is required');
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one scope is required');
|
||||
}
|
||||
|
||||
// Validate protocols
|
||||
const validProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems', '*'];
|
||||
for (const protocol of protocols) {
|
||||
if (!validProtocols.includes(protocol)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Invalid protocol: ${protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
for (const scope of scopes) {
|
||||
if (!scope.protocol || !scope.actions || scope.actions.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid scope configuration');
|
||||
}
|
||||
}
|
||||
|
||||
// If creating org token, verify permission
|
||||
if (organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
organizationId,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Not authorized to create organization tokens',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.tokenService.createToken({
|
||||
userId: dataArg.identity.userId,
|
||||
organizationId,
|
||||
createdById: dataArg.identity.userId,
|
||||
name,
|
||||
protocols: protocols as any[],
|
||||
scopes: scopes as any[],
|
||||
expiresInDays,
|
||||
});
|
||||
|
||||
return {
|
||||
token: {
|
||||
id: result.token.id,
|
||||
name: result.token.name,
|
||||
token: result.rawToken,
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols as interfaces.data.TRegistryProtocol[],
|
||||
scopes: result.token.scopes as interfaces.data.ITokenScope[],
|
||||
organizationId: result.token.organizationId,
|
||||
createdById: result.token.createdById,
|
||||
expiresAt: result.token.expiresAt instanceof Date ? result.token.expiresAt.toISOString() : result.token.expiresAt ? String(result.token.expiresAt) : undefined,
|
||||
usageCount: result.token.usageCount,
|
||||
createdAt: result.token.createdAt instanceof Date ? result.token.createdAt.toISOString() : String(result.token.createdAt),
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to create token');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke Token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeToken>(
|
||||
'revokeToken',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { tokenId } = dataArg;
|
||||
|
||||
// Check if it's a personal token
|
||||
const userTokens = await this.tokenService.getUserTokens(dataArg.identity.userId);
|
||||
let token = userTokens.find((t) => t.id === tokenId);
|
||||
|
||||
if (!token) {
|
||||
// Check if it's an org token the user can manage
|
||||
const anyToken = await ApiToken.getInstance({ id: tokenId, isRevoked: false });
|
||||
if (anyToken?.organizationId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(
|
||||
dataArg.identity.userId,
|
||||
anyToken.organizationId,
|
||||
);
|
||||
if (canManage) {
|
||||
token = anyToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Token not found');
|
||||
}
|
||||
|
||||
const success = await this.tokenService.revokeToken(tokenId, 'user_revoked');
|
||||
if (!success) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||
}
|
||||
|
||||
return { message: 'Token revoked successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke token');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
ts/opsserver/handlers/user.handler.ts
Normal file
263
ts/opsserver/handlers/user.handler.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import { requireValidIdentity, requireAdminIdentity } from '../helpers/guards.ts';
|
||||
import { User } from '../../models/user.ts';
|
||||
import { Session } from '../../models/session.ts';
|
||||
import { AuthService } from '../../services/auth.service.ts';
|
||||
|
||||
export class UserHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private authService = new AuthService();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format user to IUser interface
|
||||
*/
|
||||
private formatUser(user: User): interfaces.data.IUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : String(user.createdAt),
|
||||
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt ? String(user.lastLoginAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Users (admin only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUsers>(
|
||||
'getUsers',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const users = await User.getInstances({});
|
||||
return {
|
||||
users: users.map((u) => this.formatUser(u)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to list users');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get User
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUser>(
|
||||
'getUser',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { userId } = dataArg;
|
||||
|
||||
// Users can view their own profile, admins can view any
|
||||
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
return { user: this.formatUser(user) };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get user');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update User
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateUser>(
|
||||
'updateUser',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { userId, displayName, avatarUrl, password, isActive, isSystemAdmin } = dataArg;
|
||||
|
||||
// Users can update their own profile, admins can update any
|
||||
if (userId !== dataArg.identity.userId && !dataArg.identity.isSystemAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
if (displayName !== undefined) user.displayName = displayName;
|
||||
if (avatarUrl !== undefined) user.avatarUrl = avatarUrl;
|
||||
|
||||
// Only admins can change these
|
||||
if (dataArg.identity.isSystemAdmin) {
|
||||
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
|
||||
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (password) {
|
||||
user.passwordHash = await AuthService.hashPassword(password);
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
return { user: this.formatUser(user) };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to update user');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get User Sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUserSessions>(
|
||||
'getUserSessions',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const sessions = await Session.getUserSessions(dataArg.identity.userId);
|
||||
|
||||
return {
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
isValid: s.isValid,
|
||||
lastActivityAt: s.lastActivityAt instanceof Date ? s.lastActivityAt.toISOString() : String(s.lastActivityAt),
|
||||
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : String(s.createdAt),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to get sessions');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke Session
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeSession>(
|
||||
'revokeSession',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
await this.authService.logout(dataArg.sessionId, {
|
||||
userId: dataArg.identity.userId,
|
||||
});
|
||||
|
||||
return { message: 'Session revoked successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to revoke session');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Change Password
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { currentPassword, newPassword } = dataArg;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Current password and new password are required',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findById(dataArg.identity.userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValid = await user.verifyPassword(currentPassword);
|
||||
if (!isValid) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash and set new password
|
||||
user.passwordHash = await AuthService.hashPassword(newPassword);
|
||||
await user.save();
|
||||
|
||||
return { message: 'Password changed successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to change password');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete Account
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteAccount>(
|
||||
'deleteAccount',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
||||
|
||||
try {
|
||||
const { password } = dataArg;
|
||||
|
||||
if (!password) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Password is required to delete account',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findById(dataArg.identity.userId);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await user.verifyPassword(password);
|
||||
if (!isValid) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Password is incorrect');
|
||||
}
|
||||
|
||||
// Soft delete - deactivate instead of removing
|
||||
user.status = 'suspended';
|
||||
await user.save();
|
||||
|
||||
// Invalidate all sessions
|
||||
await Session.invalidateAllUserSessions(user.id, 'account_deleted');
|
||||
|
||||
return { message: 'Account deactivated successfully' };
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
throw new plugins.typedrequest.TypedResponseError('Failed to delete account');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { AuthHandler } from '../handlers/auth.handler.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
authHandler: AuthHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await authHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
authHandler: AuthHandler,
|
||||
dataArg: T,
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
const passed = await authHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
import * as smartguard from '@push.rocks/smartguard';
|
||||
|
||||
// api.global packages
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
// tsclass types
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
@@ -28,25 +32,28 @@ import * as fs from '@std/fs';
|
||||
import * as http from '@std/http';
|
||||
|
||||
export {
|
||||
// Push.rocks
|
||||
smartregistry,
|
||||
smartdata,
|
||||
smartbucket,
|
||||
smartlog,
|
||||
smartenv,
|
||||
smartpath,
|
||||
smartpromise,
|
||||
smartstring,
|
||||
smartcrypto,
|
||||
smartjwt,
|
||||
smartunique,
|
||||
smartdelay,
|
||||
smartrx,
|
||||
smartcli,
|
||||
// tsclass
|
||||
tsclass,
|
||||
// Deno std
|
||||
path,
|
||||
fs,
|
||||
http,
|
||||
// Deno std
|
||||
path,
|
||||
smartbucket,
|
||||
smartcli,
|
||||
smartcrypto,
|
||||
smartdata,
|
||||
smartdelay,
|
||||
smartenv,
|
||||
smartguard,
|
||||
smartjwt,
|
||||
smartlog,
|
||||
smartpath,
|
||||
smartpromise,
|
||||
// Push.rocks
|
||||
smartregistry,
|
||||
smartrx,
|
||||
smartstring,
|
||||
smartunique,
|
||||
// tsclass
|
||||
tsclass,
|
||||
// api.global
|
||||
typedrequest,
|
||||
};
|
||||
|
||||
@@ -46,206 +46,128 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request and return the actor
|
||||
* Called by smartregistry for every incoming request
|
||||
* Authenticate with username/password credentials
|
||||
* Returns userId on success, null on failure
|
||||
*/
|
||||
public async authenticate(request: plugins.smartregistry.IAuthRequest): Promise<plugins.smartregistry.IRequestActor> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: request.ip,
|
||||
actorUserAgent: request.userAgent,
|
||||
});
|
||||
|
||||
// Extract auth credentials
|
||||
const authHeader = request.headers?.['authorization'] || request.headers?.['Authorization'];
|
||||
|
||||
// Try Bearer token (API token)
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return await this.authenticateWithApiToken(token, request, auditContext);
|
||||
}
|
||||
|
||||
// Try Basic auth (for npm/other CLI tools)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const credentials = authHeader.substring(6);
|
||||
return await this.authenticateWithBasicAuth(credentials, request, auditContext);
|
||||
}
|
||||
|
||||
// Anonymous access
|
||||
return this.createAnonymousActor(request);
|
||||
public async authenticate(
|
||||
credentials: plugins.smartregistry.ICredentials,
|
||||
): Promise<string | null> {
|
||||
const result = await this.authService.login(credentials.username, credentials.password);
|
||||
if (!result.success || !result.user) return null;
|
||||
return result.user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if actor has permission for the requested action
|
||||
* Validate a token and return auth token info
|
||||
*/
|
||||
public async validateToken(
|
||||
token: string,
|
||||
protocol?: plugins.smartregistry.TRegistryProtocol,
|
||||
): Promise<plugins.smartregistry.IAuthToken | null> {
|
||||
// Try API token (srg_ prefix)
|
||||
if (token.startsWith('srg_')) {
|
||||
const result = await this.tokenService.validateToken(token);
|
||||
if (!result.valid || !result.token || !result.user) return null;
|
||||
|
||||
return {
|
||||
type: (protocol || result.token.protocols[0] ||
|
||||
'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
userId: result.user.id,
|
||||
scopes: result.token.scopes.map((s) => `${s.protocol}:${s.actions.join(',')}`),
|
||||
readonly: !result.token.scopes.some((s) =>
|
||||
s.actions.includes('write') || s.actions.includes('*')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Try JWT access token
|
||||
const validated = await this.authService.validateAccessToken(token);
|
||||
if (!validated) return null;
|
||||
|
||||
return {
|
||||
type: (protocol || 'npm') as plugins.smartregistry.TRegistryProtocol,
|
||||
userId: validated.user.id,
|
||||
scopes: ['*'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new token for a user and protocol
|
||||
*/
|
||||
public async createToken(
|
||||
userId: string,
|
||||
protocol: plugins.smartregistry.TRegistryProtocol,
|
||||
options?: plugins.smartregistry.ITokenOptions,
|
||||
): Promise<string> {
|
||||
const result = await this.tokenService.createToken({
|
||||
userId,
|
||||
name: `${protocol}-token`,
|
||||
protocols: [protocol as TRegistryProtocol],
|
||||
scopes: [
|
||||
{
|
||||
protocol: protocol as TRegistryProtocol,
|
||||
actions: options?.readonly ? ['read'] : ['read', 'write', 'delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
return result.rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token
|
||||
*/
|
||||
public async revokeToken(token: string): Promise<void> {
|
||||
if (token.startsWith('srg_')) {
|
||||
// Hash and find the token
|
||||
const result = await this.tokenService.validateToken(token);
|
||||
if (result.valid && result.token) {
|
||||
await this.tokenService.revokeToken(result.token.id, 'provider_revoked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token holder is authorized for a resource and action
|
||||
*/
|
||||
public async authorize(
|
||||
actor: plugins.smartregistry.IRequestActor,
|
||||
request: plugins.smartregistry.IAuthorizationRequest
|
||||
): Promise<plugins.smartregistry.IAuthorizationResult> {
|
||||
const stackActor = actor as IStackGalleryActor;
|
||||
token: plugins.smartregistry.IAuthToken | null,
|
||||
resource: string,
|
||||
action: string,
|
||||
): Promise<boolean> {
|
||||
// Anonymous access: only public reads
|
||||
if (!token) return false;
|
||||
|
||||
// Anonymous users can only read public packages
|
||||
if (stackActor.type === 'anonymous') {
|
||||
if (request.action === 'read' && request.isPublic) {
|
||||
return { allowed: true };
|
||||
// Parse resource string (format: "protocol:type:name" or "org/repo")
|
||||
const userId = token.userId;
|
||||
if (!userId) return false;
|
||||
|
||||
// Map action
|
||||
const mappedAction = this.mapAction(action);
|
||||
|
||||
// Check if user is active
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return false;
|
||||
|
||||
// System admins bypass all checks
|
||||
if (user.isSystemAdmin) return true;
|
||||
|
||||
// Check token scopes for the requested action
|
||||
if (token.scopes) {
|
||||
for (const scope of token.scopes) {
|
||||
// Scope format: "protocol:action1,action2" or "*"
|
||||
if (scope === '*') return true;
|
||||
const [, actions] = scope.split(':');
|
||||
if (actions) {
|
||||
const actionList = actions.split(',');
|
||||
if (actionList.includes(mappedAction) || actionList.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Authentication required',
|
||||
statusCode: 401,
|
||||
};
|
||||
}
|
||||
|
||||
// Check protocol access
|
||||
if (!stackActor.protocols.includes(request.protocol as TRegistryProtocol) &&
|
||||
!stackActor.protocols.includes('*' as TRegistryProtocol)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Token does not have access to ${request.protocol} protocol`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
// Map action to TAction
|
||||
const action = this.mapAction(request.action);
|
||||
|
||||
// Resolve permissions
|
||||
const permissions = await this.permissionService.resolvePermissions({
|
||||
userId: stackActor.userId!,
|
||||
organizationId: request.organizationId,
|
||||
repositoryId: request.repositoryId,
|
||||
protocol: request.protocol as TRegistryProtocol,
|
||||
});
|
||||
|
||||
// Check permission
|
||||
let allowed = false;
|
||||
switch (action) {
|
||||
case 'read':
|
||||
allowed = permissions.canRead || (request.isPublic ?? false);
|
||||
break;
|
||||
case 'write':
|
||||
allowed = permissions.canWrite;
|
||||
break;
|
||||
case 'delete':
|
||||
allowed = permissions.canDelete;
|
||||
break;
|
||||
case 'admin':
|
||||
allowed = permissions.canAdmin;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Insufficient permissions for ${request.action} on ${request.resourceType}`,
|
||||
statusCode: 403,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API token
|
||||
*/
|
||||
private async authenticateWithApiToken(
|
||||
rawToken: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
const result = await this.tokenService.validateToken(rawToken, request.ip);
|
||||
|
||||
if (!result.valid || !result.token || !result.user) {
|
||||
await auditContext.logFailure(
|
||||
'TOKEN_USED',
|
||||
'api_token',
|
||||
result.errorCode || 'UNKNOWN',
|
||||
result.errorMessage || 'Token validation failed'
|
||||
);
|
||||
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
await auditContext.log('TOKEN_USED', 'api_token', {
|
||||
resourceId: result.token.id,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'api_token',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
tokenId: result.token.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: result.token.protocols,
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using Basic auth (username:password or username:token)
|
||||
*/
|
||||
private async authenticateWithBasicAuth(
|
||||
credentials: string,
|
||||
request: plugins.smartregistry.IAuthRequest,
|
||||
auditContext: AuditService
|
||||
): Promise<IStackGalleryActor> {
|
||||
try {
|
||||
const decoded = atob(credentials);
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
// If password looks like an API token, try token auth
|
||||
if (password?.startsWith('srg_')) {
|
||||
return await this.authenticateWithApiToken(password, request, auditContext);
|
||||
}
|
||||
|
||||
// Otherwise try username/password (email/password)
|
||||
const result = await this.authService.login(username, password, {
|
||||
userAgent: request.userAgent,
|
||||
ipAddress: request.ip,
|
||||
});
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'user',
|
||||
userId: result.user.id,
|
||||
user: result.user,
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'],
|
||||
permissions: {
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return this.createAnonymousActor(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous actor
|
||||
*/
|
||||
private createAnonymousActor(request: plugins.smartregistry.IAuthRequest): IStackGalleryActor {
|
||||
return {
|
||||
type: 'anonymous',
|
||||
ip: request.ip,
|
||||
userAgent: request.userAgent,
|
||||
protocols: [],
|
||||
permissions: {
|
||||
canRead: false,
|
||||
canWrite: false,
|
||||
canDelete: false,
|
||||
},
|
||||
};
|
||||
// Default: authenticated users can read
|
||||
return mappedAction === 'read';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
export { StackGalleryAuthProvider, type IStackGalleryActor } from './auth.provider.ts';
|
||||
export { StackGalleryStorageHooks, type IStorageConfig } from './storage.provider.ts';
|
||||
export { type IStackGalleryActor, StackGalleryAuthProvider } from './auth.provider.ts';
|
||||
export { type IStorageConfig, StackGalleryStorageHooks } from './storage.provider.ts';
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { Package } from '../models/package.ts';
|
||||
import { Repository } from '../models/repository.ts';
|
||||
import { Organization } from '../models/organization.ts';
|
||||
import { AuditService } from '../services/audit.service.ts';
|
||||
|
||||
export interface IStorageConfig {
|
||||
export interface IStorageProviderConfig {
|
||||
bucket: plugins.smartbucket.SmartBucket;
|
||||
bucketName: string;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
@@ -20,222 +20,192 @@ export interface IStorageConfig {
|
||||
* and stores artifacts in S3 via smartbucket
|
||||
*/
|
||||
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
|
||||
private config: IStorageConfig;
|
||||
private config: IStorageProviderConfig;
|
||||
|
||||
constructor(config: IStorageConfig) {
|
||||
constructor(config: IStorageProviderConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is stored
|
||||
* Use this to validate, transform, or prepare for storage
|
||||
*/
|
||||
public async beforeStore(context: plugins.smartregistry.IStorageContext): Promise<plugins.smartregistry.IStorageContext> {
|
||||
public async beforePut(
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<plugins.smartregistry.IBeforePutResult> {
|
||||
// Validate organization exists and has quota
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (!org) {
|
||||
throw new Error(`Organization not found: ${context.organizationId}`);
|
||||
}
|
||||
const orgId = context.actor?.orgId;
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (!org) {
|
||||
return { allowed: false, reason: `Organization not found: ${orgId}` };
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
const newSize = context.size || 0;
|
||||
if (org.settings.quotas.maxStorageBytes > 0) {
|
||||
if (org.usedStorageBytes + newSize > org.settings.quotas.maxStorageBytes) {
|
||||
throw new Error('Organization storage quota exceeded');
|
||||
// Check storage quota
|
||||
const newSize = context.metadata?.size || 0;
|
||||
if (!org.hasStorageAvailable(newSize)) {
|
||||
return { allowed: false, reason: 'Organization storage quota exceeded' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate repository exists
|
||||
const repo = await Repository.findById(context.repositoryId);
|
||||
if (!repo) {
|
||||
throw new Error(`Repository not found: ${context.repositoryId}`);
|
||||
}
|
||||
|
||||
// Check repository protocol
|
||||
if (!repo.protocols.includes(context.protocol as TRegistryProtocol)) {
|
||||
throw new Error(`Repository does not support ${context.protocol} protocol`);
|
||||
}
|
||||
|
||||
return context;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is successfully stored
|
||||
* Update database records and metrics
|
||||
*/
|
||||
public async afterStore(context: plugins.smartregistry.IStorageContext): Promise<void> {
|
||||
public async afterPut(
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version || 'unknown';
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
// Get or create package record
|
||||
let pkg = await Package.findById(packageId);
|
||||
if (!pkg) {
|
||||
pkg = new Package();
|
||||
pkg.id = packageId;
|
||||
pkg.organizationId = context.organizationId;
|
||||
pkg.repositoryId = context.repositoryId;
|
||||
pkg.organizationId = orgId;
|
||||
pkg.protocol = protocol;
|
||||
pkg.name = context.packageName;
|
||||
pkg.createdById = context.actorId || '';
|
||||
pkg.name = packageName;
|
||||
pkg.createdById = context.actor?.userId || '';
|
||||
pkg.createdAt = new Date();
|
||||
}
|
||||
|
||||
// Add version
|
||||
pkg.addVersion({
|
||||
version: context.version,
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: context.actorId || '',
|
||||
size: context.size || 0,
|
||||
checksum: context.checksum || '',
|
||||
checksumAlgorithm: context.checksumAlgorithm || 'sha256',
|
||||
publishedById: context.actor?.userId || '',
|
||||
size: context.metadata?.size || 0,
|
||||
digest: context.metadata?.digest,
|
||||
downloads: 0,
|
||||
metadata: context.metadata || {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
// Update dist tags if provided
|
||||
if (context.tags) {
|
||||
for (const [tag, version] of Object.entries(context.tags)) {
|
||||
pkg.distTags[tag] = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Set latest tag if not set
|
||||
if (!pkg.distTags['latest']) {
|
||||
pkg.distTags['latest'] = context.version;
|
||||
pkg.distTags['latest'] = version;
|
||||
}
|
||||
|
||||
await pkg.save();
|
||||
|
||||
// Update organization storage usage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes += context.size || 0;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(context.metadata?.size || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'anonymous',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackagePublished(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version,
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is fetched
|
||||
*/
|
||||
public async beforeFetch(context: plugins.smartregistry.IFetchContext): Promise<plugins.smartregistry.IFetchContext> {
|
||||
return context;
|
||||
if (context.actor?.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).logPackagePublished(packageId, packageName, version, orgId, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is fetched
|
||||
* Update download metrics
|
||||
*/
|
||||
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
|
||||
public async afterGet(
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version;
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (pkg) {
|
||||
await pkg.incrementDownloads(context.version);
|
||||
}
|
||||
|
||||
// Audit log for authenticated users
|
||||
if (context.actorId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: 'user',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).logPackageDownloaded(
|
||||
packageId,
|
||||
context.packageName,
|
||||
context.version || 'latest',
|
||||
context.organizationId,
|
||||
context.repositoryId
|
||||
);
|
||||
await pkg.incrementDownloads(version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before a package is deleted
|
||||
*/
|
||||
public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise<plugins.smartregistry.IDeleteContext> {
|
||||
return context;
|
||||
public async beforeDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a package is deleted
|
||||
*/
|
||||
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
|
||||
public async afterDelete(
|
||||
context: plugins.smartregistry.IStorageHookContext,
|
||||
): Promise<void> {
|
||||
const protocol = context.protocol as TRegistryProtocol;
|
||||
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
|
||||
const packageName = context.metadata?.packageName || context.key;
|
||||
const version = context.metadata?.version;
|
||||
const orgId = context.actor?.orgId || '';
|
||||
|
||||
const packageId = Package.generateId(protocol, orgId, packageName);
|
||||
|
||||
const pkg = await Package.findById(packageId);
|
||||
if (!pkg) return;
|
||||
|
||||
if (context.version) {
|
||||
// Delete specific version
|
||||
const version = pkg.versions[context.version];
|
||||
if (version) {
|
||||
const sizeReduction = version.size;
|
||||
delete pkg.versions[context.version];
|
||||
if (version) {
|
||||
const versionData = pkg.versions[version];
|
||||
if (versionData) {
|
||||
const sizeReduction = versionData.size;
|
||||
delete pkg.versions[version];
|
||||
pkg.storageBytes -= sizeReduction;
|
||||
|
||||
// Update dist tags
|
||||
for (const [tag, ver] of Object.entries(pkg.distTags)) {
|
||||
if (ver === context.version) {
|
||||
if (ver === version) {
|
||||
delete pkg.distTags[tag];
|
||||
}
|
||||
}
|
||||
|
||||
// If no versions left, delete the package
|
||||
if (Object.keys(pkg.versions).length === 0) {
|
||||
await pkg.delete();
|
||||
} else {
|
||||
await pkg.save();
|
||||
}
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(-sizeReduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete entire package
|
||||
const sizeReduction = pkg.storageBytes;
|
||||
await pkg.delete();
|
||||
|
||||
// Update org storage
|
||||
const org = await Organization.findById(context.organizationId);
|
||||
if (org) {
|
||||
org.usedStorageBytes -= sizeReduction;
|
||||
await org.save();
|
||||
if (orgId) {
|
||||
const org = await Organization.findById(orgId);
|
||||
if (org) {
|
||||
await org.updateStorageUsage(-sizeReduction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: context.actorId,
|
||||
actorType: context.actorId ? 'user' : 'system',
|
||||
organizationId: context.organizationId,
|
||||
repositoryId: context.repositoryId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: context.packageName,
|
||||
metadata: { version: context.version },
|
||||
success: true,
|
||||
});
|
||||
if (context.actor?.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: context.actor.userId,
|
||||
actorType: 'user',
|
||||
organizationId: orgId,
|
||||
}).log('PACKAGE_DELETED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,7 +216,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
organizationName: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
filename: string
|
||||
filename: string,
|
||||
): string {
|
||||
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
||||
}
|
||||
@@ -257,13 +227,12 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
public async storeArtifact(
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
contentType?: string
|
||||
contentType?: string,
|
||||
): Promise<string> {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
await bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(data),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
contents: data as unknown as string,
|
||||
});
|
||||
return path;
|
||||
}
|
||||
@@ -273,10 +242,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
*/
|
||||
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
const file = await bucket.fastGet({ path });
|
||||
if (!file) return null;
|
||||
return new Uint8Array(file.contents);
|
||||
return new Uint8Array(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -287,8 +256,8 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
|
||||
*/
|
||||
public async deleteArtifact(path: string): Promise<boolean> {
|
||||
try {
|
||||
const bucket = await this.config.bucket.getBucket();
|
||||
await bucket.fastDelete({ path });
|
||||
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
||||
await bucket.fastRemove({ path });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
335
ts/registry.ts
335
ts/registry.ts
@@ -4,12 +4,47 @@
|
||||
*/
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import { initDb, closeDb, isDbConnected } from './models/db.ts';
|
||||
import { closeDb, initDb, isDbConnected } from './models/db.ts';
|
||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||
import { OpsServer } from './opsserver/classes.opsserver.ts';
|
||||
import { ApiRouter } from './api/router.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 {
|
||||
// MongoDB configuration
|
||||
@@ -42,8 +77,8 @@ export class StackGalleryRegistry {
|
||||
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
||||
private authProvider: StackGalleryAuthProvider | null = null;
|
||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||
private opsServer: OpsServer | null = null;
|
||||
private apiRouter: ApiRouter | null = null;
|
||||
private reloadSocket: ReloadSocketManager | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
@@ -86,6 +121,7 @@ export class StackGalleryRegistry {
|
||||
// Initialize storage hooks
|
||||
this.storageHooks = new StackGalleryStorageHooks({
|
||||
bucket: this.smartBucket,
|
||||
bucketName: this.config.s3Bucket,
|
||||
basePath: this.config.storagePath!,
|
||||
});
|
||||
|
||||
@@ -95,26 +131,38 @@ export class StackGalleryRegistry {
|
||||
authProvider: this.authProvider,
|
||||
storageHooks: this.storageHooks,
|
||||
storage: {
|
||||
type: 's3',
|
||||
bucket: this.smartBucket,
|
||||
basePath: this.config.storagePath,
|
||||
endpoint: this.config.s3Endpoint,
|
||||
accessKey: this.config.s3AccessKey,
|
||||
accessSecret: this.config.s3SecretKey,
|
||||
bucketName: this.config.s3Bucket,
|
||||
region: this.config.s3Region,
|
||||
},
|
||||
upstreamCache: this.config.enableUpstreamCache
|
||||
? {
|
||||
enabled: true,
|
||||
expiryHours: this.config.upstreamCacheExpiry,
|
||||
}
|
||||
: undefined,
|
||||
auth: {
|
||||
jwtSecret: this.config.jwtSecret || 'change-me-in-production',
|
||||
tokenStore: 'database',
|
||||
npmTokens: { enabled: true },
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
realm: `http://${this.config.host === '0.0.0.0' ? 'localhost' : this.config.host}:${this.config.port}/v2/token`,
|
||||
service: 'registry',
|
||||
},
|
||||
},
|
||||
npm: { enabled: true, basePath: '/-/npm' },
|
||||
oci: { enabled: true, basePath: '/v2' },
|
||||
});
|
||||
await this.smartRegistry.init();
|
||||
console.log('[StackGalleryRegistry] smartregistry initialized');
|
||||
|
||||
// Initialize API router
|
||||
// Initialize REST API router
|
||||
console.log('[StackGalleryRegistry] Initializing API router...');
|
||||
this.apiRouter = new ApiRouter();
|
||||
console.log('[StackGalleryRegistry] API router initialized');
|
||||
|
||||
// Initialize reload socket for hot reload
|
||||
this.reloadSocket = new ReloadSocketManager();
|
||||
// Initialize OpsServer (TypedRequest handlers)
|
||||
console.log('[StackGalleryRegistry] Initializing OpsServer...');
|
||||
this.opsServer = new OpsServer(this);
|
||||
await this.opsServer.start();
|
||||
console.log('[StackGalleryRegistry] OpsServer initialized');
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[StackGalleryRegistry] Initialization complete');
|
||||
@@ -137,7 +185,7 @@ export class StackGalleryRegistry {
|
||||
{ port, hostname: host },
|
||||
async (request: Request): Promise<Response> => {
|
||||
return await this.handleRequest(request);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
||||
@@ -155,42 +203,57 @@ export class StackGalleryRegistry {
|
||||
return this.healthCheck();
|
||||
}
|
||||
|
||||
// API endpoints (handled by REST API layer)
|
||||
if (path.startsWith('/api/')) {
|
||||
return await this.handleApiRequest(request);
|
||||
// TypedRequest endpoint (handled by OpsServer TypedRouter)
|
||||
if (path === '/typedrequest' && request.method === 'POST') {
|
||||
return await this.handleTypedRequest(request);
|
||||
}
|
||||
|
||||
// Registry protocol endpoints (handled by smartregistry)
|
||||
// NPM: /-/..., /@scope/package (but not /packages which is UI route)
|
||||
// OCI: /v2/...
|
||||
// Maven: /maven2/...
|
||||
// PyPI: /simple/..., /pypi/...
|
||||
// Cargo: /api/v1/crates/...
|
||||
// Composer: /packages.json, /p/...
|
||||
// RubyGems: /api/v1/gems/..., /gems/...
|
||||
const registryPaths = ['/-/', '/v2/', '/maven2/', '/simple/', '/pypi/', '/api/v1/crates/', '/packages.json', '/p/', '/api/v1/gems/', '/gems/'];
|
||||
const isRegistryPath = registryPaths.some(p => path.startsWith(p)) ||
|
||||
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
||||
|
||||
if (this.smartRegistry && isRegistryPath) {
|
||||
// NPM: /-/npm/{orgName}/... -> strip orgName, forward as /-/npm/...
|
||||
// OCI: /v2/{orgName}/... -> forward as /v2/{orgName}/... (OCI uses name segments natively)
|
||||
if (this.smartRegistry) {
|
||||
try {
|
||||
const response = await this.smartRegistry.handleRequest(request);
|
||||
if (response) return response;
|
||||
// NPM protocol: extract org from /-/npm/{orgName}/...
|
||||
if (path.startsWith('/-/npm/')) {
|
||||
const orgMatch = path.match(/^\/-\/npm\/([^\/]+)(\/.*)?$/);
|
||||
if (orgMatch) {
|
||||
const orgName = decodeURIComponent(orgMatch[1]);
|
||||
const remainder = orgMatch[2] || '/';
|
||||
const requestContext = await this.requestToContext(request);
|
||||
requestContext.path = `/-/npm${remainder}`;
|
||||
if (!requestContext.actor) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
requestContext.actor = {} as any;
|
||||
}
|
||||
requestContext.actor!.orgId = orgName;
|
||||
const response = await this.smartRegistry.handleRequest(requestContext);
|
||||
if (response) return this.contextResponseToResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
// OCI token endpoint: /v2/token (Docker Bearer auth flow)
|
||||
if (path === '/v2/token') {
|
||||
return this.handleOciTokenRequest(request);
|
||||
}
|
||||
|
||||
// OCI protocol: /v2/... or /v2
|
||||
if (path.startsWith('/v2/') || path === '/v2') {
|
||||
const requestContext = await this.requestToContext(request);
|
||||
const response = await this.smartRegistry.handleRequest(requestContext);
|
||||
if (response) return this.contextResponseToResponse(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[StackGalleryRegistry] Request error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Internal server error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket upgrade for hot reload
|
||||
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
|
||||
return this.reloadSocket!.handleUpgrade(request);
|
||||
// REST API endpoints
|
||||
if (this.apiRouter && path.startsWith('/api/')) {
|
||||
return this.apiRouter.handle(request);
|
||||
}
|
||||
|
||||
// Serve static UI files
|
||||
@@ -198,24 +261,104 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from embedded UI
|
||||
* Convert a Deno Request to smartregistry IRequestContext
|
||||
*/
|
||||
private async requestToContext(
|
||||
request: Request,
|
||||
): Promise<plugins.smartregistry.IRequestContext> {
|
||||
const url = new URL(request.url);
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const query: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
let body: unknown = undefined;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
let rawBody: any = undefined;
|
||||
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
try {
|
||||
const bytes = new Uint8Array(await request.arrayBuffer());
|
||||
rawBody = bytes;
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('json')) {
|
||||
body = JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
} catch {
|
||||
// Body parsing failed, continue with undefined body
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
let token: string | undefined;
|
||||
const authHeader = headers['authorization'];
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
return {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
headers,
|
||||
query,
|
||||
body,
|
||||
rawBody,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert smartregistry IResponse to Deno Response
|
||||
*/
|
||||
private contextResponseToResponse(response: plugins.smartregistry.IResponse): Response {
|
||||
const headers = new Headers(response.headers || {});
|
||||
let body: BodyInit | null = null;
|
||||
|
||||
if (response.body !== undefined) {
|
||||
if (typeof response.body === 'string') {
|
||||
body = response.body;
|
||||
} else if (response.body instanceof Uint8Array) {
|
||||
body = response.body as unknown as BodyInit;
|
||||
} else {
|
||||
body = JSON.stringify(response.body);
|
||||
if (!headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from bundled UI
|
||||
*/
|
||||
private serveStaticFile(path: string): Response {
|
||||
if (!bundledFileMap) {
|
||||
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
|
||||
}
|
||||
|
||||
const filePath = path === '/' ? '/index.html' : path;
|
||||
|
||||
// Get embedded file
|
||||
const embeddedFile = getEmbeddedFile(filePath);
|
||||
if (embeddedFile) {
|
||||
return new Response(embeddedFile.data, {
|
||||
// Get bundled file
|
||||
const file = bundledFileMap.get(filePath);
|
||||
if (file) {
|
||||
return new Response(file.data as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': embeddedFile.contentType },
|
||||
headers: { 'Content-Type': file.contentType },
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for unknown paths
|
||||
const indexFile = getEmbeddedFile('/index.html');
|
||||
const indexFile = bundledFileMap.get('/index.html');
|
||||
if (indexFile) {
|
||||
return new Response(indexFile.data, {
|
||||
return new Response(indexFile.data as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
@@ -225,20 +368,81 @@ export class StackGalleryRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests
|
||||
* Handle TypedRequest calls
|
||||
*/
|
||||
private async handleApiRequest(request: Request): Promise<Response> {
|
||||
if (!this.apiRouter) {
|
||||
private async handleTypedRequest(request: Request): Promise<Response> {
|
||||
if (!this.opsServer) {
|
||||
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[StackGalleryRegistry] TypedRequest error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'API router not initialized' }),
|
||||
JSON.stringify({ error: message }),
|
||||
{
|
||||
status: 503,
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OCI token requests (Docker Bearer auth flow)
|
||||
* Docker sends GET /v2/token?service=...&scope=... to obtain a Bearer token
|
||||
*/
|
||||
private async handleOciTokenRequest(request: Request): Promise<Response> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
let apiToken: string | undefined;
|
||||
|
||||
// Extract token from Basic auth (Docker sends username:password)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
const credentials = atob(authHeader.substring(6));
|
||||
const [_username, password] = credentials.split(':');
|
||||
if (password) {
|
||||
apiToken = password;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token from Bearer auth
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
apiToken = authHeader.substring(7);
|
||||
}
|
||||
|
||||
if (apiToken) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: apiToken,
|
||||
access_token: apiToken,
|
||||
expires_in: 3600,
|
||||
issued_at: new Date().toISOString(),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return await this.apiRouter.handle(request);
|
||||
// No auth provided — return 401
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'authentication required' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +472,9 @@ export class StackGalleryRegistry {
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
console.log('[StackGalleryRegistry] Shutting down...');
|
||||
if (this.opsServer) {
|
||||
await this.opsServer.stop();
|
||||
}
|
||||
await closeDb();
|
||||
this.isInitialized = false;
|
||||
console.log('[StackGalleryRegistry] Shutdown complete');
|
||||
@@ -336,7 +543,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
|
||||
|
||||
const config: IRegistryConfig = {
|
||||
mongoUrl: env.MONGODB_URL || `mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${env.MONGODB_PORT || '27017'}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoUrl: env.MONGODB_URL ||
|
||||
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${
|
||||
env.MONGODB_PORT || '27017'
|
||||
}/${env.MONGODB_NAME}?authSource=admin`,
|
||||
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
||||
s3Endpoint: s3Endpoint,
|
||||
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
||||
@@ -356,7 +566,10 @@ export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry>
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables');
|
||||
} else {
|
||||
console.warn('[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:', error);
|
||||
console.warn(
|
||||
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return createRegistryFromEnv();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<AuditLog> {
|
||||
return await AuditLog.log({
|
||||
actorId: this.context.actorId,
|
||||
@@ -75,7 +75,7 @@ export class AuditService {
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId?: string,
|
||||
resourceName?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -94,7 +94,7 @@ export class AuditService {
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -107,11 +107,21 @@ export class AuditService {
|
||||
|
||||
// Convenience methods for common actions
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
public async logUserLogin(
|
||||
userId: string,
|
||||
success: boolean,
|
||||
errorMessage?: string,
|
||||
): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('AUTH_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
return await this.logFailure(
|
||||
'AUTH_LOGIN',
|
||||
'user',
|
||||
'LOGIN_FAILED',
|
||||
errorMessage || 'Login failed',
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
@@ -131,7 +141,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUSHED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -148,7 +158,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PULLED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -167,7 +177,7 @@ export class AuditService {
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPO_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
@@ -182,7 +192,7 @@ export class AuditService {
|
||||
resourceId: string,
|
||||
targetUserId: string,
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
newRole: string | null,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { User, Session } from '../models/index.ts';
|
||||
import { Session, User } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface IJwtPayload {
|
||||
@@ -52,7 +52,7 @@ export class AuthService {
|
||||
public async login(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { userAgent?: string; ipAddress?: string } = {}
|
||||
options: { userAgent?: string; ipAddress?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: options.ipAddress,
|
||||
@@ -195,7 +195,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logout(
|
||||
sessionId: string,
|
||||
options: { userId?: string; ipAddress?: string } = {}
|
||||
options: { userId?: string; ipAddress?: string } = {},
|
||||
): Promise<boolean> {
|
||||
const session = await Session.findValidSession(sessionId);
|
||||
if (!session) return false;
|
||||
@@ -218,7 +218,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logoutAll(
|
||||
userId: string,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<number> {
|
||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||
|
||||
@@ -238,7 +238,9 @@ export class AuthService {
|
||||
/**
|
||||
* Validate access token and return user
|
||||
*/
|
||||
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
|
||||
public async validateAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<{ user: User; sessionId: string } | null> {
|
||||
const payload = await this.verifyToken(accessToken);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
|
||||
@@ -339,7 +341,7 @@ export class AuthService {
|
||||
encoder.encode(this.config.jwtSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ILdapEntry {
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
@@ -55,7 +55,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
password,
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
@@ -86,7 +86,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
config.baseDn,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -129,7 +129,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
userPassword: string,
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
@@ -150,7 +150,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
baseDn: string,
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
@@ -185,7 +185,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
console.log(
|
||||
'[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,15 +208,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
username: entry[mapping.username] ? String(entry[mapping.username]) : undefined,
|
||||
displayName: entry[mapping.displayName] ? String(entry[mapping.displayName]) : undefined,
|
||||
groups: mapping.groups ? this.parseGroups(entry[mapping.groups]) : undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -243,19 +243,15 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: rawInfo[mapping.username]
|
||||
? String(rawInfo[mapping.username])
|
||||
: undefined,
|
||||
displayName: rawInfo[mapping.displayName]
|
||||
? String(rawInfo[mapping.displayName])
|
||||
: undefined,
|
||||
username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined,
|
||||
displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined,
|
||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||
? String(rawInfo[mapping.avatarUrl])
|
||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||
groups: mapping.groups && rawInfo[mapping.groups]
|
||||
? (Array.isArray(rawInfo[mapping.groups])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
: undefined,
|
||||
rawAttributes: rawInfo,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export class CryptoService {
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)',
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
@@ -50,9 +50,9 @@ export class CryptoService {
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encoded
|
||||
encoded.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
@@ -86,9 +86,9 @@ export class CryptoService {
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encrypted
|
||||
encrypted.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
@@ -120,10 +120,10 @@ export class CryptoService {
|
||||
const keyBytes = this.hexToBytes(keyHex);
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import {
|
||||
AuthProvider,
|
||||
ExternalIdentity,
|
||||
PlatformSettings,
|
||||
Session,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
import type { IConnectionTestResult, IExternalUserInfo } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
@@ -33,7 +39,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
returnUrl?: string,
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
@@ -67,7 +73,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
@@ -103,7 +109,7 @@ export class ExternalAuthService {
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
await this.auditService.log('AUTH_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
@@ -143,7 +149,7 @@ export class ExternalAuthService {
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
}).log('AUTH_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
@@ -170,7 +176,7 @@ export class ExternalAuthService {
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
@@ -194,7 +200,7 @@ export class ExternalAuthService {
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
await this.auditService.log('AUTH_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
@@ -235,7 +241,7 @@ export class ExternalAuthService {
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
}).log('AUTH_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
@@ -261,7 +267,7 @@ export class ExternalAuthService {
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
externalUser: IExternalUserInfo,
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
@@ -377,12 +383,12 @@ export class ExternalAuthService {
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
externalUser.externalId,
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
@@ -544,12 +550,12 @@ export class ExternalAuthService {
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
String.fromCharCode(...new Uint8Array(signature)),
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||
export {
|
||||
TokenService,
|
||||
type ICreateTokenOptions,
|
||||
type ITokenValidationResult,
|
||||
TokenService,
|
||||
} from './token.service.ts';
|
||||
export {
|
||||
PermissionService,
|
||||
type TAction,
|
||||
type IPermissionContext,
|
||||
type IResolvedPermissions,
|
||||
PermissionService,
|
||||
type TAction,
|
||||
} from './permission.service.ts';
|
||||
export {
|
||||
AuthService,
|
||||
type IJwtPayload,
|
||||
type IAuthResult,
|
||||
type IAuthConfig,
|
||||
type IAuthResult,
|
||||
type IJwtPayload,
|
||||
} from './auth.service.ts';
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRegistryProtocol,
|
||||
TRepositoryRole,
|
||||
TTeamRole,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import {
|
||||
User,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
Repository,
|
||||
RepositoryPermission,
|
||||
Team,
|
||||
TeamMember,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
|
||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||
@@ -71,7 +71,10 @@ export class PermissionService {
|
||||
if (!context.organizationId) return result;
|
||||
|
||||
// Get organization membership
|
||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
||||
const orgMember = await OrganizationMember.findMembership(
|
||||
context.organizationId,
|
||||
context.userId,
|
||||
);
|
||||
if (orgMember) {
|
||||
result.organizationRole = orgMember.role;
|
||||
|
||||
@@ -137,7 +140,10 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
// Get direct repository permission (highest priority)
|
||||
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
|
||||
const repoPerm = await RepositoryPermission.findPermission(
|
||||
context.repositoryId,
|
||||
context.userId,
|
||||
);
|
||||
if (repoPerm) {
|
||||
result.repositoryRole = repoPerm.role;
|
||||
this.applyRole(result, repoPerm.role);
|
||||
@@ -151,7 +157,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async checkPermission(
|
||||
context: IPermissionContext,
|
||||
action: TAction
|
||||
action: TAction,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions(context);
|
||||
|
||||
@@ -176,11 +182,11 @@ export class PermissionService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string,
|
||||
action: 'read' | 'write' | 'delete'
|
||||
action: 'read' | 'write' | 'delete',
|
||||
): Promise<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +208,7 @@ export class PermissionService {
|
||||
public async canManageRepository(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions({
|
||||
userId,
|
||||
@@ -217,7 +223,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async getAccessibleRepositories(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<Repository[]> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return [];
|
||||
|
||||
@@ -37,7 +37,9 @@ export class TokenService {
|
||||
* Generate a new API token
|
||||
* Returns the raw token (only shown once) and the saved token record
|
||||
*/
|
||||
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
public async createToken(
|
||||
options: ICreateTokenOptions,
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
// Generate secure random token: srg_{64 hex chars}
|
||||
const randomBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
@@ -206,7 +208,7 @@ export class TokenService {
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
action?: string,
|
||||
): boolean {
|
||||
if (!token.hasProtocol(protocol)) return false;
|
||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||
|
||||
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';
|
||||
55
ts_interfaces/data/organization.ts
Normal file
55
ts_interfaces/data/organization.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// ============================================================================
|
||||
// Organization Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type TOrganizationPlan = 'free' | 'team' | 'enterprise';
|
||||
export type TOrganizationRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface IOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
website?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
plan: TOrganizationPlan;
|
||||
usedStorageBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationDetail extends IOrganization {
|
||||
settings?: IOrganizationSettings;
|
||||
}
|
||||
|
||||
export interface IOrganizationSettings {
|
||||
requireMfa: boolean;
|
||||
allowPublicRepositories: boolean;
|
||||
defaultRepositoryVisibility: TRepositoryVisibility;
|
||||
allowedProtocols: TRegistryProtocol[];
|
||||
}
|
||||
|
||||
export interface IOrganizationMember {
|
||||
userId: string;
|
||||
role: TOrganizationRole;
|
||||
addedAt: string;
|
||||
user: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface IOrgRedirect {
|
||||
id: string;
|
||||
oldName: string;
|
||||
organizationId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Re-export types used by settings
|
||||
import type { TRepositoryVisibility } from './repository.ts';
|
||||
import type { TRegistryProtocol } from './package.ts';
|
||||
export type { TRegistryProtocol, TRepositoryVisibility };
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user