Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
af9d244
feat: added script to test production
Alex-GF May 14, 2026
465c4a8
Bump pricing4ts to ^0.11.1 for Pricing2Yaml 3.1
javiercavlop Jul 30, 2026
988fd7d
A database outage is not a 401, and not healthy either
javiercavlop Jul 30, 2026
26fe9f6
Apply expected consumption for all limits in one write
javiercavlop Jul 30, 2026
29182f9
Accept an expected consumption of zero
javiercavlop Jul 30, 2026
c1c510b
Let the integration tests run on pull requests from forks
javiercavlop Jul 30, 2026
2179283
Keep a rejected key a 401 while the lookup itself fails with 503
javiercavlop Jul 30, 2026
52224fa
Merge pull request #62 from javiercavlop/ci/fork-pull-requests
Alex-GF Jul 31, 2026
2a2f228
Merge remote-tracking branch 'upstream/develop' into fix/database-out…
javiercavlop Jul 31, 2026
8a5c08c
Merge remote-tracking branch 'upstream/develop' into chore/pricing4ts…
javiercavlop Jul 31, 2026
850e79a
Merge remote-tracking branch 'upstream/develop' into fix/evaluate-los…
javiercavlop Jul 31, 2026
099cae8
Merge remote-tracking branch 'upstream/develop' into fix/zero-expecte…
javiercavlop Jul 31, 2026
54eed39
Merge pull request #61 from javiercavlop/fix/zero-expected-consumption
Alex-GF Jul 31, 2026
c40914f
Merge pull request #59 from javiercavlop/fix/database-outage-is-not-a…
Alex-GF Jul 31, 2026
a19f8e6
Merge pull request #58 from javiercavlop/chore/pricing4ts-0.11
Alex-GF Jul 31, 2026
70931b4
Let the database settle the increment, with $inc
javiercavlop Jul 31, 2026
5b999d3
Merge pull request #60 from javiercavlop/fix/evaluate-lost-updates
Alex-GF Jul 31, 2026
2da036f
docs: citation config
Alex-GF Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .github/workflows/run-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ on:
branches:
- 'develop'
- 'main'

# A pull request opened from a fork does not receive the `testing` environment's
# secrets or variables, so every `vars.*` and `secrets.*` below resolves to an
# empty string on those runs. The `|| '...'` fallbacks keep the job
# self-sufficient; where the environment is available, its values still win.
#
# The fallbacks are not arbitrary. MONGO_URI is hard-coded, so the container the
# suite talks to has to listen on 27017 and hold `space_testing_db` for the
# tests to connect at all - the two variables cannot hold anything else without
# breaking the run. JWT_SECRET and JWT_SALT are only a key and a pbkdf2 salt for
# tokens minted and verified inside the same run, so any non-empty pair works.
jobs:
build:
name: Test
Expand All @@ -22,14 +33,14 @@ jobs:
uses: SpicyPizza/create-envfile@v2.0
with:
envkey_ENVIRONMENT: "testing"
envkey_DATABASE_NAME: ${{ vars.CI_MONGO_INITDB_DATABASE }}
envkey_DATABASE_NAME: ${{ vars.CI_MONGO_INITDB_DATABASE || 'space_testing_db' }}
envkey_MONGO_URI: mongodb://localhost:27017/space_testing_db?authSource=space_testing_db
envkey_ADMIN_USER: "admin"
envkey_ADMIN_PASSWORD: "4dm1n"
envkey_REDIS_URL: "redis://localhost:6379"
envkey_JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
envkey_JWT_SECRET: ${{ secrets.CI_JWT_SECRET || 'ci_test_secret' }}
envkey_JWT_EXPIRATION: "1h"
envkey_JWT_SALT: ${{ secrets.CI_JWT_SALT }}
envkey_JWT_SALT: ${{ secrets.CI_JWT_SALT || 'ci_test_salt' }}

directory: .
file_name: api/.env
Expand All @@ -40,8 +51,8 @@ jobs:
uses: supercharge/mongodb-github-action@1.10.0
with:
mongodb-version: '7.0.16'
mongodb-db: ${{ vars.CI_MONGO_INITDB_DATABASE }}
mongodb-port: ${{ vars.CI_MONGO_PORT }}
mongodb-db: ${{ vars.CI_MONGO_INITDB_DATABASE || 'space_testing_db' }}
mongodb-port: ${{ vars.CI_MONGO_PORT || '27017' }}

