24 Commits
v1.4.0 ... main

Author SHA1 Message Date
27955c6a7b v1.8.5
All checks were successful
Release / build-and-release (push) Successful in 2m16s
2026-03-22 08:59:34 +00:00
3b2aa57b7d fix(registry): restore protocol routing and test coverage for npm, oci, and api flows 2026-03-22 08:59:34 +00:00
2d84470688 v1.8.4
All checks were successful
Release / build-and-release (push) Successful in 2m29s
2026-03-21 11:06:14 +00:00
883fc1d22b fix(deps): bump @stack.gallery/catalog to ^1.0.2 and remove committed test fixture auth token 2026-03-21 11:06:14 +00:00
6961ac7e27 v1.8.3
Some checks failed
Release / build-and-release (push) Failing after 12s
2026-03-21 11:01:14 +00:00
fae8147414 fix(test-fixtures): update npm fixture registry configuration for scoped package installs 2026-03-21 11:01:14 +00:00
c589476590 v1.8.2
Some checks failed
Release / build-and-release (push) Failing after 14s
2026-03-21 11:00:24 +00:00
03529bc140 fix(deps): replace local catalog dependency with published version and simplify npm fixture auth config 2026-03-21 11:00:24 +00:00
ffade4d5ca v1.8.1
Some checks failed
Release / build-and-release (push) Failing after 19s
2026-03-21 10:58:44 +00:00
9c4636906a fix(release,test): streamline release UI bundling and add npm fixture registry configuration 2026-03-21 10:58:44 +00:00
f44b03b47d v1.8.0
Some checks failed
Release / build-and-release (push) Failing after 19s
2026-03-21 10:54:10 +00:00
6d6ed61e70 feat(web): add public package browsing and organization redirect management 2026-03-21 10:54:10 +00:00
392060bf23 v1.7.0
Some checks failed
Release / build-and-release (push) Failing after 7s
2026-03-20 17:07:12 +00:00
8cb5e4fa96 feat(organization): add organization rename redirects and redirect management endpoints 2026-03-20 17:07:12 +00:00
c60a0ed536 v1.6.0
Some checks failed
Release / build-and-release (push) Failing after 23s
2026-03-20 16:48:04 +00:00
087b8c0bb3 feat(web-organizations): add organization detail editing and isolate detail view state from global navigation 2026-03-20 16:48:04 +00:00
ffe7ffbde9 v1.5.1
Some checks failed
Release / build-and-release (push) Failing after 26s
2026-03-20 16:44:44 +00:00
b9a3d79b5f fix(web-app): update dashboard navigation to use the router directly and refresh admin tabs on login changes 2026-03-20 16:44:44 +00:00
aacf30e582 v1.5.0
Some checks failed
Release / build-and-release (push) Failing after 22s
2026-03-20 16:43:44 +00:00
d4f758ce0f feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend 2026-03-20 16:43:44 +00:00
0fc74ff995 v1.4.2
All checks were successful
Release / build-and-release (push) Successful in 3m43s
2026-03-20 14:14:39 +00:00
d71ae08645 fix(registry): align registry integrations with updated auth, storage, repository, and audit models 2026-03-20 14:14:39 +00:00
fe3cb75095 v1.4.1
All checks were successful
Release / build-and-release (push) Successful in 3m49s
2026-03-20 13:57:11 +00:00
f76778ce45 fix(repo): no changes to commit 2026-03-20 13:57:11 +00:00
166 changed files with 13620 additions and 15346 deletions

View File

@@ -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
View File

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

View File

@@ -1,59 +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.
@@ -72,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.

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.4.0",
"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",

3088
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

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

33
html/index.html Normal file
View File

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

View File

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

View File

