@appliance.sh/api-server 1.16.1 → 1.18.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.16.1",
3
+ "version": "1.18.0",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -19,11 +19,9 @@
19
19
  "test:e2e": "vitest run --config vitest.e2e.config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@appliance.sh/infra": "1.16.1",
23
- "@appliance.sh/sdk": "1.16.1",
24
- "@pulumi/aws": "^7.16.0",
25
- "@pulumi/aws-native": "^1.48.0",
26
- "@pulumi/pulumi": "^3.216.0",
22
+ "@appliance.sh/infra": "1.18.0",
23
+ "@appliance.sh/sdk": "1.18.0",
24
+ "@aws-sdk/client-s3": "^3.750.0",
27
25
  "express": "^5.2.1"
28
26
  },
29
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,32 @@
1
1
  import express from 'express';
2
2
  import { indexRoutes } from './routes';
3
3
  import { infraRoutes } from './routes/infra';
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);
29
+ app.use('/api/v1/infra', signatureAuth, infraRoutes);
13
30
 
14
31
  return app;
15
32
  }
@@ -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 { randomUUID, randomBytes } from 'crypto';
2
+ import { getStorageService } from './storage.service';
3
+ import { ApiKeyCreateResponse } 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 = `ak_${randomUUID()}`;
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,84 @@
1
+ import { Deployment, DeploymentInput, DeploymentStatus, DeploymentAction, EnvironmentStatus } from '@appliance.sh/sdk';
2
+ import { createApplianceDeploymentService } from '@appliance.sh/infra';
3
+ import { getStorageService } from './storage.service';
4
+ import { environmentService } from './environment.service';
5
+ import { randomUUID } from 'crypto';
6
+
7
+ const COLLECTION = 'deployments';
8
+
9
+ export class DeploymentService {
10
+ async execute(input: DeploymentInput): Promise<Deployment> {
11
+ const storage = getStorageService();
12
+
13
+ const environment = await environmentService.get(input.environmentId);
14
+ if (!environment) {
15
+ throw new Error(`Environment not found: ${input.environmentId}`);
16
+ }
17
+
18
+ const now = new Date().toISOString();
19
+ const deployment: Deployment = {
20
+ ...input,
21
+ id: randomUUID(),
22
+ projectId: environment.projectId,
23
+ status: DeploymentStatus.Pending,
24
+ startedAt: now,
25
+ };
26
+
27
+ await storage.set(COLLECTION, deployment.id, deployment);
28
+
29
+ // Update deployment to in_progress
30
+ deployment.status = DeploymentStatus.InProgress;
31
+ await storage.set(COLLECTION, deployment.id, deployment);
32
+
33
+ // Update environment status
34
+ const envStatus =
35
+ input.action === DeploymentAction.Deploy ? EnvironmentStatus.Deploying : EnvironmentStatus.Destroying;
36
+ await environmentService.updateStatus(environment.id, envStatus);
37
+
38
+ // Execute the deployment
39
+ try {
40
+ const deploymentService = createApplianceDeploymentService({
41
+ baseConfig: environment.baseConfig,
42
+ });
43
+
44
+ let result;
45
+ if (input.action === DeploymentAction.Deploy) {
46
+ result = await deploymentService.deploy(environment.stackName);
47
+ } else {
48
+ result = await deploymentService.destroy(environment.stackName);
49
+ }
50
+
51
+ deployment.status = DeploymentStatus.Succeeded;
52
+ deployment.completedAt = new Date().toISOString();
53
+ deployment.message = result.message;
54
+ deployment.idempotentNoop = result.idempotentNoop;
55
+ await storage.set(COLLECTION, deployment.id, deployment);
56
+
57
+ // Update environment status
58
+ const finalEnvStatus =
59
+ input.action === DeploymentAction.Deploy ? EnvironmentStatus.Deployed : EnvironmentStatus.Destroyed;
60
+ await environmentService.updateStatus(environment.id, finalEnvStatus);
61
+ } catch (error) {
62
+ deployment.status = DeploymentStatus.Failed;
63
+ deployment.completedAt = new Date().toISOString();
64
+ deployment.message = error instanceof Error ? error.message : String(error);
65
+ await storage.set(COLLECTION, deployment.id, deployment);
66
+
67
+ await environmentService.updateStatus(environment.id, EnvironmentStatus.Failed);
68
+ }
69
+
70
+ return deployment;
71
+ }
72
+
73
+ async get(id: string): Promise<Deployment | null> {
74
+ const storage = getStorageService();
75
+ return storage.get<Deployment>(COLLECTION, id);
76
+ }
77
+
78
+ async listByEnvironment(environmentId: string): Promise<Deployment[]> {
79
+ const storage = getStorageService();
80
+ return storage.filter<Deployment>(COLLECTION, (d) => d.environmentId === environmentId);
81
+ }
82
+ }
83
+
84
+ export const deploymentService = new DeploymentService();
@@ -0,0 +1,73 @@
1
+ import { Environment, EnvironmentInput, EnvironmentStatus } from '@appliance.sh/sdk';
2
+ import type { ApplianceBaseConfig } from '@appliance.sh/sdk';
3
+ import { getStorageService } from './storage.service';
4
+ import { randomUUID } from 'crypto';
5
+
6
+ const COLLECTION = 'environments';
7
+
8
+ export class EnvironmentService {
9
+ async create(input: EnvironmentInput, projectName: string, baseConfig: ApplianceBaseConfig): Promise<Environment> {
10
+ const storage = getStorageService();
11
+ const now = new Date().toISOString();
12
+ const id = randomUUID();
13
+ const environment: Environment = {
14
+ ...input,
15
+ id,
16
+ baseConfig,
17
+ status: EnvironmentStatus.Pending,
18
+ stackName: `${projectName}-${input.name}`,
19
+ createdAt: now,
20
+ updatedAt: now,
21
+ };
22
+ await storage.set(COLLECTION, environment.id, environment);
23
+ return environment;
24
+ }
25
+
26
+ async get(id: string): Promise<Environment | null> {
27
+ const storage = getStorageService();
28
+ return storage.get<Environment>(COLLECTION, id);
29
+ }
30
+
31
+ async listByProject(projectId: string): Promise<Environment[]> {
32
+ const storage = getStorageService();
33
+ return storage.filter<Environment>(COLLECTION, (env) => env.projectId === projectId);
34
+ }
35
+
36
+ async delete(id: string): Promise<void> {
37
+ const storage = getStorageService();
38
+ await storage.delete(COLLECTION, id);
39
+ }
40
+
41
+ async updateStatus(id: string, status: EnvironmentStatus): Promise<Environment | null> {
42
+ const storage = getStorageService();
43
+ const existing = await storage.get<Environment>(COLLECTION, id);
44
+ if (!existing) return null;
45
+
46
+ const updated: Environment = {
47
+ ...existing,
48
+ status,
49
+ updatedAt: new Date().toISOString(),
50
+ lastDeployedAt: status === EnvironmentStatus.Deployed ? new Date().toISOString() : existing.lastDeployedAt,
51
+ };
52
+ await storage.set(COLLECTION, id, updated);
53
+ return updated;
54
+ }
55
+
56
+ async update(id: string, updates: Partial<Environment>): Promise<Environment | null> {
57
+ const storage = getStorageService();
58
+ const existing = await storage.get<Environment>(COLLECTION, id);
59
+ if (!existing) return null;
60
+
61
+ const updated: Environment = {
62
+ ...existing,
63
+ ...updates,
64
+ id: existing.id,
65
+ createdAt: existing.createdAt,
66
+ updatedAt: new Date().toISOString(),
67
+ };
68
+ await storage.set(COLLECTION, id, updated);
69
+ return updated;
70
+ }
71
+ }
72
+
73
+ export const environmentService = new EnvironmentService();
@@ -0,0 +1,54 @@
1
+ import { Project, ProjectInput, ProjectStatus } from '@appliance.sh/sdk';
2
+ import { getStorageService } from './storage.service';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ const COLLECTION = 'projects';
6
+
7
+ export class ProjectService {
8
+ async create(input: ProjectInput): Promise<Project> {
9
+ const storage = getStorageService();
10
+ const now = new Date().toISOString();
11
+ const project: Project = {
12
+ ...input,
13
+ id: randomUUID(),
14
+ status: ProjectStatus.Active,
15
+ createdAt: now,
16
+ updatedAt: now,
17
+ };
18
+ await storage.set(COLLECTION, project.id, project);
19
+ return project;
20
+ }
21
+
22
+ async get(id: string): Promise<Project | null> {
23
+ const storage = getStorageService();
24
+ return storage.get<Project>(COLLECTION, id);
25
+ }
26
+
27
+ async list(): Promise<Project[]> {
28
+ const storage = getStorageService();
29
+ return storage.getAll<Project>(COLLECTION);
30
+ }
31
+
32
+ async delete(id: string): Promise<void> {
33
+ const storage = getStorageService();
34
+ await storage.delete(COLLECTION, id);
35
+ }
36
+
37
+ async update(id: string, updates: Partial<Project>): Promise<Project | null> {
38
+ const storage = getStorageService();
39
+ const existing = await storage.get<Project>(COLLECTION, id);
40
+ if (!existing) return null;
41
+
42
+ const updated: Project = {
43
+ ...existing,
44
+ ...updates,
45
+ id: existing.id,
46
+ createdAt: existing.createdAt,
47
+ updatedAt: new Date().toISOString(),
48
+ };
49
+ await storage.set(COLLECTION, id, updated);
50
+ return updated;
51
+ }
52
+ }
53
+
54
+ export const projectService = new ProjectService();
@@ -1,146 +1,12 @@
1
- import * as auto from '@pulumi/pulumi/automation';
2
- import * as aws from '@pulumi/aws';
3
- import * as awsNative from '@pulumi/aws-native';
4
- import { ApplianceStack } from '@appliance.sh/infra';
5
- import { applianceBaseConfig } from '@appliance.sh/sdk';
1
+ import {
2
+ ApplianceDeploymentService,
3
+ createApplianceDeploymentService,
4
+ type PulumiAction,
5
+ type PulumiResult,
6
+ } from '@appliance.sh/infra';
6
7
 