- name: Start Redis
uses: shogo82148/actions-setup-redis@v1
Expand Down
27 changes: 27 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
cff-version: 1.2.0
title: "SPACE"
message: "If you use this software, please cite it as below."
type: software
authors:
- family-names: García-Fernández
given-names: Alejandro
email: agarcia29@us.es
orcid: https://orcid.org/0009-0005-0778-1432
- family-names: Parejo
given-names: José Antonio
email: japarejo@us.es
orcid: https://orcid.org/0000-0002-4708-4606
- family-names: Ruiz-Cortés
given-names: Antonio
email: aruiz@us.es
orcid: https://orcid.org/0000-0001-9827-1834
repository-code: "https://github.com/isa-group/space"
license: MIT
version: 1.5.0
date-released: "2026-07-31"
keywords:
- pricing-driven self-adaptation
- self-adaptive systems
- cloud computing
- software pricing
- service-oriented architecture
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"multer": "1.4.5-lts.2",
"nock": "^14.0.10",
"node-fetch": "^3.3.2",
"pricing4ts": "^0.10.3",
"pricing4ts": "^0.11.1",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
"uuid": "^11.1.0"
Expand Down
11 changes: 6 additions & 5 deletions api/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 64 additions & 11 deletions api/src/main/middlewares/AuthMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { matchPath, extractApiPath } from '../utils/routeMatcher';
import { LeanOrganization, OrganizationMember, OrganizationUserRole } from '../types/models/Organization';
import { HttpMethod, OrganizationApiKeyRole } from '../types/permissions';
import { LeanUser } from '../types/models/User';

/**
* Middleware to authenticate API Keys (both User and Organization types)
Expand All @@ -19,6 +20,18 @@ import { HttpMethod, OrganizationApiKeyRole } from '../types/permissions';
* Sets req.user for User API Keys
* Sets req.org for Organization API Keys
*/
/**
* A credential that was read and found wanting.
*
* Distinguished from every other failure on purpose. Authentication reads the
* database, so anything that can go wrong with the database - a dropped
* connection, a replica-set election, a Mongo that is simply not running -
* surfaces as an exception here too. Answering 401 to those says the caller
* sent a bad key, which is untrue and sends whoever is debugging to look at
* their credentials.
*/
class InvalidApiKeyError extends Error {}

