Merge commit '5e1fcb296211538c3724632d82718660593227ad'

This commit is contained in:
Alexa Amundson
2025-11-28 23:07:09 -06:00
21 changed files with 8726 additions and 0 deletions

37
.eslintrc.json Normal file
View File

@@ -0,0 +1,37 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
}
}
},
"rules": {
"import/order": [
"error",
{
"alphabetize": { "order": "asc", "caseInsensitive": true },
"newlines-between": "always"
}
],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off"
}
}

View File

@@ -9,6 +9,7 @@ on:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
branches: ["*"]
pull_request: pull_request:
jobs: jobs:
@@ -50,3 +51,18 @@ jobs:
run: go test ./... run: go test ./...
- name: Build - name: Build
run: go build ./cmd/beacon run: go build ./cmd/beacon
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run compile

16
.gitignore vendored
View File

@@ -3,3 +3,19 @@ beacon
# Build artifacts # Build artifacts
*.db *.db
node_modules
.dist
dist
.turbo
sandbox
scripts/*.js
scripts/*.js.map
tests/*.js
tests/*.js.map
.env
.env.*
pnpm-lock.yaml
npm-debug.log*
yarn-debug.log*
yarn-error.log*
public/sig.beacon.json

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 100
}

View File

@@ -1,6 +1,9 @@
# blackroad-os-beacon # blackroad-os-beacon
Lightweight status-ping collector built with Go 1.22 and Fiber v3. Beacon captures health pings, stores them in BoltDB, and streams them to the Core UI via SSE. Lightweight status-ping collector built with Go 1.22 and Fiber v3. Beacon captures health pings, stores them in BoltDB, and streams them to the Core UI via SSE.
# Blackroad OS · API Gateway
Gateway-Gen-0 scaffold for a single entry-point that fronts Blackroad OS services via REST and GraphQL.
## Quickstart ## Quickstart
@@ -32,3 +35,31 @@ Environment variables:
- `make build` — compile the service. - `make build` — compile the service.
- `make sig` — refresh `public/sig_beacon.json`. - `make sig` — refresh `public/sig_beacon.json`.
pnpm install
pnpm dev
```
Visit `http://localhost:4000/health` to verify the gateway is running.
### Docker
```bash
docker build -t blackroad/gateway:0.0.1 .
docker run -e PORT=4000 -p 4000:4000 blackroad/gateway:0.0.1
```
## Environment
Copy `.env.example` and fill in service URLs and JWT keys. No secrets are committed.
## Scripts
- `pnpm dev` start the gateway with watch mode using tsx.
- `pnpm build` lint, test, compile TypeScript, and emit beacon metadata.
- `pnpm start` run the compiled server from `dist`.
## TODO(gateway-next)
- Wire real JWT validation rules and authorization.
- Compose remote schemas with Federation v2 and enable caching.
- Add persistent rate-limit and request tracing.

11
gateway.env.example Normal file
View File

@@ -0,0 +1,11 @@
PORT=4000
HOST=0.0.0.0
JWT_SECRET=replace-me
JWT_ISSUER=blackroad-gateway
SERVICE_API_URL=http://localhost:4100
SERVICE_OPERATOR_URL=http://localhost:4200
SERVICE_CORE_URL=http://localhost:4300
SERVICE_PRISM_URL=http://localhost:4400
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=1 minute
RATE_LIMIT_ALLOWLIST=127.0.0.1

31
infra/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN if [ -f pnpm-lock.yaml ]; then \
npm install -g pnpm && pnpm install --frozen-lockfile; \
else \
npm install --production=false; \
fi
COPY tsconfig.json .eslintrc.json .prettierrc ./
COPY src ./src
COPY scripts ./scripts
RUN npm run compile && node ./scripts/postbuild.ts
FROM node:20-alpine
WORKDIR /app
COPY --from=base /app/dist ./dist
COPY --from=base /app/public ./public
COPY package.json package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN if [ -f pnpm-lock.yaml ]; then \
npm install -g pnpm && pnpm install --prod --frozen-lockfile; \
else \
npm install --production; \
fi
ENV NODE_ENV=production
EXPOSE 4000
CMD ["node", "dist/index.js"]

9
infra/railway.toml Normal file
View File

@@ -0,0 +1,9 @@
[build]
builder = "NIXPACKS"
[deploy]
numReplicas = 1
startCommand = "npm start"
healthcheckPath = "/health"
port = 4000
restartPolicyType = "ON_FAILURE"

8262
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "blackroad-os-api-gateway",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run lint && npm run test && npm run compile && tsx scripts/postbuild.ts",
"compile": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"lint": "eslint . --ext .ts",
"test": "vitest"
},
"dependencies": {
"@fastify/http-proxy": "^9.0.0",
"@fastify/jwt": "^8.0.0",
"@fastify/rate-limit": "^8.0.0",
"dotenv": "^16.4.5",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"mercurius": "^13.4.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.12.12",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"prettier": "^3.2.5",
"supertest": "^6.3.4",
"tsx": "^4.7.3",
"typescript": "5.5.4",
"vitest": "^1.6.0"
}
}

21
scripts/postbuild.ts Normal file
View File

@@ -0,0 +1,21 @@
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
const outputDir = join(process.cwd(), 'public');
const beaconPath = join(outputDir, 'sig.beacon.json');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
beaconPath,
JSON.stringify(
{
ts: new Date().toISOString(),
agent: 'Gateway-Gen-0',
},
null,
2
)
);
// eslint-disable-next-line no-console
console.log(`Wrote beacon to ${beaconPath}`);

48
src/index.ts Normal file
View File

@@ -0,0 +1,48 @@
import dotenv from 'dotenv';
import fastify from 'fastify';
import authPlugin from './plugins/auth';
import graphqlPlugin from './plugins/graphql';
import proxyPlugin from './plugins/proxy';
import rateLimitPlugin from './plugins/rateLimit';
import healthRoute from './routes/health';
import versionRoute from './routes/version';
dotenv.config();
export function buildServer() {
const app = fastify({
logger: true,
});
app.register(rateLimitPlugin);
app.register(authPlugin);
app.register(proxyPlugin);
app.register(graphqlPlugin);
app.register(healthRoute);
app.register(versionRoute);
app.addHook('onReady', async () => {
app.log.info({ services: app.serviceMap }, 'service map loaded');
});
return app;
}
async function start() {
const app = buildServer();
const port = Number(process.env.PORT || 4000);
const host = process.env.HOST || '0.0.0.0';
try {
await app.listen({ port, host });
app.log.info(`Gateway listening on http://${host}:${port}`);
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
if (require.main === module) {
void start();
}

34
src/plugins/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import fastifyJwt, { JWT } from '@fastify/jwt';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
export type AuthenticatedRequest = FastifyRequest & { jwt: JWT };
async function authPlugin(fastify: FastifyInstance) {
fastify.register(fastifyJwt, {
secret: process.env.JWT_SECRET || 'development-secret',
});
fastify.decorate(
'verifyJWT',
async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
// TODO(gateway-next): Integrate real authz and audience checks.
try {
await request.jwtVerify();
} catch (error) {
request.log.warn({ err: error }, 'JWT verification failed');
reply.code(401).send({ error: 'Unauthorized' });
}
}
);
}
declare module 'fastify' {
interface FastifyInstance {
verifyJWT(request: FastifyRequest, reply: FastifyReply): Promise<void>;
}
}
export default fp(authPlugin, {
name: 'auth-plugin',
});

32
src/plugins/graphql.ts Normal file
View File

@@ -0,0 +1,32 @@
import { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import mercurius from 'mercurius';
const typeDefs = /* GraphQL */ `
type Query {
_ping: String
}
# TODO(gateway-next): Extend schema with federation v2 directives and compose remote schemas.
`;
const resolvers = {
Query: {
_ping: async () => 'pong',
},
};
async function graphqlPlugin(fastify: FastifyInstance) {
fastify.register(mercurius, {
schema: typeDefs,
resolvers,
graphiql: process.env.NODE_ENV !== 'production',
context: () => ({
// TODO(gateway-next): Inject authenticated user + stitched service clients.
}),
});
}
export default fp(graphqlPlugin, {
name: 'graphql-plugin',
});

