16 Commits
v1.5.1 ... 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
33 changed files with 906 additions and 171 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

View File

@@ -1,5 +1,61 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.5.1",
"version": "1.8.5",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

14
deno.lock generated
View File

@@ -43,6 +43,7 @@
"npm:@push.rocks/smartrx@^3.0.10": "3.0.10",
"npm:@push.rocks/smartstring@^4.1.0": "4.1.0",
"npm:@push.rocks/smartunique@^3.0.9": "3.0.9",
"npm:@stack.gallery/catalog@^1.0.2": "1.0.2",
"npm:@tsclass/tsclass@^9.5.0": "9.5.0"
},
"jsr": {
@@ -3036,6 +3037,16 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"tarball": "https://verdaccio.lossless.digital/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz"
},
"@stack.gallery/catalog@1.0.2": {
"integrity": "sha512-alPyu2YwpIwaM0hYcLnW05PcCYishWDitYVWDkk7+HDcy3q32LGizkS0eUu/l7Za6w/to06OXGgEmZMhZY4nuQ==",
"dependencies": [
"@design.estate/dees-catalog",
"@design.estate/dees-domtools",
"@design.estate/dees-element",
"@design.estate/dees-wcctools"
],
"tarball": "https://verdaccio.lossless.digital/@stack.gallery/catalog/-/catalog-1.0.2.tgz"
},
"@tempfix/idb@8.0.3": {
"integrity": "sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==",
"tarball": "https://verdaccio.lossless.digital/@tempfix/idb/-/idb-8.0.3.tgz"
@@ -6796,7 +6807,8 @@
"npm:@git.zone/tsbundle@^2.8.3",
"npm:@git.zone/tsdeno@^1.2.0",
"npm:@git.zone/tswatch@^3.1.0",
"npm:@push.rocks/smartguard@^3.1.0"
"npm:@push.rocks/smartguard@^3.1.0",
"npm:@stack.gallery/catalog@^1.0.2"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.5.1",
"version": "1.8.5",
"private": true,
"description": "Enterprise-grade multi-protocol package registry",
"type": "module",
@@ -33,7 +33,7 @@
"@design.estate/dees-catalog": "^3.43.0",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/smartguard": "^3.1.0",
"@stack.gallery/catalog": "file:../catalog"
"@stack.gallery/catalog": "^1.0.2"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.3",

10
pnpm-lock.yaml generated
View File

@@ -27,8 +27,8 @@ importers:
specifier: ^3.1.0
version: 3.1.0
'@stack.gallery/catalog':
specifier: file:../catalog
version: file:../catalog(@tiptap/pm@2.27.2)
specifier: ^1.0.2
version: 1.0.2(@tiptap/pm@2.27.2)
devDependencies:
'@git.zone/tsbundle':
specifier: ^2.8.3
@@ -845,8 +845,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@stack.gallery/catalog@file:../catalog':
resolution: {directory: ../catalog, type: directory}
'@stack.gallery/catalog@1.0.2':
resolution: {integrity: sha512-alPyu2YwpIwaM0hYcLnW05PcCYishWDitYVWDkk7+HDcy3q32LGizkS0eUu/l7Za6w/to06OXGgEmZMhZY4nuQ==}
'@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
@@ -3483,7 +3483,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@stack.gallery/catalog@file:../catalog(@tiptap/pm@2.27.2)':
'@stack.gallery/catalog@1.0.2(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.49.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.1

118
readme.md
View File

@@ -6,11 +6,7 @@ all behind a single binary with a modern web UI.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit
[community.foss.global/](https://community.foss.global/). This is the central community hub for all
issue reporting. Developers who sign and comply with our contribution agreement and go through
identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull
Requests directly.
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## ✨ Features
@@ -22,7 +18,7 @@ Requests directly.
- 🛡️ **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
@@ -40,7 +36,7 @@ Requests directly.
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
# Install specific version
curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.4.0
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
@@ -60,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
@@ -114,7 +113,7 @@ manager at the registry:
| Protocol | Paths | Client Config Example |
| -------------- | --------------------------- | ------------------------------------------------------ |
| **NPM** | `/-/*`, `/@scope/*` | `npm config set registry http://registry:3000` |
| **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` |
@@ -125,6 +124,34 @@ manager at the registry:
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
@@ -265,11 +292,10 @@ All management endpoints live under `/api/v1/`. Authenticated via
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)
@@ -277,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
@@ -294,29 +321,25 @@ registry/
│ │ ├── auth.provider.ts # IAuthProvider implementation
│ │ └── storage.provider.ts # IStorageHooks for quota/audit
│ └── interfaces/ # TypeScript interfaces & types
── ts_interfaces/ # Shared API contract (TypedRequest interfaces)
├── data/ # Data types (auth, org, repo, package, token, audit, admin)
└── requests/ # Request/response interfaces for all API endpoints
└── ui/ # Angular 19 + Tailwind CSS frontend
└── src/app/
├── features/ # Login, dashboard, orgs, repos, packages, tokens, admin
├── 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
@@ -329,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
@@ -355,7 +375,7 @@ 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
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
@@ -367,38 +387,28 @@ 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
This repository contains open-source code licensed under the MIT License. A copy of the license can
be found in the [LICENSE](./LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks,
service marks, or product names of the project, except as required for reasonable and customary use
in describing the origin of the work and reproducing the content of the NOTICE file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated
with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture
Capital GmbH or third parties, and are not included within the scope of the MIT license granted
herein.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the
guidelines of the respective third-party owners, and any usage must be approved in writing.
Third-party trademarks used herein are the property of their respective owners and used only in a
descriptive manner, e.g. for an implementation of an API or similar.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
Capital GmbH of any derivative works.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -15,9 +15,12 @@ import {
createTestApiToken,
createTestRepository,
createTestUser,
getTestRegistry,
runCommand,
setupTestDb,
skipIfMissing,
startTestServer,
stopTestServer,
teardownTestDb,
testConfig,
} from '../helpers/index.ts';
@@ -27,7 +30,7 @@ const FIXTURE_DIR = path.join(
'../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;
@@ -237,11 +260,17 @@ describe('NPM E2E: Full lifecycle', () => {
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(

View File

@@ -17,6 +17,8 @@ import {
createTestUser,
setupTestDb,
skipIfMissing,
startTestServer,
stopTestServer,
teardownTestDb,
testConfig,
} from '../helpers/index.ts';
@@ -26,7 +28,7 @@ const FIXTURE_DIR = path.join(
'../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

@@ -81,5 +81,8 @@ export {
setupTestStorage,
} from './storage.helper.ts';
// Server helpers
export { getTestRegistry, startTestServer, stopTestServer } from './server.helper.ts';
// Re-export test config
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

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

View File

@@ -13,16 +13,20 @@ import {
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');
});
});
@@ -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

@@ -16,19 +16,23 @@ import {
post,
put,
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 () => {
@@ -116,8 +120,8 @@ describe('Organization API Integration', () => {
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);
});
});
@@ -202,8 +206,8 @@ describe('Organization API Integration', () => {
);
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 () => {

View File

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

View File

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

@@ -26,7 +26,7 @@ export class AdminHandler {
try {
const providers = await AuthProvider.getAllProviders();
return {
providers: providers.map((p) => p.toAdminInfo()),
providers: providers.map((p) => p.toAdminInfo() as unknown as interfaces.data.IAuthProvider),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
@@ -124,7 +124,7 @@ export class AdminHandler {
},
});
return { provider: provider.toAdminInfo() };
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');
@@ -146,7 +146,7 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
return { provider: provider.toAdminInfo() };
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');
@@ -235,7 +235,7 @@ export class AdminHandler {
metadata: { providerName: provider.name },
});
return { provider: provider.toAdminInfo() };
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');

View File

@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { Organization, OrganizationMember, User } from '../../models/index.ts';
import { Organization, OrganizationMember, OrgRedirect, User } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
@@ -19,9 +19,18 @@ export class OrganizationHandler {
* Helper to resolve organization by ID or name
*/
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
return idOrName.startsWith('Organization:')
? await Organization.findById(idOrName)
: await Organization.findByName(idOrName);
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 {
@@ -232,6 +241,36 @@ export class OrganizationHandler {
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;
@@ -544,5 +583,69 @@ export class OrganizationHandler {
},
),
);
// 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

@@ -144,15 +144,30 @@ export class StackGalleryAuthProvider implements plugins.smartregistry.IAuthProv
// Map action
const mappedAction = this.mapAction(action);
// For simple authorization without specific resource context,
// check if user is active
// 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;
return mappedAction === 'read'; // Default: authenticated users can read
// 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;
}
}
}
}
// Default: authenticated users can read
return mappedAction === 'read';
}
/**

View File

@@ -8,6 +8,7 @@ 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';
// Bundled UI files (generated by tsbundle with base64ts output mode)
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
@@ -77,6 +78,7 @@ export class StackGalleryRegistry {
private authProvider: StackGalleryAuthProvider | null = null;
private storageHooks: StackGalleryStorageHooks | null = null;
private opsServer: OpsServer | null = null;
private apiRouter: ApiRouter | null = null;
private isInitialized = false;
constructor(config: IRegistryConfig) {
@@ -141,13 +143,21 @@ export class StackGalleryRegistry {
npmTokens: { enabled: true },
ociTokens: {
enabled: true,
realm: 'stack.gallery',
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 REST API router
console.log('[StackGalleryRegistry] Initializing API router...');
this.apiRouter = new ApiRouter();
console.log('[StackGalleryRegistry] API router initialized');
// Initialize OpsServer (TypedRequest handlers)
console.log('[StackGalleryRegistry] Initializing OpsServer...');
this.opsServer = new OpsServer(this);
@@ -198,31 +208,40 @@ export class StackGalleryRegistry {
return await this.handleTypedRequest(request);
}
// Legacy REST API endpoints (keep for backwards compatibility during migration)
// TODO: Remove once frontend is fully migrated to TypedRequest
// Registry protocol endpoints (handled by smartregistry)
const registryPaths = [
'/-/',
'/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 {
// Convert Request to IRequestContext
const requestContext = await this.requestToContext(request);
const response = await this.smartRegistry.handleRequest(requestContext);
if (response) return this.contextResponseToResponse(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' }), {
@@ -232,6 +251,11 @@ export class StackGalleryRegistry {
}
}
// REST API endpoints
if (this.apiRouter && path.startsWith('/api/')) {
return this.apiRouter.handle(request);
}
// Serve static UI files
return this.serveStaticFile(path);
}
@@ -325,7 +349,7 @@ export class StackGalleryRegistry {
// Get bundled file
const file = bundledFileMap.get(filePath);
if (file) {
return new Response(file.data, {
return new Response(file.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': file.contentType },
});
@@ -334,7 +358,7 @@ export class StackGalleryRegistry {
// SPA fallback: serve index.html for unknown paths
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' },
});
@@ -374,6 +398,53 @@ export class StackGalleryRegistry {
}
}
/**
* 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' },
},
);
}
// No auth provided — return 401
return new Response(
JSON.stringify({ error: 'authentication required' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
},
);
}
/**
* Health check endpoint
*/

View File

@@ -42,6 +42,13 @@ export interface IOrganizationMember {
} | 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';

View File

@@ -61,6 +61,7 @@ export interface IReq_UpdateOrganization extends
request: {
identity: data.IIdentity;
organizationId: string;
name?: string;
displayName?: string;
description?: string;
avatarUrl?: string;
@@ -159,3 +160,37 @@ export interface IReq_RemoveOrganizationMember extends
message: string;
};
}
// ============================================================================
// Organization Redirect Requests
// ============================================================================
export interface IReq_GetOrgRedirects extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetOrgRedirects
> {
method: 'getOrgRedirects';
request: {
identity: data.IIdentity;
organizationId: string;
};
response: {
redirects: data.IOrgRedirect[];
};
}
export interface IReq_DeleteOrgRedirect extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteOrgRedirect
> {
method: 'deleteOrgRedirect';
request: {
identity: data.IIdentity;
redirectId: string;
};
response: {
message: string;
};
}

View File

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

View File

@@ -20,6 +20,7 @@ export interface IOrganizationsState {
currentOrg: interfaces.data.IOrganizationDetail | null;
repositories: interfaces.data.IRepository[];
members: interfaces.data.IOrganizationMember[];
redirects: interfaces.data.IOrgRedirect[];
}
export interface IPackagesState {
@@ -70,6 +71,7 @@ export const organizationsStatePart = await appState.getStatePart<IOrganizations
currentOrg: null,
repositories: [],
members: [],
redirects: [],
},
'soft',
);
@@ -276,6 +278,31 @@ export const createOrganizationAction = organizationsStatePart.createAction<{
}
});
export const updateOrganizationAction = organizationsStatePart.createAction<{
organizationId: string;
name?: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_UpdateOrganization>(
'updateOrganization',
);
const response = await typedRequest.fire({
identity: context.identity,
...dataArg,
});
// Update the current org in state
return { ...statePartArg.getState(), currentOrg: response.organization };
} catch {
return statePartArg.getState();
}
});
export const deleteOrganizationAction = organizationsStatePart.createAction<{
organizationId: string;
}>(async (statePartArg, dataArg) => {
@@ -341,6 +368,53 @@ export const fetchMembersAction = organizationsStatePart.createAction<{
}
});
export const fetchRedirectsAction = organizationsStatePart.createAction<{
organizationId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
);
const response = await typedRequest.fire({
identity: context.identity,
organizationId: dataArg.organizationId,
});
return { ...statePartArg.getState(), redirects: response.redirects };
} catch {
return statePartArg.getState();
}
});
export const deleteRedirectAction = organizationsStatePart.createAction<{
redirectId: string;
organizationId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_DeleteOrgRedirect>(
'deleteOrgRedirect',
);
await typedRequest.fire({
identity: context.identity,
redirectId: dataArg.redirectId,
});
// Re-fetch redirects
const listReq = createTypedRequest<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
);
const listResp = await listReq.fire({
identity: context.identity,
organizationId: dataArg.organizationId,
});
return { ...statePartArg.getState(), redirects: listResp.redirects };
} catch {
return statePartArg.getState();
}
});
// ============================================================================
// Package Actions
// ============================================================================

View File

@@ -6,3 +6,4 @@ export * from './sg-view-packages.js';
export * from './sg-view-tokens.js';
export * from './sg-view-settings.js';
export * from './sg-view-admin.js';
export * from './sg-view-public-packages.js';

View File

@@ -39,6 +39,9 @@ export class SgAppShell extends DeesElement {
@state()
accessor localAuthEnabled: boolean = true;
@state()
accessor showLoginForm: boolean = false;
private viewTabs = [
{
name: 'Dashboard',
@@ -117,7 +120,24 @@ export class SgAppShell extends DeesElement {
];
public render(): TemplateResult {
if (!this.loginState.isLoggedIn) {
// Authenticated: full appdash
if (this.loginState.isLoggedIn) {
return html`
<div class="maincontainer">
<dees-simple-appdash
name="Stack.Gallery"
.viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView,
) || this.resolvedViewTabs[0]}
>
</dees-simple-appdash>
</div>
`;
}
// Login form requested
if (this.showLoginForm) {
return html`
<div class="maincontainer">
<sg-login-view
@@ -133,16 +153,14 @@ export class SgAppShell extends DeesElement {
`;
}
// Public browsing mode: package search + detail
return html`
<div class="maincontainer">
<dees-simple-appdash
name="Stack.Gallery"
.viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView,
) || this.resolvedViewTabs[0]}
<sg-public-layout
@sign-in=${() => { this.showLoginForm = true; }}
>
</dees-simple-appdash>
<sg-view-public-packages></sg-view-public-packages>
</sg-public-layout>
</div>
`;
}
@@ -152,7 +170,7 @@ export class SgAppShell extends DeesElement {
this.fetchAuthProviders();
// Resolve async view tab imports
const allTabs = await Promise.all(
this.allResolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
@@ -162,8 +180,8 @@ export class SgAppShell extends DeesElement {
// Filter admin tab based on user role
this.resolvedViewTabs = this.loginState.identity?.isSystemAdmin
? allTabs
: allTabs.filter((t) => t.name !== 'Admin');
? this.allResolvedViewTabs
: this.allResolvedViewTabs.filter((t) => t.name !== 'Admin');
this.requestUpdate();
await this.updateComplete;

View File

@@ -79,14 +79,14 @@ export class SgViewDashboard extends DeesElement {
const { type, id } = e.detail;
if (type === 'org' && id) {
appRouter.navigateToEntity('organizations', id);
} else if (type === 'org') {
appRouter.navigateToView('organizations');
} else if (type === 'package' && id) {
appRouter.navigateToEntity('packages', id);
} else if (type === 'packages') {
appRouter.navigateToView('packages');
} else if (type === 'tokens') {
appRouter.navigateToView('tokens');
} else if (type === 'organizations') {
appRouter.navigateToView('organizations');
}
}
}

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
css,
cssManager,
@@ -18,11 +19,15 @@ export class SgViewOrganizations extends DeesElement {
currentOrg: null,
repositories: [],
members: [],
redirects: [],
};
@state()
accessor uiState: appstate.IUiState = { activeView: 'organizations' };
@state()
accessor detailOrgId: string | null = null;
constructor() {
super();
const orgSub = appstate.organizationsStatePart
@@ -48,9 +53,10 @@ export class SgViewOrganizations extends DeesElement {
async connectedCallback() {
super.connectedCallback();
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
// If there's an entity ID, load the detail
// If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) {
await this.loadOrgDetail(this.uiState.activeEntityId);
this.detailOrgId = this.uiState.activeEntityId;
await this.loadOrgDetail(this.detailOrgId);
}
}
@@ -67,18 +73,26 @@ export class SgViewOrganizations extends DeesElement {
appstate.fetchMembersAction,
{ organizationId: orgId },
);
await appstate.organizationsStatePart.dispatchAction(
appstate.fetchRedirectsAction,
{ organizationId: orgId },
);
}
public render(): TemplateResult {
if (this.uiState.activeEntityId && this.organizationsState.currentOrg) {
if (this.detailOrgId && this.organizationsState.currentOrg) {
return html`
<sg-organization-detail-view
.organization="${this.organizationsState.currentOrg}"
.repositories="${this.organizationsState.repositories}"
.members="${this.organizationsState.members}"
.redirects="${this.organizationsState.redirects}"
@back="${() => this.goBack()}"
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
@create-repo="${() => {/* TODO: create repo modal */}}"
@edit="${(e: CustomEvent) => this.handleEditOrg(e.detail)}"
@delete="${(e: CustomEvent) => this.handleDeleteOrg(e.detail.organizationId)}"
@delete-redirect="${(e: CustomEvent) => this.handleDeleteRedirect(e.detail.redirectId)}"
></sg-organization-detail-view>
`;
}
@@ -93,10 +107,7 @@ export class SgViewOrganizations extends DeesElement {
}
private selectOrg(orgId: string) {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: orgId,
});
this.detailOrgId = orgId;
this.loadOrgDetail(orgId);
}
@@ -106,18 +117,50 @@ export class SgViewOrganizations extends DeesElement {
}
private goBack() {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
this.detailOrgId = null;
appstate.organizationsStatePart.setState({
...appstate.organizationsStatePart.getState(),
currentOrg: null,
repositories: [],
members: [],
redirects: [],
});
}
private async handleDeleteRedirect(redirectId: string) {
if (!this.detailOrgId) return;
await appstate.organizationsStatePart.dispatchAction(
appstate.deleteRedirectAction,
{ redirectId, organizationId: this.detailOrgId },
);
}
private async handleEditOrg(data: {
organizationId: string;
name?: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}) {
await appstate.organizationsStatePart.dispatchAction(
appstate.updateOrganizationAction,
data,
);
// Re-load detail to reflect changes
if (this.detailOrgId) {
await this.loadOrgDetail(this.detailOrgId);
}
}
private async handleDeleteOrg(organizationId: string) {
await appstate.organizationsStatePart.dispatchAction(
appstate.deleteOrganizationAction,
{ organizationId },
);
this.goBack();
}
private async createOrg(data: { name: string; displayName?: string; description?: string }) {
await appstate.organizationsStatePart.dispatchAction(
appstate.createOrganizationAction,

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
css,
cssManager,
@@ -25,6 +26,9 @@ export class SgViewPackages extends DeesElement {
@state()
accessor uiState: appstate.IUiState = { activeView: 'packages' };
@state()
accessor detailPackageId: string | null = null;
constructor() {
super();
const pkgSub = appstate.packagesStatePart
@@ -49,8 +53,10 @@ export class SgViewPackages extends DeesElement {
async connectedCallback() {
super.connectedCallback();
// If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) {
await this.loadPackageDetail(this.uiState.activeEntityId);
this.detailPackageId = this.uiState.activeEntityId;
await this.loadPackageDetail(this.detailPackageId);
} else {
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
@@ -71,7 +77,7 @@ export class SgViewPackages extends DeesElement {
}
public render(): TemplateResult {
if (this.uiState.activeEntityId && this.packagesState.currentPackage) {
if (this.detailPackageId && this.packagesState.currentPackage) {
return html`
<sg-package-detail-view
.package="${this.packagesState.currentPackage}"
@@ -98,18 +104,12 @@ export class SgViewPackages extends DeesElement {
}
private selectPackage(packageId: string) {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: packageId,
});
this.detailPackageId = packageId;
this.loadPackageDetail(packageId);
}
private goBack() {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
this.detailPackageId = null;
appstate.packagesStatePart.setState({
...appstate.packagesStatePart.getState(),
currentPackage: null,

View File

@@ -0,0 +1,132 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import {
css,
cssManager,
customElement,
DeesElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('sg-view-public-packages')
export class SgViewPublicPackages extends DeesElement {
@state()
accessor packagesState: appstate.IPackagesState = {
packages: [],
currentPackage: null,
versions: [],
total: 0,
query: '',
protocolFilter: '',
};
@state()
accessor detailPackageId: string | null = null;
@state()
accessor loading: boolean = false;
constructor() {
super();
const pkgSub = appstate.packagesStatePart
.select((s) => s)
.subscribe((s) => {
this.packagesState = s;
this.loading = false;
});
this.rxSubscriptions.push(pkgSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
async connectedCallback() {
super.connectedCallback();
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ offset: 0 },
);
this.loading = false;
}
public render(): TemplateResult {
if (this.detailPackageId && this.packagesState.currentPackage) {
return html`
<sg-package-detail-view
.package="${this.packagesState.currentPackage}"
.versions="${this.packagesState.versions}"
@back="${() => this.goBack()}"
></sg-package-detail-view>
`;
}
return html`
<sg-public-search-view
.packages="${this.packagesState.packages}"
.total="${this.packagesState.total}"
.query="${this.packagesState.query}"
.activeProtocol="${this.packagesState.protocolFilter}"
.protocols="${['npm', 'oci', 'maven', 'cargo', 'pypi', 'composer', 'rubygems']}"
.loading="${this.loading}"
@search="${(e: CustomEvent) => this.search(e.detail.query)}"
@filter="${(e: CustomEvent) => this.filter(e.detail.protocol)}"
@select="${(e: CustomEvent) => this.selectPackage(e.detail.packageId)}"
@page="${(e: CustomEvent) => this.paginate(e.detail.offset)}"
></sg-public-search-view>
`;
}
private async selectPackage(packageId: string) {
this.detailPackageId = packageId;
await appstate.packagesStatePart.dispatchAction(
appstate.fetchPackageAction,
{ packageId },
);
await appstate.packagesStatePart.dispatchAction(
appstate.fetchPackageVersionsAction,
{ packageId },
);
}
private goBack() {
this.detailPackageId = null;
appstate.packagesStatePart.setState({
...appstate.packagesStatePart.getState(),
currentPackage: null,
versions: [],
});
}
private async search(query: string) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ query, protocol: this.packagesState.protocolFilter, offset: 0 },
);
}
private async filter(protocol: string) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ query: this.packagesState.query, protocol, offset: 0 },
);
}
private async paginate(offset: number) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{
query: this.packagesState.query,
protocol: this.packagesState.protocolFilter,
offset,
},
);
}
}

View File

@@ -3,10 +3,8 @@ import { css } from '@design.estate/dees-element';
export const viewHostCss = css`
:host {
display: block;
width: 100%;
height: 100%;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
margin: auto;
max-width: 1280px;
padding: 16px 16px;
}
`;

View File

@@ -68,8 +68,14 @@ class AppRouter {
return;
}
// Check if user is logged in to decide default route
const isLoggedIn = appstate.loginStatePart.getState().isLoggedIn;
if (!path || path === '/') {
this.router.pushUrl('/dashboard');
if (isLoggedIn) {
this.router.pushUrl('/dashboard');
}
// If not logged in, stay on / for public browsing
} else {
const segments = path.split('/').filter(Boolean);
const view = segments[0];