const authenticateApiKeyMiddleware = async (req: Request, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-api-key'] as string;

Expand All @@ -40,11 +53,25 @@ const authenticateApiKeyMiddleware = async (req: Request, res: Response, next: N

return checkPermissions(req, res, next);
} catch (err: any) {
if (!res.headersSent) {
if (res.headersSent) {
return;
}

if (err instanceof InvalidApiKeyError) {
return res.status(401).json({
error: err.message || 'Invalid API Key',
});
}

// Anything else got as far as trying and could not finish - almost always
// the database. 503 rather than 401, because the credential was never
// judged, and Retry-After because it is worth trying again.
console.error('Authentication could not be completed:', err);
res.setHeader('Retry-After', '5');
return res.status(503).json({
error: 'Space cannot verify credentials right now.',
details: err?.message ?? String(err),
});
}
};

Expand All @@ -54,28 +81,54 @@ const authenticateApiKeyMiddleware = async (req: Request, res: Response, next: N
async function authenticateUserApiKey(req: Request, apiKey: string): Promise<void> {
const userService = container.resolve('userService');

const user = await userService.findByApiKey(apiKey);

if (!user) {
throw new Error('Invalid User API Key');
}
const user = await rejectionOrOutage<LeanUser>(
() => userService.findByApiKey(apiKey),
'Invalid User API Key'
);

req.user = user;
req.authType = 'user';
}

/**
* Run a credential lookup, telling a refusal apart from a failure.
*
* `UserService.findByApiKey` reports an unknown key by throwing rather than by
* returning nothing, using the `INVALID DATA:` prefix this codebase gives to a
* caller's own mistake. That has to keep answering 401. Anything else thrown by
* a lookup is the database being unable to answer, which is the case this
* middleware exists to stop reporting as a bad credential.
*/
async function rejectionOrOutage<T>(lookup: () => Promise<T>, absent: string): Promise<T> {
let found: T;

try {
found = await lookup();
} catch (err: any) {
if (typeof err?.message === 'string' && err.message.startsWith('INVALID DATA:')) {
throw new InvalidApiKeyError(err.message);
}
throw err;
}

if (!found) {
throw new InvalidApiKeyError(absent);
}

return found;
}

/**
* Authenticates an Organization API Key and populates req.org
*/
async function authenticateOrgApiKey(req: Request, apiKey: string): Promise<void> {
const organizationRepository = container.resolve('organizationRepository');

// Find organization by API Key
const result: LeanOrganization = await organizationRepository.findByApiKey(apiKey);

if (!result) {
throw new Error('Invalid Organization API Key');
}
const result: LeanOrganization = await rejectionOrOutage(
() => organizationRepository.findByApiKey(apiKey),
'Invalid Organization API Key'
);

req.org = {
id: result.id!,
Expand Down
40 changes: 40 additions & 0 deletions api/src/main/repositories/mongoose/ContractRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,46 @@ class ContractRepository extends RepositoryBase {
return contract ? toPlainObject<LeanContract>(contract.toJSON()) : null;
}

/**
* Add to several usage levels of one contract in a single atomic update.
*
* `$inc` is evaluated by the database against the stored document rather than
* against a copy the process read earlier, so concurrent increments compose
* instead of overwriting one another. Read-modify-write cannot do this from
* the application: two callers who read the same value both write the same
* total, and one consumption disappears.
*
* Every path is required to exist by the filter, so a limit that is not part
* of the contract matches no document and is reported to the caller instead
* of being created by the update - `$inc` would otherwise happily add the
* field. That check and the increment are one operation, so a limit cannot be
* validated and then vanish before the write.
*
* @param increments usage level path (`service.limit`) to amount to add.
* @returns the contract as it is after the increment, or null when the filter
* matched nothing.
*/
async incrementUsageLevels(
userId: string,
increments: Record<string, number>
): Promise<LeanContract | null> {
const filter: Record<string, unknown> = { 'userContact.userId': userId };
const inc: Record<string, number> = {};

for (const [path, amount] of Object.entries(increments)) {
filter[`usageLevels.${path}.consumed`] = { $exists: true };
inc[`usageLevels.${path}.consumed`] = amount;
}

const contract = await ContractMongoose.findOneAndUpdate(
filter,
{ $inc: inc },
{ new: true }
);

return contract ? toPlainObject<LeanContract>(contract.toJSON()) : null;
}

async changeServiceName(oldServiceName: string, newServiceName: string, organizationId: string): Promise<number> {
const oldServiceKey = oldServiceName.toLowerCase();
const newServiceKey = newServiceName.toLowerCase();
Expand Down
49 changes: 40 additions & 9 deletions api/src/main/routes/HealthcheckRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,49 @@
import express from 'express';
import mongoose from 'mongoose';

/**
* Mongoose reports 1 when the driver has a usable connection.
*
* 2 is "still connecting", which matters at start-up: a container answering
* "healthy" while it is still dialling is one an orchestrator will start
* sending traffic to.
*/
const CONNECTED = 1;

const loadFileRoutes = function (app: express.Application) {
const baseUrl = '/api/v1';

// Public route for authentication (does not require API Key)
app
.route(`${baseUrl}/healthcheck`)
.get(
(req: any, res: any) => {
res.status(200).json({
message: 'Service is up and running!',
});
}
);
app.route(`${baseUrl}/healthcheck`).get(async (req: any, res: any) => {
// The database is not a detail of this service, it is the service: every
// authenticated route reads it before it can answer anything. A check that
// proves only the HTTP listener is up reports a Space that cannot serve a
// single request as healthy, and keeps reporting it indefinitely while
// every call fails.
if (mongoose.connection.readyState !== CONNECTED) {
return res.status(503).json({
message: 'Service is up but cannot reach its database.',
database: 'disconnected',
});
}

try {
// readyState is what the driver believes. A ping is what the database
// says, and the two disagree when a connection has gone stale.
await mongoose.connection.db!.admin().ping();
} catch (error: any) {
return res.status(503).json({
message: 'Service is up but its database is not answering.',
database: 'unreachable',
details: error?.message ?? String(error),
});
}

res.status(200).json({
message: 'Service is up and running!',
database: 'connected',
});
});
};

export default loadFileRoutes;
Loading
Loading