feat: add scoped image sync endpoint

This commit is contained in:
2026-08-06 14:58:41 +00:00
parent 5078f6f3b1
commit 2fb618aa5c
11 changed files with 229 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sam-image-hook",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"engines": {
+30
View File
@@ -18,6 +18,35 @@ function secret(name, fileName) {
return readFileSync(path, 'utf8').trim();
}
function syncClientSecrets() {
const entries = text('IMAGE_SYNC_CLIENT_SECRET_FILES', '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length === 0) {
throw new Error('IMAGE_SYNC_CLIENT_SECRET_FILES is required');
}
const result = Object.create(null);
for (const entry of entries) {
const separator = entry.indexOf('=');
const client = entry.slice(0, separator);
const path = entry.slice(separator + 1);
if (separator < 1 || !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) || !path) {
throw new Error(`Invalid image sync client entry: ${entry}`);
}
const value = readFileSync(path, 'utf8').trim();
if (value.length < 32) {
throw new Error(`Image sync secret for ${client} must be at least 32 characters`);
}
if (result[client]) {
throw new Error(`Duplicate image sync client: ${client}`);
}
result[client] = value;
}
return result;
}
export function loadConfig() {
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
@@ -44,6 +73,7 @@ export function loadConfig() {
.filter(Boolean),
webhookSecret,
adminSecret,
syncClientSecrets: syncClientSecrets(),
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
};
}
+18
View File
@@ -77,6 +77,22 @@ export class GitService {
});
}
async deploySync({ requestKey, expectedCommit }) {
return this.enqueue(async () => {
if (this.state.syncRequests.includes(requestKey)) {
return { duplicate: true, ...this.publicStatus() };
}
const result = await this.#deploy({
branch: this.state.activeBranch,
expectedCommit,
allowUnrelated: false,
});
this.state.syncRequests = [...this.state.syncRequests.slice(-199), requestKey];
await this.#saveState();
return result;
});
}
async #deploy({ branch, expectedCommit, allowUnrelated }) {
this.#validateBranch(branch);
await this.#assertClean();
@@ -163,6 +179,7 @@ export class GitService {
activeBranch: saved.activeBranch ?? this.config.defaultBranch,
deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [],
adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [],
syncRequests: Array.isArray(saved.syncRequests) ? saved.syncRequests : [],
lastSuccess: saved.lastSuccess ?? null,
lastError: saved.lastError ?? null,
};
@@ -174,6 +191,7 @@ export class GitService {
activeBranch: this.config.defaultBranch,
deliveries: [],
adminRequests: [],
syncRequests: [],
lastSuccess: null,
lastError: null,
};
+37 -2
View File
@@ -35,8 +35,8 @@ function parseJson(body) {
}
}
export async function createApp(config = loadConfig()) {
const service = new GitService(config);
export async function createApp(config = loadConfig(), dependencies = {}) {
const service = dependencies.service ?? new GitService(config);
await service.initialize();
const server = createServer(async (request, response) => {
@@ -100,6 +100,41 @@ export async function createApp(config = loadConfig()) {
const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit });
return json(response, 200, { ok: true, ...result });
}
if (request.method === 'POST' && url.pathname === '/v1/sync') {
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
throw new DeploymentError('Content-Type must be application/json', 415);
}
const body = await readBody(request, config.maxBodyBytes);
const client = request.headers['x-image-client'];
const timestamp = request.headers['x-image-timestamp'];
const requestId = request.headers['x-image-request-id'];
const knownClient = typeof client === 'string'
&& Object.hasOwn(config.syncClientSecrets, client);
const signatureValid = verifyAdminSignature({
secret: knownClient ? config.syncClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
timestamp,
requestId,
body,
supplied: request.headers['x-image-signature'],
});
if (!knownClient || !signatureValid) {
return json(response, 401, { ok: false, reason: 'invalid sync signature' });
}
const payload = parseJson(body);
if (!payload || Array.isArray(payload) || typeof payload !== 'object'
|| Object.keys(payload).some((key) => key !== 'commit')) {
throw new DeploymentError('Sync body may only contain commit', 400);
}
if (payload.commit !== undefined
&& (typeof payload.commit !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.commit))) {
throw new DeploymentError('Invalid target commit', 400);
}
const result = await service.deploySync({
requestKey: `${client}:${requestId}`,
expectedCommit: payload.commit,
});
return json(response, 200, { ok: true, ...result });
}
return json(response, 404, { ok: false, reason: 'not found' });
} catch (error) {
await service.recordError(error).catch(() => undefined);
+16
View File
@@ -124,3 +124,19 @@ test('same-branch force pushes and payload SHA mismatches are rejected', async (
/Payload commit does not match remote branch tip/,
);
});
test('signed sync callers can only fast-forward the active branch and requests are idempotent', async (t) => {
const f = await fixture();
t.after(() => rm(f.root, { recursive: true, force: true }));
await writeFile(join(f.seed, 'icons', 'sync.jpg'), 'sync');
await git(f.seed, 'add', '.');
await git(f.seed, 'commit', '-m', 'sync target');
await git(f.seed, 'push', 'origin', 'master');
const target = await git(f.seed, 'rev-parse', 'HEAD');
const result = await f.service.deploySync({ requestKey: 'core:sync-request-1', expectedCommit: target });
assert.equal(result.changed, true);
assert.equal(await git(f.deployed, 'rev-parse', 'HEAD'), target);
assert.equal((await f.service.deploySync({ requestKey: 'core:sync-request-1' })).duplicate, true);
assert.equal(f.service.publicStatus().activeBranch, 'master');
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { adminSignature } from '../src/auth.mjs';
import { createApp } from '../src/server.mjs';
test('sync endpoint authenticates a scoped caller and passes only an optional commit', async (t) => {
const calls = [];
const service = {
async initialize() {},
async deploySync(value) {
calls.push(value);
return { changed: false };
},
async recordError() {},
};
const secret = 's'.repeat(32);
const { server } = await createApp({
maxBodyBytes: 4096,
syncClientSecrets: { core: secret },
}, { service });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const address = server.address();
const body = Buffer.from(JSON.stringify({ commit: 'a'.repeat(40) }));
const timestamp = String(Date.now());
const requestId = 'sync-request-1234';
const response = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-image-client': 'core',
'x-image-timestamp': timestamp,
'x-image-request-id': requestId,
'x-image-signature': adminSignature(secret, timestamp, requestId, body),
},
body,
});
assert.equal(response.status, 200);
assert.deepEqual(calls, [{ requestKey: `core:${requestId}`, expectedCommit: 'a'.repeat(40) }]);
});
test('sync endpoint rejects unknown callers and body fields outside the sync contract', async (t) => {
const service = {
async initialize() {},
async deploySync() { throw new Error('must not deploy'); },
async recordError() {},
};
const secret = 's'.repeat(32);
const { server } = await createApp({ maxBodyBytes: 4096, syncClientSecrets: { core: secret } }, { service });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const address = server.address();
const body = Buffer.from(JSON.stringify({ branch: 'preview' }));
const timestamp = String(Date.now());
const requestId = 'sync-request-5678';
const signedHeaders = {
'content-type': 'application/json',
'x-image-timestamp': timestamp,
'x-image-request-id': requestId,
'x-image-signature': adminSignature(secret, timestamp, requestId, body),
};
const unknown = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, {
method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'unknown' }, body,
});
assert.equal(unknown.status, 401);
const prototypeName = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, {
method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'toString' }, body,
});
assert.equal(prototypeName.status, 401);
const extraField = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, {
method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'core' }, body,
});
assert.equal(extraField.status, 400);
});