@@ -1,13 +1,13 @@
{
"name": "@stack.gallery/registry",
"version": "1.4.0",
"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

File diff suppressed because it is too large Load Diff

296
readme.md
View File

@@ -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

View File

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

View File

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

View File

@@ -6,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

View File

@@ -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 {

View File

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

View File

@@ -1,34 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.stacktest</groupId>
<artifactId>demo-artifact</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Stack.Gallery Demo Artifact</name>
<description>Demo Maven artifact for e2e tests</description>
<url>https://github.com/stack-gallery/demo-artifact</url>
<project
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd"
>
<modelVersion>4.0.0</modelVersion>
<groupId>com.stacktest</groupId>
<artifactId>demo-artifact</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Stack.Gallery Demo Artifact</name>
<description>Demo Maven artifact for e2e tests</description>
<url>https://github.com/stack-gallery/demo-artifact</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<developers>
<developer>
<name>Stack.Gallery Test</name>
<email>test@stack.gallery</email>
</developer>
</developers>
<developers>
<developer>
<name>Stack.Gallery Test</name>
<email>test@stack.gallery</email>
</developer>
</developers>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,7 +75,7 @@ export const del = (path: string, headers?: Record<string, string>) =>
export function assertStatus(response: ITestResponse, expected: number): void {
if (response.status !== expected) {
throw new Error(
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`,
);
}
}
@@ -98,7 +98,7 @@ export function assertBodyHas(response: ITestResponse, keys: string[]): void {
export function assertSuccess(response: ITestResponse): void {
if (response.status < 200 || response.status >= 300) {
throw new Error(
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`,
);
}
}

View File

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

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

View File

@@ -22,7 +22,7 @@ export interface ICommandOptions {
*/
export async function runCommand(
cmd: string[],
options: ICommandOptions = {}
options: ICommandOptions = {},
): Promise<ICommandResult> {
const { cwd, env, timeout = 60000, stdin } = options;
@@ -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 }),
},

View File

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

View File

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

View File

@@ -7,10 +7,10 @@ import { Qenv } from '@push.rocks/qenv';
const testQenv = new Qenv('./', '.nogit/', false);
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL')
|| 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME')
|| 'test-registry';
const mongoUrl = await testQenv.getEnvVarOnDemand('MONGODB_URL') ||
'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin';
const mongoName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') ||
'test-registry';
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT') || 'localhost';
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT') || '9100';

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
import { ApiToken } from '../../../ts/models/apitoken.ts';
describe('ApiToken Model', () => {

View File

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

View File

@@ -3,14 +3,14 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import {
setupTestDb,
teardownTestDb,
cleanupTestDb,
createTestUser,
createOrgWithOwner,
createTestRepository,
createTestUser,
setupTestDb,
teardownTestDb,
} from '../../helpers/index.ts';
import { Package } from '../../../ts/models/package.ts';
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';

View File

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

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
import { Session } from '../../../ts/models/session.ts';
describe('Session Model', () => {
@@ -70,9 +70,21 @@ describe('Session Model', () => {
describe('getUserSessions', () => {
it('should return all valid sessions for user', async () => {
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 1',
ipAddress: '1.1.1.1',
});
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 2',
ipAddress: '2.2.2.2',
});
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 3',
ipAddress: '3.3.3.3',
});
const sessions = await Session.getUserSessions(testUserId);
assertEquals(sessions.length, 3);
@@ -110,9 +122,21 @@ describe('Session Model', () => {
describe('invalidateAllUserSessions', () => {
it('should invalidate all user sessions', async () => {
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 1',
ipAddress: '1.1.1.1',
});
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 2',
ipAddress: '2.2.2.2',
});
await Session.createSession({
userId: testUserId,
userAgent: 'Agent 3',
ipAddress: '3.3.3.3',
});
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
assertEquals(count, 3);

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
import { User } from '../../../ts/models/user.ts';
describe('User Model', () => {

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
import { AuthService } from '../../../ts/services/auth.service.ts';
import { Session } from '../../../ts/models/session.ts';
import { testConfig } from '../../test.config.ts';

View File

@@ -3,8 +3,8 @@
*/
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
import { afterAll, beforeAll, beforeEach, describe, it } from 'jsr:@std/testing/bdd';
import { cleanupTestDb, createTestUser, setupTestDb, teardownTestDb } from '../../helpers/index.ts';
import { TokenService } from '../../../ts/services/token.service.ts';
import { ApiToken } from '../../../ts/models/apitoken.ts';
@@ -39,8 +39,8 @@ describe('TokenService', () => {
assertExists(result.rawToken);
assertExists(result.token);
// Check token format: srg_{prefix}_{random}
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
// Check token format: srg_ + 64 hex chars
assertMatch(result.rawToken, /^srg_[a-f0-9]{64}$/);
assertEquals(result.token.name, 'test-token');
assertEquals(result.token.protocols.includes('npm'), true);
assertEquals(result.token.protocols.includes('oci'), true);
@@ -111,13 +111,19 @@ describe('TokenService', () => {
it('should reject invalid token format', async () => {
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'INVALID_TOKEN_FORMAT');
});
it('should reject non-existent token', async () => {
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
// Must match srg_ prefix + 64 hex chars = 68 total
const validation = await tokenService.validateToken(
'srg_0000000000000000000000000000000000000000000000000000000000000000',
'127.0.0.1',
);
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
});
it('should reject revoked token', async () => {
@@ -132,7 +138,9 @@ describe('TokenService', () => {
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
assertEquals(validation.valid, false);
// findByHash excludes revoked tokens, so the token is not found
assertEquals(validation.errorCode, 'TOKEN_NOT_FOUND');
});
it('should reject expired token', async () => {
@@ -150,7 +158,8 @@ describe('TokenService', () => {
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
assertEquals(validation, null);
assertEquals(validation.valid, false);
assertEquals(validation.errorCode, 'TOKEN_EXPIRED');
});
it('should record usage on validation', async () => {

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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' } };
}

View File

@@ -50,7 +50,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (canAccess) {
accessiblePackages.push(pkg);
@@ -106,7 +106,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (!canAccess) {
@@ -161,7 +161,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'read'
'read',
);
if (!canAccess) {
@@ -174,7 +174,7 @@ export class PackageApi {
publishedAt: data.publishedAt,
size: data.size,
downloads: data.downloads,
checksum: data.checksum,
checksum: data.metadata?.checksum,
}));
return {
@@ -213,7 +213,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {
@@ -267,7 +267,7 @@ export class PackageApi {
ctx.actor.userId,
pkg.organizationId,
pkg.repositoryId,
'delete'
'delete',
);
if (!canDelete) {

View File

@@ -5,8 +5,8 @@
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import { Repository, Organization } from '../../models/index.ts';
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
import { Organization, Repository } from '../../models/index.ts';
import type { TRegistryProtocol, TRepositoryVisibility } from '../../interfaces/auth.interfaces.ts';
export class RepositoryApi {
private permissionService: PermissionService;
@@ -26,10 +26,9 @@ export class RepositoryApi {
const { orgId } = ctx.params;
try {
// Get accessible repositories
const repositories = await this.permissionService.getAccessibleRepositories(
ctx.actor.userId,
orgId
orgId,
);
return {
@@ -38,9 +37,9 @@ export class RepositoryApi {
repositories: repositories.map((repo) => ({
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
packageCount: repo.packageCount,
createdAt: repo.createdAt,
@@ -84,11 +83,10 @@ export class RepositoryApi {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
settings: repo.settings,
packageCount: repo.packageCount,
storageBytes: repo.storageBytes,
createdAt: repo.createdAt,
@@ -118,17 +116,25 @@ export class RepositoryApi {
try {
const body = await ctx.request.json();
const { name, displayName, description, protocols, isPublic, settings } = body;
const { name, description, protocol, visibility } = body as {
name: string;
description?: string;
protocol?: TRegistryProtocol;
visibility?: TRepositoryVisibility;
};
if (!name) {
return { status: 400, body: { error: 'Repository name is required' } };
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(name)) {
return {
status: 400,
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
body: {
error:
'Name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
},
};
}
@@ -138,30 +144,15 @@ export class RepositoryApi {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check if name is taken in this org
const existing = await Repository.findByName(orgId, name);
if (existing) {
return { status: 409, body: { error: 'Repository name already taken in this organization' } };
}
// Create repository
const repo = new Repository();
repo.id = await Repository.getNewId();
repo.organizationId = orgId;
repo.name = name;
repo.displayName = displayName || name;
repo.description = description;
repo.protocols = protocols || ['npm'];
repo.isPublic = isPublic ?? false;
repo.settings = settings || {
allowOverwrite: false,
immutableTags: false,
retentionDays: 0,
};
repo.createdAt = new Date();
repo.createdById = ctx.actor.userId;
await repo.save();
// Create repository using the model's factory method
const repo = await Repository.createRepository({
organizationId: orgId,
name,
description,
protocol: protocol || 'npm',
visibility: visibility || 'private',
createdById: ctx.actor.userId,
});
// Audit log
await AuditService.withContext({
@@ -177,9 +168,9 @@ export class RepositoryApi {
id: repo.id,
organizationId: repo.organizationId,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
createdAt: repo.createdAt,
},
@@ -210,20 +201,20 @@ export class RepositoryApi {
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
const body = await ctx.request.json();
const { displayName, description, protocols, isPublic, settings } = body;
const { description, visibility } = body as {
description?: string;
visibility?: TRepositoryVisibility;
};
if (displayName !== undefined) repo.displayName = displayName;
if (description !== undefined) repo.description = description;
if (protocols !== undefined) repo.protocols = protocols;
if (isPublic !== undefined) repo.isPublic = isPublic;
if (settings !== undefined) repo.settings = { ...repo.settings, ...settings };
if (visibility !== undefined) repo.visibility = visibility;
await repo.save();
@@ -232,11 +223,10 @@ export class RepositoryApi {
body: {
id: repo.id,
name: repo.name,
displayName: repo.displayName,
description: repo.description,
protocols: repo.protocols,
protocol: repo.protocol,
visibility: repo.visibility,
isPublic: repo.isPublic,
settings: repo.settings,
},
};
} catch (error) {
@@ -265,7 +255,7 @@ export class RepositoryApi {
const canManage = await this.permissionService.canManageRepository(
ctx.actor.userId,
repo.organizationId,
id
id,
);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };

View File

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

View File

@@ -137,8 +137,8 @@ export class UserApi {
user.username = username;
user.passwordHash = passwordHash;
user.displayName = displayName || username;
user.isSystemAdmin = isSystemAdmin || false;
user.isActive = true;
user.isPlatformAdmin = isSystemAdmin || false;
user.status = 'active';
user.createdAt = new Date();
await user.save();
@@ -189,8 +189,8 @@ export class UserApi {
// Only admins can change these
if (ctx.actor.user?.isSystemAdmin) {
if (isActive !== undefined) user.isActive = isActive;
if (isSystemAdmin !== undefined) user.isSystemAdmin = isSystemAdmin;
if (isActive !== undefined) user.status = isActive ? 'active' : 'suspended';
if (isSystemAdmin !== undefined) user.isPlatformAdmin = isSystemAdmin;
}
// Password change
@@ -245,7 +245,7 @@ export class UserApi {
}
// Soft delete - deactivate instead of removing
user.isActive = false;
user.status = 'suspended';
await user.save();
return {

View File

@@ -104,24 +104,56 @@ export class ApiRouter {
this.addRoute('POST', '/api/v1/organizations', (ctx) => this.organizationApi.create(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id', (ctx) => this.organizationApi.update(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id', (ctx) => this.organizationApi.delete(ctx));
this.addRoute('GET', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.listMembers(ctx));
this.addRoute('POST', '/api/v1/organizations/:id/members', (ctx) => this.organizationApi.addMember(ctx));
this.addRoute('PUT', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.updateMember(ctx));
this.addRoute('DELETE', '/api/v1/organizations/:id/members/:userId', (ctx) => this.organizationApi.removeMember(ctx));
this.addRoute(
'GET',
'/api/v1/organizations/:id/members',
(ctx) => this.organizationApi.listMembers(ctx),
);
this.addRoute(
'POST',
'/api/v1/organizations/:id/members',
(ctx) => this.organizationApi.addMember(ctx),
);
this.addRoute(
'PUT',
'/api/v1/organizations/:id/members/:userId',
(ctx) => this.organizationApi.updateMember(ctx),
);
this.addRoute(
'DELETE',
'/api/v1/organizations/:id/members/:userId',
(ctx) => this.organizationApi.removeMember(ctx),
);
// Repository routes
this.addRoute('GET', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.list(ctx));
this.addRoute(
'GET',
'/api/v1/organizations/:orgId/repositories',
(ctx) => this.repositoryApi.list(ctx),
);
this.addRoute('GET', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.get(ctx));
this.addRoute('POST', '/api/v1/organizations/:orgId/repositories', (ctx) => this.repositoryApi.create(ctx));
this.addRoute(
'POST',
'/api/v1/organizations/:orgId/repositories',
(ctx) => this.repositoryApi.create(ctx),
);
this.addRoute('PUT', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.update(ctx));
this.addRoute('DELETE', '/api/v1/repositories/:id', (ctx) => this.repositoryApi.delete(ctx));
// Package routes
this.addRoute('GET', '/api/v1/packages', (ctx) => this.packageApi.search(ctx));
this.addRoute('GET', '/api/v1/packages/:id', (ctx) => this.packageApi.get(ctx));
this.addRoute('GET', '/api/v1/packages/:id/versions', (ctx) => this.packageApi.listVersions(ctx));
this.addRoute(
'GET',
'/api/v1/packages/:id/versions',
(ctx) => this.packageApi.listVersions(ctx),
);
this.addRoute('DELETE', '/api/v1/packages/:id', (ctx) => this.packageApi.delete(ctx));
this.addRoute('DELETE', '/api/v1/packages/:id/versions/:version', (ctx) => this.packageApi.deleteVersion(ctx));
this.addRoute(
'DELETE',
'/api/v1/packages/:id/versions/:version',
(ctx) => this.packageApi.deleteVersion(ctx),
);
// Token routes
this.addRoute('GET', '/api/v1/tokens', (ctx) => this.tokenApi.list(ctx));
@@ -138,14 +170,46 @@ export class ApiRouter {
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
// Admin auth routes (platform admin only)
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
this.addRoute(
'GET',
'/api/v1/admin/auth/providers',
(ctx) => this.adminAuthApi.listProviders(ctx),
);
this.addRoute(
'POST',
'/api/v1/admin/auth/providers',
(ctx) => this.adminAuthApi.createProvider(ctx),
);
this.addRoute(
'GET',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.getProvider(ctx),
);
this.addRoute(
'PUT',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.updateProvider(ctx),
);
this.addRoute(
'DELETE',
'/api/v1/admin/auth/providers/:id',
(ctx) => this.adminAuthApi.deleteProvider(ctx),
);
this.addRoute(
'POST',
'/api/v1/admin/auth/providers/:id/test',
(ctx) => this.adminAuthApi.testProvider(ctx),
);
this.addRoute(
'GET',
'/api/v1/admin/auth/settings',
(ctx) => this.adminAuthApi.getSettings(ctx),
);
this.addRoute(
'PUT',
'/api/v1/admin/auth/settings',
(ctx) => this.adminAuthApi.updateSettings(ctx),
);
}
/**

View File

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

View File

@@ -51,6 +51,13 @@ export type TAuditAction =
| 'PACKAGE_PULLED'
| 'PACKAGE_DELETED'
| 'PACKAGE_DEPRECATED'
// Auth Provider Management
| 'AUTH_PROVIDER_CREATED'
| 'AUTH_PROVIDER_UPDATED'
| 'AUTH_PROVIDER_DELETED'
| 'AUTH_PROVIDER_TESTED'
// Platform Settings
| 'PLATFORM_SETTINGS_UPDATED'
// Security Events
| 'SECURITY_SCAN_COMPLETED'
| 'SECURITY_VULNERABILITY_FOUND'
@@ -65,6 +72,8 @@ export type TAuditResourceType =
| 'package'
| 'api_token'
| 'session'
| 'auth_provider'
| 'platform_settings'
| 'system';
// =============================================================================

View File

@@ -103,7 +103,14 @@ export interface ITeamMember {
export type TRepositoryVisibility = 'public' | 'private' | 'internal';
export type TRepositoryRole = 'admin' | 'maintainer' | 'developer' | 'reader';
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
export type TRegistryProtocol =
| 'oci'
| 'npm'
| 'maven'
| 'cargo'
| 'composer'
| 'pypi'
| 'rubygems';
export interface IRepository {
id: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -18,7 +18,8 @@ const DEFAULT_SETTINGS: IOrganizationSettings = {
};
@plugins.smartdata.Collection(() => db)
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization> implements IOrganization {
export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization, Organization>
implements IOrganization {
@plugins.smartdata.unI()
public id: string = '';
@@ -92,7 +93,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name)) {
throw new Error(
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
'Organization name must be lowercase alphanumeric with optional hyphens and dots',
);
}

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,16 @@
*/
import * as plugins from '../plugins.ts';
import type { IRepository, TRepositoryVisibility, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import type {
IRepository,
TRegistryProtocol,
TRepositoryVisibility,
} from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository> implements IRepository {
export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Repository>
implements IRepository {
@plugins.smartdata.unI()
public id: string = '';
@@ -39,6 +44,12 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
@plugins.smartdata.svDb()
public starCount: number = 0;
@plugins.smartdata.svDb()
public packageCount: number = 0;
@plugins.smartdata.svDb()
public storageBytes: number = 0;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@@ -64,7 +75,9 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
// Validate name
const nameRegex = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name.toLowerCase())) {
throw new Error('Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores');
throw new Error(
'Repository name must be lowercase alphanumeric with optional dots, hyphens, or underscores',
);
}
// Check for duplicate name in org + protocol
@@ -99,7 +112,7 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
public static async findByName(
organizationId: string,
name: string,
protocol: TRegistryProtocol
protocol: TRegistryProtocol,
): Promise<Repository | null> {
return await Repository.getInstance({
organizationId,
@@ -128,6 +141,20 @@ export class Repository extends plugins.smartdata.SmartDataDbDoc<Repository, Rep
return await Repository.getInstances(query);
}
/**
* Whether this repository is public
*/
public get isPublic(): boolean {
return this.visibility === 'public';
}
/**
* Find repository by ID
*/
public static async findById(id: string): Promise<Repository | null> {
return await Repository.getInstance({ id });
}
/**
* Increment download count
*/

View File

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

View File

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

View File

@@ -25,6 +25,9 @@ export class Team extends plugins.smartdata.SmartDataDbDoc<Team, Team> implement
@plugins.smartdata.svDb()
public isDefaultTeam: boolean = false;
@plugins.smartdata.svDb()
public repositoryIds: string[] = [];
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
}
/**

View File

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

View File

@@ -6,12 +6,12 @@
import * as plugins from '../plugins.ts';
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
import { Package } from '../models/package.ts';
import { Repository } from '../models/repository.ts';
import { Organization } from '../models/organization.ts';
import { AuditService } from '../services/audit.service.ts';
export interface IStorageConfig {
export interface IStorageProviderConfig {
bucket: plugins.smartbucket.SmartBucket;
bucketName: string;
basePath: string;
}
@@ -20,222 +20,192 @@ export interface IStorageConfig {
* and stores artifacts in S3 via smartbucket
*/
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
private config: IStorageConfig;
private config: IStorageProviderConfig;
constructor(config: IStorageConfig) {
constructor(config: IStorageProviderConfig) {
this.config = config;
}
/**
* Called before a package is stored
* Use this to validate, transform, or prepare for storage
*/
public async beforeStore(context: plugins.smartregistry.IStorageContext): Promise<plugins.smartregistry.IStorageContext> {
public async beforePut(
context: plugins.smartregistry.IStorageHookContext,
): Promise<plugins.smartregistry.IBeforePutResult> {
// Validate organization exists and has quota
const org = await Organization.findById(context.organizationId);
if (!org) {
throw new Error(`Organization not found: ${context.organizationId}`);
}
const orgId = context.actor?.orgId;
if (orgId) {
const org = await Organization.findById(orgId);
if (!org) {
return { allowed: false, reason: `Organization not found: ${orgId}` };
}
// Check storage quota
const newSize = context.size || 0;
if (org.settings.quotas.maxStorageBytes > 0) {
if (org.usedStorageBytes + newSize > org.settings.quotas.maxStorageBytes) {
throw new Error('Organization storage quota exceeded');
// Check storage quota
const newSize = context.metadata?.size || 0;
if (!org.hasStorageAvailable(newSize)) {
return { allowed: false, reason: 'Organization storage quota exceeded' };
}
}
// Validate repository exists
const repo = await Repository.findById(context.repositoryId);
if (!repo) {
throw new Error(`Repository not found: ${context.repositoryId}`);
}
// Check repository protocol
if (!repo.protocols.includes(context.protocol as TRegistryProtocol)) {
throw new Error(`Repository does not support ${context.protocol} protocol`);
}
return context;
return { allowed: true };
}
/**
* Called after a package is successfully stored
* Update database records and metrics
*/
public async afterStore(context: plugins.smartregistry.IStorageContext): Promise<void> {
public async afterPut(
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version || 'unknown';
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, packageName);
// Get or create package record
let pkg = await Package.findById(packageId);
if (!pkg) {
pkg = new Package();
pkg.id = packageId;
pkg.organizationId = context.organizationId;
pkg.repositoryId = context.repositoryId;
pkg.organizationId = orgId;
pkg.protocol = protocol;
pkg.name = context.packageName;
pkg.createdById = context.actorId || '';
pkg.name = packageName;
pkg.createdById = context.actor?.userId || '';
pkg.createdAt = new Date();
}
// Add version
pkg.addVersion({
version: context.version,
version,
publishedAt: new Date(),
publishedBy: context.actorId || '',
size: context.size || 0,
checksum: context.checksum || '',
checksumAlgorithm: context.checksumAlgorithm || 'sha256',
publishedById: context.actor?.userId || '',
size: context.metadata?.size || 0,
digest: context.metadata?.digest,
downloads: 0,
metadata: context.metadata || {},
metadata: {},
});
// Update dist tags if provided
if (context.tags) {
for (const [tag, version] of Object.entries(context.tags)) {
pkg.distTags[tag] = version;
}
}
// Set latest tag if not set
if (!pkg.distTags['latest']) {
pkg.distTags['latest'] = context.version;
pkg.distTags['latest'] = version;
}
await pkg.save();
// Update organization storage usage
const org = await Organization.findById(context.organizationId);
if (org) {
org.usedStorageBytes += context.size || 0;
await org.save();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(context.metadata?.size || 0);
}
}
// Audit log
await AuditService.withContext({
actorId: context.actorId,
actorType: context.actorId ? 'user' : 'anonymous',
organizationId: context.organizationId,
repositoryId: context.repositoryId,
}).logPackagePublished(
packageId,
context.packageName,
context.version,
context.organizationId,
context.repositoryId
);
}
/**
* Called before a package is fetched
*/
public async beforeFetch(context: plugins.smartregistry.IFetchContext): Promise<plugins.smartregistry.IFetchContext> {
return context;
if (context.actor?.userId) {
await AuditService.withContext({
actorId: context.actor.userId,
actorType: 'user',
organizationId: orgId,
}).logPackagePublished(packageId, packageName, version, orgId, '');
}
}
/**
* Called after a package is fetched
* Update download metrics
*/
public async afterFetch(context: plugins.smartregistry.IFetchContext): Promise<void> {
public async afterGet(
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version;
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, packageName);
const pkg = await Package.findById(packageId);
if (pkg) {
await pkg.incrementDownloads(context.version);
}
// Audit log for authenticated users
if (context.actorId) {
await AuditService.withContext({
actorId: context.actorId,
actorType: 'user',
organizationId: context.organizationId,
repositoryId: context.repositoryId,
}).logPackageDownloaded(
packageId,
context.packageName,
context.version || 'latest',
context.organizationId,
context.repositoryId
);
await pkg.incrementDownloads(version);
}
}
/**
* Called before a package is deleted
*/
public async beforeDelete(context: plugins.smartregistry.IDeleteContext): Promise<plugins.smartregistry.IDeleteContext> {
return context;
public async beforeDelete(
context: plugins.smartregistry.IStorageHookContext,
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
return { allowed: true };
}
/**
* Called after a package is deleted
*/
public async afterDelete(context: plugins.smartregistry.IDeleteContext): Promise<void> {
public async afterDelete(
context: plugins.smartregistry.IStorageHookContext,
): Promise<void> {
const protocol = context.protocol as TRegistryProtocol;
const packageId = Package.generateId(protocol, context.organizationName, context.packageName);
const packageName = context.metadata?.packageName || context.key;
const version = context.metadata?.version;
const orgId = context.actor?.orgId || '';
const packageId = Package.generateId(protocol, orgId, packageName);
const pkg = await Package.findById(packageId);
if (!pkg) return;
if (context.version) {
// Delete specific version
const version = pkg.versions[context.version];
if (version) {
const sizeReduction = version.size;
delete pkg.versions[context.version];
if (version) {
const versionData = pkg.versions[version];
if (versionData) {
const sizeReduction = versionData.size;
delete pkg.versions[version];
pkg.storageBytes -= sizeReduction;
// Update dist tags
for (const [tag, ver] of Object.entries(pkg.distTags)) {
if (ver === context.version) {
if (ver === version) {
delete pkg.distTags[tag];
}
}
// If no versions left, delete the package
if (Object.keys(pkg.versions).length === 0) {
await pkg.delete();
} else {
await pkg.save();
}
// Update org storage
const org = await Organization.findById(context.organizationId);
if (org) {
org.usedStorageBytes -= sizeReduction;
await org.save();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(-sizeReduction);
}
}
}
} else {
// Delete entire package
const sizeReduction = pkg.storageBytes;
await pkg.delete();
// Update org storage
const org = await Organization.findById(context.organizationId);
if (org) {
org.usedStorageBytes -= sizeReduction;
await org.save();
if (orgId) {
const org = await Organization.findById(orgId);
if (org) {
await org.updateStorageUsage(-sizeReduction);
}
}
}
// Audit log
await AuditService.withContext({
actorId: context.actorId,
actorType: context.actorId ? 'user' : 'system',
organizationId: context.organizationId,
repositoryId: context.repositoryId,
}).log('PACKAGE_DELETED', 'package', {
resourceId: packageId,
resourceName: context.packageName,
metadata: { version: context.version },
success: true,
});
if (context.actor?.userId) {
await AuditService.withContext({
actorId: context.actor.userId,
actorType: 'user',
organizationId: orgId,
}).log('PACKAGE_DELETED', 'package', {
resourceId: packageId,
resourceName: packageName,
metadata: { version },
success: true,
});
}
}
/**
@@ -246,7 +216,7 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
organizationName: string,
packageName: string,
version: string,
filename: string
filename: string,
): string {
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
}
@@ -257,13 +227,12 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
public async storeArtifact(
path: string,
data: Uint8Array,
contentType?: string
contentType?: string,
): Promise<string> {
const bucket = await this.config.bucket.getBucket();
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
await bucket.fastPut({
path,
contents: Buffer.from(data),
contentType: contentType || 'application/octet-stream',
contents: data as unknown as string,
});
return path;
}
@@ -273,10 +242,10 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
*/
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
try {
const bucket = await this.config.bucket.getBucket();
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
const file = await bucket.fastGet({ path });
if (!file) return null;
return new Uint8Array(file.contents);
return new Uint8Array(file);
} catch {
return null;
}
@@ -287,8 +256,8 @@ export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageH
*/
public async deleteArtifact(path: string): Promise<boolean> {
try {
const bucket = await this.config.bucket.getBucket();
await bucket.fastDelete({ path });
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
await bucket.fastRemove({ path });
return true;
} catch {
return false;

View File

@@ -4,12 +4,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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ export class CryptoService {
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
if (!keyHex) {
console.warn(
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)',
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
@@ -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'],
);
}

View File

@@ -3,12 +3,18 @@
* Orchestrates OAuth/OIDC and LDAP authentication flows
*/
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
import {
AuthProvider,
ExternalIdentity,
PlatformSettings,
Session,
User,
} from '../models/index.ts';
import { AuthService, type IAuthResult } from './auth.service.ts';
import { AuditService } from './audit.service.ts';
import { cryptoService } from './crypto.service.ts';
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
import type { IConnectionTestResult, IExternalUserInfo } from '../interfaces/auth.interfaces.ts';
export interface IOAuthState {
providerId: string;
@@ -33,7 +39,7 @@ export class ExternalAuthService {
*/
public async initiateOAuth(
providerId: string,
returnUrl?: string
returnUrl?: string,
): Promise<{ authUrl: string; state: string }> {
const provider = await AuthProvider.findById(providerId);
if (!provider) {
@@ -67,7 +73,7 @@ export class ExternalAuthService {
*/
public async handleOAuthCallback(
data: IOAuthCallbackData,
options: { ipAddress?: string; userAgent?: string } = {}
options: { ipAddress?: string; userAgent?: string } = {},
): Promise<IAuthResult> {
// Validate state
const stateData = await this.validateState(data.state);
@@ -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}`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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