7
- export type PulumiAction = 'deploy' | 'destroy';
8
-
9
- export interface PulumiResult {
10
- action: PulumiAction;
11
- ok: boolean;
12
- idempotentNoop: boolean;
13
- message: string;
14
- stackName: string;
15
- }
16
-
17
- class PulumiService {
18
- private readonly projectName = 'appliance-api-managed-proj';
19
-
20
- private readonly baseConfig = process.env.APPLIANCE_BASE_CONFIG
21
- ? applianceBaseConfig.parse(JSON.parse(process.env.APPLIANCE_BASE_CONFIG))
22
- : undefined;
23
- private readonly region = this.baseConfig?.aws.region || 'us-east-1';
24
-
25
- private inlineProgram() {
26
- return async () => {
27
- const name = 'appliance';
28
-
29
- if (!this.baseConfig) {
30
- throw new Error('Missing base config');
31
- }
32
-
33
- const regionalProvider = new aws.Provider(`${name}-regional`, {
34
- region: (this.baseConfig?.aws.region as aws.Region) ?? 'ap-southeast-1',
35
- });
36
- const globalProvider = new aws.Provider(`${name}-global`, {
37
- region: 'us-east-1',
38
- });
39
- const nativeRegionalProvider = new awsNative.Provider(`${name}-native-regional`, {
40
- region: (this.baseConfig?.aws.region as awsNative.Region) ?? 'ap-southeast-1',
41
- });
42
-
43
- const nativeGlobalProvider = new awsNative.Provider(`${name}-native-global`, {
44
- region: 'us-east-1',
45
- });
46
-
47
- const applianceStack = new ApplianceStack(
48
- `${name}-stack`,
49
- {
50
- tags: { project: name },
51
- config: this.baseConfig,
52
- },
53
- {
54
- globalProvider,
55
- provider: regionalProvider,
56
- nativeProvider: nativeRegionalProvider,
57
- nativeGlobalProvider: nativeGlobalProvider,
58
- }
59
- );
60
-
61
- return {
62
- applianceStack,
63
- };
64
- };
65
- }
66
-
67
- private async getOrCreateStack(stackName: string): Promise<auto.Stack> {
68
- const program = this.inlineProgram();
69
- const envVars: Record<string, string> = {
70
- AWS_REGION: this.region,
71
- };
72
- if (!this.baseConfig) {
73
- throw new Error('Missing base config');
74
- }
75
- if (this.baseConfig) {
76
- envVars['PULUMI_BACKEND_URL'] = this.baseConfig.stateBackendUrl;
77
- }
78
-
79
- const stack = await auto.LocalWorkspace.createOrSelectStack(
80
- { projectName: this.projectName, stackName, program },
81
- { envVars }
82
- );
83
- await stack.setConfig('aws:region', { value: this.baseConfig.aws.region });
84
- return stack;
85
- }
86
-
87
- private async selectExistingStack(stackName: string): Promise<auto.Stack> {
88
- const envVars: Record<string, string> = {
89
- AWS_REGION: this.region,
90
- };
91
- if (!this.baseConfig) {
92
- throw new Error('Missing base config');
93
- }
94
- if (this.baseConfig) {
95
- envVars['PULUMI_BACKEND_URL'] = this.baseConfig.stateBackendUrl;
96
- }
97
-
98
- const ws = await auto.LocalWorkspace.create({
99
- projectSettings: { name: this.projectName, runtime: 'nodejs' },
100
- envVars,
101
- });
102
-
103
- return auto.Stack.createOrSelect(stackName, ws);
104
- }
105
-
106
- async deploy(stackName = 'appliance-api-managed'): Promise<PulumiResult> {
107
- const stack = await this.getOrCreateStack(stackName);
108
- const result = await stack.up({ onOutput: (m) => console.log(m) });
109
- const changes = result.summary.resourceChanges || {};
110
- const totalChanges = Object.entries(changes)
111
- .filter(([k]) => k !== 'same')
112
- .reduce((acc, [, v]) => acc + (v || 0), 0);
113
- const idempotentNoop = totalChanges === 0;
114
- return {
115
- action: 'deploy',
116
- ok: true,
117
- idempotentNoop,
118
- message: idempotentNoop ? 'No changes (idempotent)' : 'Stack updated',
119
- stackName,
120
- };
121
- }
122
-
123
- async destroy(stackName = 'appliance-api-managed'): Promise<PulumiResult> {
124
- try {
125
- const stack = await this.selectExistingStack(stackName);
126
- await stack.destroy({ onOutput: (m) => console.log(m) });
127
- return { action: 'destroy', ok: true, idempotentNoop: false, message: 'Stack resources deleted', stackName };
128
- } catch (e) {
129
- if (!(e instanceof Error)) throw e;
130
- const msg = String(e?.message || e);
131
- if (msg.includes('no stack named') || msg.includes('not found')) {
132
- return {
133
- action: 'destroy',
134
- ok: true,
135
- idempotentNoop: true,
136
- message: 'Stack not found (idempotent)',
137
- stackName,
138
- };
139
- }
140
- throw e;
141
- }
142
- }
143
- }
8
+ // Re-export types for consumers
9
+ export type { PulumiAction, PulumiResult };
144
10
 
145
11
  // Export a singleton instance
146
- export const pulumiService = new PulumiService();
12
+ export const pulumiService: ApplianceDeploymentService = createApplianceDeploymentService();
@@ -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
+ }