50
src/plugins/proxy.ts Normal file
View File

@@ -0,0 +1,50 @@
import fastifyHttpProxy from '@fastify/http-proxy';
import { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { ServiceMap } from '../types';
function registerProxy(
fastify: FastifyInstance,
path: string,
target: string,
prefixRewrite = ''
) {
fastify.register(fastifyHttpProxy, {
upstream: target,
prefix: path,
rewritePrefix: prefixRewrite,
replyOptions: {
rewriteRequestHeaders: (_req, headers) => ({
...headers,
'x-gateway-proxied': 'true',
}),
},
});
}
async function proxyPlugin(fastify: FastifyInstance) {
const services: ServiceMap = {
api: process.env.SERVICE_API_URL || 'http://localhost:4100',
operator: process.env.SERVICE_OPERATOR_URL || 'http://localhost:4200',
core: process.env.SERVICE_CORE_URL || 'http://localhost:4300',
prism: process.env.SERVICE_PRISM_URL || 'http://localhost:4400',
};
registerProxy(fastify, '/api', services.api, '/');
registerProxy(fastify, '/operator', services.operator, '/');
registerProxy(fastify, '/core', services.core, '/');
registerProxy(fastify, '/prism', services.prism, '/');
fastify.decorate('serviceMap', services);
}
declare module 'fastify' {
interface FastifyInstance {
serviceMap: ServiceMap;
}
}
export default fp(proxyPlugin, {
name: 'proxy-plugin',
});

18
src/plugins/rateLimit.ts Normal file
View File

@@ -0,0 +1,18 @@
import fastifyRateLimit from '@fastify/rate-limit';
import { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
async function rateLimitPlugin(fastify: FastifyInstance) {
fastify.register(fastifyRateLimit, {
max: Number(process.env.RATE_LIMIT_MAX || 100),
timeWindow: process.env.RATE_LIMIT_WINDOW || '1 minute',
allowList: (process.env.RATE_LIMIT_ALLOWLIST || '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
});
}
export default fp(rateLimitPlugin, {
name: 'rate-limit-plugin',
});

8
src/routes/health.ts Normal file
View File

@@ -0,0 +1,8 @@
import { FastifyInstance } from 'fastify';
export default async function healthRoute(fastify: FastifyInstance) {
fastify.get('/health', async () => ({
status: 'ok',
uptime: process.uptime(),
}));
}

8
src/routes/version.ts Normal file
View File

@@ -0,0 +1,8 @@
import { FastifyInstance } from 'fastify';
export default async function versionRoute(fastify: FastifyInstance) {
fastify.get('/version', async () => ({
version: '0.0.1',
commit: process.env.COMMIT_SHA || 'dev',
}));
}

10
src/types/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export type ServiceMap = {
api: string;
operator: string;
core: string;
prism: string;
};
export type GatewayContext = {
services: ServiceMap;
};

23
tests/health.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildServer } from '../src/index';
describe('health route', () => {
const app = buildServer();
beforeAll(async () => {
await app.ready();
});
afterAll(async () => {
await app.close();
});
it('returns ok', async () => {
const response = await request(app.server).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ok');
expect(response.body).toHaveProperty('uptime');
});
});

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"sourceMap": true,
"types": ["node"]
},
"include": ["src", "scripts", "tests"],
"exclude": ["node_modules", "dist"]
}