@appliance.sh/api-server 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appliance.sh/api-server",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -19,8 +19,9 @@
19
19
  "test:e2e": "vitest run --config vitest.e2e.config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@appliance.sh/infra": "1.17.0",
23
- "@appliance.sh/sdk": "1.17.0",
22
+ "@appliance.sh/infra": "1.19.0",
23
+ "@appliance.sh/sdk": "1.19.0",
24
+ "@aws-sdk/client-s3": "^3.750.0",
24
25
  "express": "^5.2.1"
25
26
  },
26
27
  "devDependencies": {
@@ -10,3 +10,12 @@ describe('Index Route', () => {
10
10
  expect(response.text).toBe('Hello World!');
11
11
  });
12
12
  });
13
+
14
+ describe('Authenticated routes', () => {
15
+ it('should return 401 for /api/v1/projects without auth', async () => {
16
+ const app = createApp();
17
+ const response = await request(app).get('/api/v1/projects');
18
+ expect(response.status).toBe(401);
19
+ expect(response.body.error).toBe('Missing signature headers');
20
+ });
21
+ });
package/src/main.ts CHANGED
@@ -1,15 +1,31 @@
1
1
  import express from 'express';
2
2
  import { indexRoutes } from './routes';
3
- import { infraRoutes } from './routes/infra';
3
+
4
+ import { projectRoutes } from './routes/projects';
5
+ import { environmentRoutes } from './routes/environments';
6
+ import { deploymentRoutes } from './routes/deployments';
7
+ import { bootstrapRoutes } from './routes/bootstrap';
8
+ import { signatureAuth } from './middleware/auth';
4
9
 
5
10
  export function createApp() {
6
11
  const app = express();
7
12
 
8
- app.use(express.json());
13
+ app.use(
14
+ express.json({
15
+ verify: (req, _res, buf) => {
16
+ (req as express.Request & { rawBody?: Buffer }).rawBody = buf;
17
+ },
18
+ })
19
+ );
9
20
 
10
- // Set up routes
21
+ // Unauthenticated routes
11
22
  app.use('/', indexRoutes);
12
- app.use('/infra', infraRoutes);
23
+ app.use('/bootstrap', bootstrapRoutes);
24
+
25
+ // Authenticated routes
26
+ app.use('/api/v1/projects', signatureAuth, projectRoutes);
27
+ app.use('/api/v1/projects/:projectId/environments', signatureAuth, environmentRoutes);
28
+ app.use('/api/v1/deployments', signatureAuth, deploymentRoutes);
13
29
 
14
30
  return app;
15
31
  }
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import request from 'supertest';
3
+ import express from 'express';
4
+ import { signRequest } from '@appliance.sh/sdk';
5
+ import { signatureAuth } from './auth';
6
+
7
+ const TEST_KEY_ID = 'ak_test-key-id';
8
+ const TEST_SECRET = 'sk_test-secret-value';
9
+ const TEST_HOST = 'test.local';
10
+
11
+ const mockKeyStore = new Map<string, { id: string; secret: string; name: string }>();
12
+
13
+ vi.mock('../services/api-key.service', () => ({
14
+ apiKeyService: {
15
+ getByKeyId: async (keyId: string) => mockKeyStore.get(keyId) ?? null,
16
+ updateLastUsed: async () => {},
17
+ },
18
+ }));
19
+
20
+ function createTestApp() {
21
+ const app = express();
22
+ app.use(
23
+ express.json({
24
+ verify: (req, _res, buf) => {
25
+ (req as express.Request & { rawBody?: Buffer }).rawBody = buf;
26
+ },
27
+ })
28
+ );
29
+ app.get('/api/v1/test', signatureAuth, (_req, res) => {
30
+ res.json({ ok: true });
31
+ });
32
+ app.post('/api/v1/test', signatureAuth, (req, res) => {
33
+ res.json({ ok: true, body: req.body });
34
+ });
35
+ return app;
36
+ }
37
+
38
+ describe('signatureAuth middleware', () => {
39
+ beforeEach(() => {
40
+ mockKeyStore.clear();
41
+ mockKeyStore.set(TEST_KEY_ID, {
42
+ id: TEST_KEY_ID,
43
+ secret: TEST_SECRET,
44
+ name: 'test',
45
+ });
46
+ });
47
+
48
+ it('should return 401 when no signature headers', async () => {
49
+ const app = createTestApp();
50
+ const res = await request(app).get('/api/v1/test');
51
+ expect(res.status).toBe(401);
52
+ expect(res.body.error).toBe('Missing signature headers');
53
+ });
54
+
55
+ it('should return 401 with invalid signature', async () => {
56
+ const app = createTestApp();
57
+ const res = await request(app)
58
+ .get('/api/v1/test')
59
+ .set('signature', 'sig=:invalidbase64:')
60
+ .set('signature-input', 'sig=();created=1234567890;keyid="ak_wrong";alg="hmac-sha256"');
61
+ expect(res.status).toBe(401);
62
+ });
63
+
64
+ it('should pass through with valid HMAC-SHA256 signature (GET)', async () => {
65
+ const app = createTestApp();
66
+ const url = `http://${TEST_HOST}/api/v1/test`;
67
+ const headers: Record<string, string> = {
68
+ 'content-type': 'application/json',
69
+ };
70
+
71
+ const sigHeaders = await signRequest({ keyId: TEST_KEY_ID, secret: TEST_SECRET }, { method: 'GET', url, headers });
72
+
73
+ const res = await request(app)
74
+ .get('/api/v1/test')
75
+ .set('host', TEST_HOST)
76
+ .set('content-type', 'application/json')
77
+ .set(sigHeaders);
78
+
79
+ expect(res.status).toBe(200);
80
+ expect(res.body.ok).toBe(true);
81
+ });
82
+
83
+ it('should pass through with valid HMAC-SHA256 signature (POST with body)', async () => {
84
+ const app = createTestApp();
85
+ const body = JSON.stringify({ name: 'test' });
86
+ const url = `http://${TEST_HOST}/api/v1/test`;
87
+ const headers: Record<string, string> = {
88
+ 'content-type': 'application/json',
89
+ };
90
+
91
+ const sigHeaders = await signRequest(
92
+ { keyId: TEST_KEY_ID, secret: TEST_SECRET },
93
+ { method: 'POST', url, headers, body }
94
+ );
95
+
96
+ const res = await request(app)
97
+ .post('/api/v1/test')
98
+ .set('host', TEST_HOST)
99
+ .set('content-type', 'application/json')
100
+ .set(sigHeaders)
101
+ .send(body);
102
+
103
+ expect(res.status).toBe(200);
104
+ expect(res.body.ok).toBe(true);
105
+ });
106
+
107
+ it('should return 401 when Content-Digest does not match body', async () => {
108
+ const app = createTestApp();
109
+ const body = JSON.stringify({ name: 'test' });
110
+ const url = `http://${TEST_HOST}/api/v1/test`;
111
+ const headers: Record<string, string> = {
112
+ 'content-type': 'application/json',
113
+ };
114
+
115
+ const sigHeaders = await signRequest(
116
+ { keyId: TEST_KEY_ID, secret: TEST_SECRET },
117
+ { method: 'POST', url, headers, body }
118
+ );
119
+
120
+ // Send different body than what was signed
121
+ const res = await request(app)
122
+ .post('/api/v1/test')
123
+ .set('host', TEST_HOST)
124
+ .set('content-type', 'application/json')
125
+ .set(sigHeaders)
126
+ .send(JSON.stringify({ name: 'tampered' }));
127
+
128
+ expect(res.status).toBe(401);
129
+ });
130
+
131
+ it('should return 401 for unknown key', async () => {
132
+ const app = createTestApp();
133
+ const url = `http://${TEST_HOST}/api/v1/test`;
134
+ const headers: Record<string, string> = {
135
+ 'content-type': 'application/json',
136
+ };
137
+
138
+ const sigHeaders = await signRequest({ keyId: 'ak_unknown', secret: 'sk_wrong' }, { method: 'GET', url, headers });
139
+
140
+ const res = await request(app)
141
+ .get('/api/v1/test')
142
+ .set('host', TEST_HOST)
143
+ .set('content-type', 'application/json')
144
+ .set(sigHeaders);
145
+
146
+ expect(res.status).toBe(401);
147
+ });
148
+ });
@@ -0,0 +1,68 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { timingSafeEqual } from 'crypto';
3
+ import { verifySignedRequest, computeContentDigest } from '@appliance.sh/sdk';
4
+ import { apiKeyService } from '../services/api-key.service';
5
+
6
+ declare module 'express-serve-static-core' {
7
+ interface Request {
8
+ apiKeyId?: string;
9
+ rawBody?: Buffer;
10
+ }
11
+ }
12
+
13
+ export async function signatureAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
14
+ const signature = req.headers['signature'];
15
+ const signatureInput = req.headers['signature-input'];
16
+
17
+ if (!signature || !signatureInput) {
18
+ res.status(401).json({ error: 'Missing signature headers' });
19
+ return;
20
+ }
21
+
22
+ // Verify Content-Digest for requests with body
23
+ if (req.rawBody && req.rawBody.length > 0) {
24
+ const contentDigest = req.headers['content-digest'] as string | undefined;
25
+ if (!contentDigest) {
26
+ res.status(401).json({ error: 'Missing Content-Digest header' });
27
+ return;
28
+ }
29
+
30
+ const expected = computeContentDigest(req.rawBody.toString());
31
+ if (
32
+ contentDigest.length !== expected.length ||
33
+ !timingSafeEqual(Buffer.from(contentDigest), Buffer.from(expected))
34
+ ) {
35
+ res.status(401).json({ error: 'Unauthorized' });
36
+ return;
37
+ }
38
+ }
39
+
40
+ const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
41
+
42
+ const result = await verifySignedRequest(
43
+ {
44
+ method: req.method,
45
+ url,
46
+ headers: req.headers as Record<string, string | string[]>,
47
+ },
48
+ async (keyId: string) => {
49
+ const key = await apiKeyService.getByKeyId(keyId);
50
+ if (!key) return null;
51
+ return { secret: key.secret };
52
+ }
53
+ );
54
+
55
+ if (!result.verified) {
56
+ res.status(401).json({ error: 'Unauthorized' });
57
+ return;
58
+ }
59
+
60
+ req.apiKeyId = result.keyId;
61
+
62
+ // Fire-and-forget lastUsed update
63
+ if (result.keyId) {
64
+ apiKeyService.updateLastUsed(result.keyId).catch(() => {});
65
+ }
66
+
67
+ next();
68
+ }
@@ -0,0 +1,44 @@
1
+ import { Router } from 'express';
2
+ import { timingSafeEqual } from 'crypto';
3
+ import { apiKeyInput } from '@appliance.sh/sdk';
4
+ import { apiKeyService } from '../../services/api-key.service';
5
+
6
+ function constantTimeEqual(a: string, b: string): boolean {
7
+ if (a.length !== b.length) return false;
8
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
9
+ }
10
+
11
+ export const bootstrapRoutes = Router();
12
+
13
+ bootstrapRoutes.post('/create-key', async (req, res) => {
14
+ try {
15
+ const bootstrapToken = process.env.BOOTSTRAP_TOKEN;
16
+ if (!bootstrapToken) {
17
+ res.status(500).json({ error: 'Bootstrap token not configured' });
18
+ return;
19
+ }
20
+
21
+ const providedToken = req.headers['x-bootstrap-token'] as string | undefined;
22
+ if (!providedToken || !constantTimeEqual(providedToken, bootstrapToken)) {
23
+ res.status(403).json({ error: 'Invalid bootstrap token' });
24
+ return;
25
+ }
26
+
27
+ const input = apiKeyInput.parse(req.body);
28
+ const result = await apiKeyService.create(input.name);
29
+ res.status(201).json(result);
30
+ } catch (error) {
31
+ console.error('Create key error:', error);
32
+ res.status(400).json({ error: 'Failed to create API key', message: String(error) });
33
+ }
34
+ });
35
+
36
+ bootstrapRoutes.get('/status', async (_req, res) => {
37
+ try {
38
+ const initialized = await apiKeyService.exists();
39
+ res.json({ initialized });
40
+ } catch (error) {
41
+ console.error('Bootstrap status error:', error);
42
+ res.status(500).json({ error: 'Failed to check bootstrap status', message: String(error) });
43
+ }
44
+ });
@@ -0,0 +1,30 @@
1
+ import { Router } from 'express';
2
+ import { deploymentInput } from '@appliance.sh/sdk';
3
+ import { deploymentService } from '../../services/deployment.service';
4
+
5
+ export const deploymentRoutes = Router();
6
+
7
+ deploymentRoutes.post('/', async (req, res) => {
8
+ try {
9
+ const input = deploymentInput.parse(req.body);
10
+ const deployment = await deploymentService.execute(input);
11
+ res.status(201).json(deployment);
12
+ } catch (error) {
13
+ console.error('Execute deployment error:', error);
14
+ res.status(400).json({ error: 'Failed to execute deployment', message: String(error) });
15
+ }
16
+ });
17
+
18
+ deploymentRoutes.get('/:id', async (req, res) => {
19
+ try {
20
+ const deployment = await deploymentService.get(req.params.id);
21
+ if (!deployment) {
22
+ res.status(404).json({ error: 'Deployment not found' });
23
+ return;
24
+ }
25
+ res.json(deployment);
26
+ } catch (error) {
27
+ console.error('Get deployment error:', error);
28
+ res.status(500).json({ error: 'Failed to get deployment', message: String(error) });
29
+ }
30
+ });
@@ -0,0 +1,84 @@
1
+ import { Router } from 'express';
2
+ import { environmentInput, applianceBaseConfig } from '@appliance.sh/sdk';
3
+ import { environmentService } from '../../services/environment.service';
4
+ import { projectService } from '../../services/project.service';
5
+
6
+ interface EnvironmentParams {
7
+ projectId: string;
8
+ id?: string;
9
+ }
10
+
11
+ function getServerBaseConfig() {
12
+ const raw = process.env.APPLIANCE_BASE_CONFIG;
13
+ if (!raw) throw new Error('APPLIANCE_BASE_CONFIG is not set on the server');
14
+ return applianceBaseConfig.parse(JSON.parse(raw));
15
+ }
16
+
17
+ export const environmentRoutes = Router({ mergeParams: true });
18
+
19
+ environmentRoutes.post('/', async (req, res) => {
20
+ try {
21
+ const params = req.params as EnvironmentParams;
22
+ const project = await projectService.get(params.projectId);
23
+ if (!project) {
24
+ res.status(404).json({ error: 'Project not found' });
25
+ return;
26
+ }
27
+ const baseConfig = getServerBaseConfig();
28
+ const input = environmentInput.parse({
29
+ ...req.body,
30
+ projectId: params.projectId,
31
+ });
32
+ const environment = await environmentService.create(input, project.name, baseConfig);
33
+ res.status(201).json(environment);
34
+ } catch (error) {
35
+ console.error('Create environment error:', error);
36
+ res.status(400).json({ error: 'Failed to create environment', message: String(error) });
37
+ }
38
+ });
39
+
40
+ environmentRoutes.get('/', async (req, res) => {
41
+ try {
42
+ const params = req.params as EnvironmentParams;
43
+ const environments = await environmentService.listByProject(params.projectId);
44
+ res.json(environments);
45
+ } catch (error) {
46
+ console.error('List environments error:', error);
47
+ res.status(500).json({ error: 'Failed to list environments', message: String(error) });
48
+ }
49
+ });
50
+
51
+ environmentRoutes.get('/:id', async (req, res) => {
52
+ try {
53
+ const params = req.params as EnvironmentParams;
54
+ const environment = await environmentService.get(params.id!);
55
+ if (!environment) {
56
+ res.status(404).json({ error: 'Environment not found' });
57
+ return;
58
+ }
59
+ if (environment.projectId !== params.projectId) {
60
+ res.status(404).json({ error: 'Environment not found' });
61
+ return;
62
+ }
63
+ res.json(environment);
64
+ } catch (error) {
65
+ console.error('Get environment error:', error);
66
+ res.status(500).json({ error: 'Failed to get environment', message: String(error) });
67
+ }
68
+ });
69
+
70
+ environmentRoutes.delete('/:id', async (req, res) => {
71
+ try {
72
+ const params = req.params as EnvironmentParams;
73
+ const environment = await environmentService.get(params.id!);
74
+ if (environment && environment.projectId !== params.projectId) {
75
+ res.status(404).json({ error: 'Environment not found' });
76
+ return;
77
+ }
78
+ await environmentService.delete(params.id!);
79
+ res.status(204).send();
80
+ } catch (error) {
81
+ console.error('Delete environment error:', error);
82
+ res.status(500).json({ error: 'Failed to delete environment', message: String(error) });
83
+ }
84
+ });
@@ -0,0 +1,50 @@
1
+ import { Router } from 'express';
2
+ import { projectInput } from '@appliance.sh/sdk';
3
+ import { projectService } from '../../services/project.service';
4
+
5
+ export const projectRoutes = Router();
6
+
7
+ projectRoutes.post('/', async (req, res) => {
8
+ try {
9
+ const input = projectInput.parse(req.body);
10
+ const project = await projectService.create(input);
11
+ res.status(201).json(project);
12
+ } catch (error) {
13
+ console.error('Create project error:', error);
14
+ res.status(400).json({ error: 'Failed to create project', message: String(error) });
15
+ }
16
+ });
17
+
18
+ projectRoutes.get('/', async (_req, res) => {
19
+ try {
20
+ const projects = await projectService.list();
21
+ res.json(projects);
22
+ } catch (error) {
23
+ console.error('List projects error:', error);
24
+ res.status(500).json({ error: 'Failed to list projects', message: String(error) });
25
+ }
26
+ });
27
+
28
+ projectRoutes.get('/:id', async (req, res) => {
29
+ try {
30
+ const project = await projectService.get(req.params.id);
31
+ if (!project) {
32
+ res.status(404).json({ error: 'Project not found' });
33
+ return;
34
+ }
35
+ res.json(project);
36
+ } catch (error) {
37
+ console.error('Get project error:', error);
38
+ res.status(500).json({ error: 'Failed to get project', message: String(error) });
39
+ }
40
+ });
41
+
42
+ projectRoutes.delete('/:id', async (req, res) => {
43
+ try {
44
+ await projectService.delete(req.params.id);
45
+ res.status(204).send();
46
+ } catch (error) {
47
+ console.error('Delete project error:', error);
48
+ res.status(500).json({ error: 'Failed to delete project', message: String(error) });
49
+ }
50
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ApiKeyService } from './api-key.service';
3
+
4
+ const mockStore = new Map<string, string>();
5
+
6
+ vi.mock('./storage.service', () => ({
7
+ getStorageService: () => ({
8
+ get: async (_collection: string, id: string) => {
9
+ const key = `${_collection}/${id}.json`;
10
+ const data = mockStore.get(key);
11
+ return data ? JSON.parse(data) : null;
12
+ },
13
+ set: async (_collection: string, id: string, value: unknown) => {
14
+ const key = `${_collection}/${id}.json`;
15
+ mockStore.set(key, JSON.stringify(value));
16
+ },
17
+ getAll: async (_collection: string) => {
18
+ const items: unknown[] = [];
19
+ for (const [key, value] of mockStore) {
20
+ if (key.startsWith(`${_collection}/`)) {
21
+ items.push(JSON.parse(value));
22
+ }
23
+ }
24
+ return items;
25
+ },
26
+ delete: async (_collection: string, id: string) => {
27
+ const key = `${_collection}/${id}.json`;
28
+ mockStore.delete(key);
29
+ },
30
+ }),
31
+ }));
32
+
33
+ describe('ApiKeyService', () => {
34
+ let service: ApiKeyService;
35
+
36
+ beforeEach(() => {
37
+ mockStore.clear();
38
+ service = new ApiKeyService();
39
+ });
40
+
41
+ it('should create a key with ak_ prefixed id and sk_ prefixed secret', async () => {
42
+ const result = await service.create('test-key');
43
+ expect(result.id).toMatch(/^ak_/);
44
+ expect(result.secret).toMatch(/^sk_/);
45
+ expect(result.name).toBe('test-key');
46
+ expect(result.createdAt).toBeDefined();
47
+ });
48
+
49
+ it('should retrieve a stored key by id', async () => {
50
+ const created = await service.create('test-key');
51
+ const stored = await service.getByKeyId(created.id);
52
+ expect(stored).not.toBeNull();
53
+ expect(stored!.id).toBe(created.id);
54
+ expect(stored!.name).toBe('test-key');
55
+ expect(stored!.secret).toBe(created.secret);
56
+ });
57
+
58
+ it('should return null for non-existent key', async () => {
59
+ const result = await service.getByKeyId('ak_nonexistent');
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it('should return false for exists when no keys', async () => {
64
+ const result = await service.exists();
65
+ expect(result).toBe(false);
66
+ });
67
+
68
+ it('should return true for exists when keys exist', async () => {
69
+ await service.create('test-key');
70
+ const result = await service.exists();
71
+ expect(result).toBe(true);
72
+ });
73
+
74
+ it('should update lastUsedAt', async () => {
75
+ const created = await service.create('test-key');
76
+ await service.updateLastUsed(created.id);
77
+ const stored = await service.getByKeyId(created.id);
78
+ expect(stored!.lastUsedAt).toBeDefined();
79
+ });
80
+ });
@@ -0,0 +1,59 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { getStorageService } from './storage.service';
3
+ import { ApiKeyCreateResponse, generateId } from '@appliance.sh/sdk';
4
+
5
+ const COLLECTION = 'api-keys';
6
+
7
+ // The shared secret must be stored to verify HMAC signatures (RFC 9421).
8
+ // Unlike password hashing, HMAC requires the original key on both sides.
9
+ interface StoredApiKey {
10
+ id: string;
11
+ name: string;
12
+ secret: string;
13
+ createdAt: string;
14
+ lastUsedAt?: string;
15
+ }
16
+
17
+ export class ApiKeyService {
18
+ async create(name: string): Promise<ApiKeyCreateResponse> {
19
+ const storage = getStorageService();
20
+ const id = generateId('ak');
21
+ const secret = `sk_${randomBytes(32).toString('hex')}`;
22
+ const now = new Date().toISOString();
23
+
24
+ const stored: StoredApiKey = {
25
+ id,
26
+ name,
27
+ secret,
28
+ createdAt: now,
29
+ };
30
+
31
+ await storage.set(COLLECTION, id, stored);
32
+
33
+ return { id, name, secret, createdAt: now };
34
+ }
35
+
36
+ async getByKeyId(keyId: string): Promise<StoredApiKey | null> {
37
+ const storage = getStorageService();
38
+ return storage.get<StoredApiKey>(COLLECTION, keyId);
39
+ }
40
+
41
+ async exists(): Promise<boolean> {
42
+ const storage = getStorageService();
43
+ const keys = await storage.getAll<StoredApiKey>(COLLECTION);
44
+ return keys.length > 0;
45
+ }
46
+
47
+ async updateLastUsed(keyId: string): Promise<void> {
48
+ const storage = getStorageService();
49
+ const existing = await storage.get<StoredApiKey>(COLLECTION, keyId);
50
+ if (existing) {
51
+ await storage.set(COLLECTION, keyId, {
52
+ ...existing,
53
+ lastUsedAt: new Date().toISOString(),
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ export const apiKeyService = new ApiKeyService();
@@ -0,0 +1,106 @@
1
+ import {
2
+ Deployment,
3
+ DeploymentInput,
4
+ DeploymentStatus,
5
+ DeploymentAction,
6
+ EnvironmentStatus,
7
+ generateId,
8
+ } from '@appliance.sh/sdk';
9
+ import { createApplianceDeploymentService } from '@appliance.sh/infra';
10
+ import { getStorageService } from './storage.service';
11
+ import { environmentService } from './environment.service';
12
+ import { projectService } from './project.service';
13
+
14
+ const COLLECTION = 'deployments';
15
+
16
+ export class DeploymentService {
17
+ async execute(input: DeploymentInput): Promise<Deployment> {
18
+ const storage = getStorageService();
19
+
20
+ const environment = await environmentService.get(input.environmentId);
21
+ if (!environment) {
22
+ throw new Error(`Environment not found: ${input.environmentId}`);
23
+ }
24
+
25
+ const now = new Date().toISOString();
26
+ const deployment: Deployment = {
27
+ ...input,
28
+ id: generateId('dep'),
29
+ projectId: environment.projectId,
30
+ status: DeploymentStatus.Pending,
31
+ startedAt: now,
32
+ };
33
+
34
+ await storage.set(COLLECTION, deployment.id, deployment);
35
+
36
+ // Update deployment to in_progress
37
+ deployment.status = DeploymentStatus.InProgress;
38
+ await storage.set(COLLECTION, deployment.id, deployment);
39
+
40
+ // Update environment status
41
+ const envStatus =
42
+ input.action === DeploymentAction.Deploy ? EnvironmentStatus.Deploying : EnvironmentStatus.Destroying;
43
+ await environmentService.updateStatus(environment.id, envStatus);
44
+
45
+ // Look up the project for tagging
46
+ const project = await projectService.get(environment.projectId);
47
+ if (!project) {
48
+ throw new Error(`Project not found: ${environment.projectId}`);
49
+ }
50
+
51
+ const metadata = {
52
+ projectId: project.id,
53
+ projectName: project.name,
54
+ environmentId: environment.id,
55
+ environmentName: environment.name,
56
+ deploymentId: deployment.id,
57
+ stackName: environment.stackName,
58
+ };
59
+
60
+ // Execute the deployment
61
+ try {
62
+ const deploymentService = createApplianceDeploymentService({
63
+ baseConfig: environment.baseConfig,
64
+ });
65
+
66
+ let result;
67
+ if (input.action === DeploymentAction.Deploy) {
68
+ result = await deploymentService.deploy(environment.stackName, metadata);
69
+ } else {
70
+ result = await deploymentService.destroy(environment.stackName);
71
+ }
72
+
73
+ deployment.status = DeploymentStatus.Succeeded;
74
+ deployment.completedAt = new Date().toISOString();
75
+ deployment.message = result.message;
76
+ deployment.idempotentNoop = result.idempotentNoop;
77
+ await storage.set(COLLECTION, deployment.id, deployment);
78
+
79
+ // Update environment status
80
+ const finalEnvStatus =
81
+ input.action === DeploymentAction.Deploy ? EnvironmentStatus.Deployed : EnvironmentStatus.Destroyed;
82
+ await environmentService.updateStatus(environment.id, finalEnvStatus);
83
+ } catch (error) {
84
+ deployment.status = DeploymentStatus.Failed;
85
+ deployment.completedAt = new Date().toISOString();
86
+ deployment.message = error instanceof Error ? error.message : String(error);
87
+ await storage.set(COLLECTION, deployment.id, deployment);
88
+
89
+ await environmentService.updateStatus(environment.id, EnvironmentStatus.Failed);
90
+ }
91
+
92
+ return deployment;
93
+ }
94
+
95
+ async get(id: string): Promise<Deployment | null> {
96
+ const storage = getStorageService();
97
+ return storage.get<Deployment>(COLLECTION, id);
98
+ }
99
+
100
+ async listByEnvironment(environmentId: string): Promise<Deployment[]> {
101
+ const storage = getStorageService();
102
+ return storage.filter<Deployment>(COLLECTION, (d) => d.environmentId === environmentId);
103
+ }
104
+ }
105
+
106
+ export const deploymentService = new DeploymentService();
@@ -0,0 +1,72 @@
1
+ import { Environment, EnvironmentInput, EnvironmentStatus, generateId } from '@appliance.sh/sdk';
2
+ import type { ApplianceBaseConfig } from '@appliance.sh/sdk';
3
+ import { getStorageService } from './storage.service';
4
+
5
+ const COLLECTION = 'environments';
6
+
7
+ export class EnvironmentService {
8
+ async create(input: EnvironmentInput, projectName: string, baseConfig: ApplianceBaseConfig): Promise<Environment> {
9
+ const storage = getStorageService();
10
+ const now = new Date().toISOString();
11
+ const id = generateId('env');
12
+ const environment: Environment = {
13
+ ...input,
14
+ id,
15
+ baseConfig,
16
+ status: EnvironmentStatus.Pending,
17
+ stackName: `${projectName}-${input.name}`,
18
+ createdAt: now,
19
+ updatedAt: now,
20
+ };
21
+ await storage.set(COLLECTION, environment.id, environment);
22
+ return environment;
23
+ }
24
+
25
+ async get(id: string): Promise<Environment | null> {
26
+ const storage = getStorageService();
27
+ return storage.get<Environment>(COLLECTION, id);
28
+ }
29
+
30
+ async listByProject(projectId: string): Promise<Environment[]> {
31
+ const storage = getStorageService();
32
+ return storage.filter<Environment>(COLLECTION, (env) => env.projectId === projectId);
33
+ }
34
+
35
+ async delete(id: string): Promise<void> {
36
+ const storage = getStorageService();
37
+ await storage.delete(COLLECTION, id);
38
+ }
39
+
40
+ async updateStatus(id: string, status: EnvironmentStatus): Promise<Environment | null> {
41
+ const storage = getStorageService();
42
+ const existing = await storage.get<Environment>(COLLECTION, id);
43
+ if (!existing) return null;
44
+
45
+ const updated: Environment = {
46
+ ...existing,
47
+ status,
48
+ updatedAt: new Date().toISOString(),
49
+ lastDeployedAt: status === EnvironmentStatus.Deployed ? new Date().toISOString() : existing.lastDeployedAt,
50
+ };
51
+ await storage.set(COLLECTION, id, updated);
52
+ return updated;
53
+ }
54
+
55
+ async update(id: string, updates: Partial<Environment>): Promise<Environment | null> {
56
+ const storage = getStorageService();
57
+ const existing = await storage.get<Environment>(COLLECTION, id);
58
+ if (!existing) return null;
59
+
60
+ const updated: Environment = {
61
+ ...existing,
62
+ ...updates,
63
+ id: existing.id,
64
+ createdAt: existing.createdAt,
65
+ updatedAt: new Date().toISOString(),
66
+ };
67
+ await storage.set(COLLECTION, id, updated);
68
+ return updated;
69
+ }
70
+ }
71
+
72
+ export const environmentService = new EnvironmentService();
@@ -0,0 +1,53 @@
1
+ import { Project, ProjectInput, ProjectStatus, generateId } from '@appliance.sh/sdk';
2
+ import { getStorageService } from './storage.service';
3
+
4
+ const COLLECTION = 'projects';
5
+
6
+ export class ProjectService {
7
+ async create(input: ProjectInput): Promise<Project> {
8
+ const storage = getStorageService();
9
+ const now = new Date().toISOString();
10
+ const project: Project = {
11
+ ...input,
12
+ id: generateId('proj'),
13
+ status: ProjectStatus.Active,
14
+ createdAt: now,
15
+ updatedAt: now,
16
+ };
17
+ await storage.set(COLLECTION, project.id, project);
18
+ return project;
19
+ }
20
+
21
+ async get(id: string): Promise<Project | null> {
22
+ const storage = getStorageService();
23
+ return storage.get<Project>(COLLECTION, id);
24
+ }
25
+
26
+ async list(): Promise<Project[]> {
27
+ const storage = getStorageService();
28
+ return storage.getAll<Project>(COLLECTION);
29
+ }
30
+
31
+ async delete(id: string): Promise<void> {
32
+ const storage = getStorageService();
33
+ await storage.delete(COLLECTION, id);
34
+ }
35
+
36
+ async update(id: string, updates: Partial<Project>): Promise<Project | null> {
37
+ const storage = getStorageService();
38
+ const existing = await storage.get<Project>(COLLECTION, id);
39
+ if (!existing) return null;
40
+
41
+ const updated: Project = {
42
+ ...existing,
43
+ ...updates,
44
+ id: existing.id,
45
+ createdAt: existing.createdAt,
46
+ updatedAt: new Date().toISOString(),
47
+ };
48
+ await storage.set(COLLECTION, id, updated);
49
+ return updated;
50
+ }
51
+ }
52
+
53
+ export const projectService = new ProjectService();
@@ -0,0 +1,78 @@
1
+ import {
2
+ S3Client,
3
+ GetObjectCommand,
4
+ PutObjectCommand,
5
+ DeleteObjectCommand,
6
+ ListObjectsV2Command,
7
+ } from '@aws-sdk/client-s3';
8
+ import type { ObjectStore } from '@appliance.sh/sdk';
9
+
10
+ export class S3ObjectStore implements ObjectStore {
11
+ private readonly client: S3Client;
12
+ private readonly bucketName: string;
13
+
14
+ constructor(bucketName: string, region?: string) {
15
+ this.bucketName = bucketName;
16
+ this.client = new S3Client({ region: region ?? 'us-east-1' });
17
+ }
18
+
19
+ async get(key: string): Promise<string | null> {
20
+ try {
21
+ const command = new GetObjectCommand({
22
+ Bucket: this.bucketName,
23
+ Key: key,
24
+ });
25
+ const response = await this.client.send(command);
26
+ return (await response.Body?.transformToString()) ?? null;
27
+ } catch (error) {
28
+ if ((error as { name?: string }).name === 'NoSuchKey') {
29
+ return null;
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ async set(key: string, value: string): Promise<void> {
36
+ const command = new PutObjectCommand({
37
+ Bucket: this.bucketName,
38
+ Key: key,
39
+ Body: value,
40
+ ContentType: 'application/json',
41
+ });
42
+ await this.client.send(command);
43
+ }
44
+
45
+ async delete(key: string): Promise<void> {
46
+ const command = new DeleteObjectCommand({
47
+ Bucket: this.bucketName,
48
+ Key: key,
49
+ });
50
+ await this.client.send(command);
51
+ }
52
+
53
+ async list(prefix?: string): Promise<string[]> {
54
+ const keys: string[] = [];
55
+ let continuationToken: string | undefined;
56
+
57
+ do {
58
+ const command = new ListObjectsV2Command({
59
+ Bucket: this.bucketName,
60
+ Prefix: prefix,
61
+ ContinuationToken: continuationToken,
62
+ });
63
+ const response = await this.client.send(command);
64
+
65
+ if (response.Contents) {
66
+ for (const object of response.Contents) {
67
+ if (object.Key) {
68
+ keys.push(object.Key);
69
+ }
70
+ }
71
+ }
72
+
73
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
74
+ } while (continuationToken);
75
+
76
+ return keys;
77
+ }
78
+ }
@@ -0,0 +1,72 @@
1
+ import { applianceBaseConfig, type ObjectStore } from '@appliance.sh/sdk';
2
+ import { S3ObjectStore } from './s3-object-store';
3
+
4
+ export class StorageService {
5
+ private readonly store: ObjectStore;
6
+
7
+ constructor(store: ObjectStore) {
8
+ this.store = store;
9
+ }
10
+
11
+ private getKey(collection: string, id: string): string {
12
+ return `${collection}/${id}.json`;
13
+ }
14
+
15
+ async get<T>(collection: string, id: string): Promise<T | null> {
16
+ const data = await this.store.get(this.getKey(collection, id));
17
+ if (!data) return null;
18
+ return JSON.parse(data) as T;
19
+ }
20
+
21
+ async getAll<T>(collection: string): Promise<T[]> {
22
+ const keys = await this.store.list(`${collection}/`);
23
+ const items: T[] = [];
24
+
25
+ for (const key of keys) {
26
+ const data = await this.store.get(key);
27
+ if (data) {
28
+ items.push(JSON.parse(data) as T);
29
+ }
30
+ }
31
+
32
+ return items;
33
+ }
34
+
35
+ async set<T>(collection: string, id: string, value: T): Promise<void> {
36
+ await this.store.set(this.getKey(collection, id), JSON.stringify(value));
37
+ }
38
+
39
+ async delete(collection: string, id: string): Promise<void> {
40
+ await this.store.delete(this.getKey(collection, id));
41
+ }
42
+
43
+ async filter<T>(collection: string, predicate: (item: T) => boolean): Promise<T[]> {
44
+ const all = await this.getAll<T>(collection);
45
+ return all.filter(predicate);
46
+ }
47
+ }
48
+
49
+ function createStorageService(): StorageService {
50
+ const baseConfigJson = process.env.APPLIANCE_BASE_CONFIG;
51
+ if (!baseConfigJson) {
52
+ throw new Error('APPLIANCE_BASE_CONFIG environment variable is required');
53
+ }
54
+
55
+ const config = applianceBaseConfig.parse(JSON.parse(baseConfigJson));
56
+
57
+ if (!config.aws.dataBucketName) {
58
+ throw new Error('dataBucketName is required in APPLIANCE_BASE_CONFIG');
59
+ }
60
+
61
+ const store = new S3ObjectStore(config.aws.dataBucketName, config.aws.region);
62
+ return new StorageService(store);
63
+ }
64
+
65
+ let storageServiceInstance: StorageService | null = null;
66
+
67
+ export function getStorageService(): StorageService {
68
+ if (!storageServiceInstance) {
69
+ storageServiceInstance = createStorageService();
70
+ }
71
+ return storageServiceInstance;
72
+ }
@@ -1,24 +0,0 @@
1
- import { Router } from 'express';
2
- import { pulumiService } from '../../services/pulumi.service';
3
-
4
- export const infraRoutes = Router();
5
-
6
- infraRoutes.post('/deploy', async (_req, res) => {
7
- try {
8
- const result = await pulumiService.deploy();
9
- res.json(result);
10
- } catch (error) {
11
- console.error('Deploy error:', error);
12
- res.status(500).json({ error: 'Deploy failed', message: String(error) });
13
- }
14
- });
15
-
16
- infraRoutes.post('/destroy', async (_req, res) => {
17
- try {
18
- const result = await pulumiService.destroy();
19
- res.json(result);
20
- } catch (error) {
21
- console.error('Destroy error:', error);
22
- res.status(500).json({ error: 'Destroy failed', message: String(error) });
23
- }
24
- });
@@ -1,12 +0,0 @@
1
- import {
2
- ApplianceDeploymentService,
3
- createApplianceDeploymentService,
4
- type PulumiAction,
5
- type PulumiResult,
6
- } from '@appliance.sh/infra';
7
-
8
- // Re-export types for consumers
9
- export type { PulumiAction, PulumiResult };
10
-
11
- // Export a singleton instance
12
- export const pulumiService: ApplianceDeploymentService = createApplianceDeploymentService();