mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(push): relay notifications as encrypted web push (#2906)
This commit is contained in:
1 parent
7021a58090
commit
18c303abf6
111 files changed
+12473
-1177
No files matched your search
@@ -1,12 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createServer} from 'node:net';
|
||||
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {
|
||||
INSTANCE_CONFIG_REFRESH_CHANNEL,
|
||||
INSTANCE_CONFIG_WRITE_ATTEMPTS,
|
||||
InstanceConfigRepository,
|
||||
InstanceConfigWriteConflictError,
|
||||
type InstanceRegistrationConfig,
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
@@ -21,7 +30,13 @@ import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
@@ -29,6 +44,12 @@ const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
|
||||
const POSTGRES_CONTAINER = `fluxer-instance-config-races-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
|
||||
instanceConfigSelects = 0;
|
||||
@@ -512,3 +533,466 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function describeConcurrentInstanceConfigWrites(prepareBase: () => Promise<CassandraQueryExecutorForTesting>): void {
|
||||
const pods: Array<InstanceConfigRepository> = [];
|
||||
let executor: InstanceConfigWriteRaceExecutor;
|
||||
|
||||
beforeEach(async () => {
|
||||
executor = new InstanceConfigWriteRaceExecutor(await prepareBase());
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(pods.map((pod) => pod.shutdown()));
|
||||
pods.length = 0;
|
||||
});
|
||||
|
||||
function createPod(): InstanceConfigRepository {
|
||||
const pod = new InstanceConfigRepository(new MockKVProvider());
|
||||
pods.push(pod);
|
||||
return pod;
|
||||
}
|
||||
|
||||
async function readStoredRegistrationConfig(): Promise<unknown> {
|
||||
const raw = await executor.readDirectly(REGISTRATION_CONFIG_KEY);
|
||||
return raw === null ? null : JSON.parse(raw);
|
||||
}
|
||||
|
||||
it('applies two concurrent patches on top of each other instead of dropping one', async () => {
|
||||
const first = createPod();
|
||||
const second = createPod();
|
||||
await first.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
|
||||
await second.getRegistrationConfig();
|
||||
executor.watch(REGISTRATION_CONFIG_KEY);
|
||||
executor.pauseWritesUntil(2);
|
||||
|
||||
await Promise.all([
|
||||
first.setRegistrationConfig({mode: 'closed'}),
|
||||
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
|
||||
]);
|
||||
|
||||
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
|
||||
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
|
||||
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
|
||||
});
|
||||
|
||||
it('lets one of two concurrent first writes create the config and applies the other on top', async () => {
|
||||
const first = createPod();
|
||||
const second = createPod();
|
||||
await first.getRegistrationConfig();
|
||||
await second.getRegistrationConfig();
|
||||
executor.watch(REGISTRATION_CONFIG_KEY);
|
||||
executor.pauseWritesUntil(2);
|
||||
|
||||
await Promise.all([
|
||||
first.setRegistrationConfig({mode: 'closed'}),
|
||||
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
|
||||
]);
|
||||
|
||||
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
|
||||
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
|
||||
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
|
||||
});
|
||||
|
||||
it('re-reads the database, not its stale cache, when a concurrent write lands between its read and its write', async () => {
|
||||
const stale = createPod();
|
||||
const other = createPod();
|
||||
await stale.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
|
||||
await stale.getRegistrationConfig();
|
||||
await other.setRegistrationConfig({mode: 'approval'});
|
||||
expect(await stale.getRegistrationConfig()).toEqual({mode: 'open', admin_registration_urls_enabled: true});
|
||||
executor.watch(REGISTRATION_CONFIG_KEY);
|
||||
let competed = false;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
if (competed) return;
|
||||
competed = true;
|
||||
await executor.writeDirectly(
|
||||
REGISTRATION_CONFIG_KEY,
|
||||
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: false}),
|
||||
);
|
||||
});
|
||||
|
||||
await stale.setRegistrationConfig({mode: 'closed'});
|
||||
|
||||
expect(executor.events).toEqual(['read', 'write rejected', 'read', 'write']);
|
||||
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
|
||||
});
|
||||
|
||||
it('fails loudly and writes nothing once every attempt has lost the race', async () => {
|
||||
const pod = createPod();
|
||||
await pod.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
|
||||
executor.watch(REGISTRATION_CONFIG_KEY);
|
||||
let competingWrites = 0;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
competingWrites++;
|
||||
await executor.writeDirectly(
|
||||
REGISTRATION_CONFIG_KEY,
|
||||
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: competingWrites % 2 === 0}),
|
||||
);
|
||||
});
|
||||
|
||||
const write = pod.setRegistrationConfig({mode: 'closed'});
|
||||
|
||||
await expect(write).rejects.toBeInstanceOf(InstanceConfigWriteConflictError);
|
||||
await expect(write).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: 'CONFLICT',
|
||||
message: expect.stringContaining(REGISTRATION_CONFIG_KEY),
|
||||
});
|
||||
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(INSTANCE_CONFIG_WRITE_ATTEMPTS);
|
||||
expect(executor.events).not.toContain('write');
|
||||
expect(await readStoredRegistrationConfig()).toEqual({
|
||||
mode: 'approval',
|
||||
admin_registration_urls_enabled: INSTANCE_CONFIG_WRITE_ATTEMPTS % 2 === 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a pending registration another pod added while this pod held a stale list', async () => {
|
||||
const first = createPod();
|
||||
const second = createPod();
|
||||
await first.getPendingRegistrations();
|
||||
await second.getPendingRegistrations();
|
||||
|
||||
await first.addPendingRegistration(pendingRegistration('1400000000000000011'));
|
||||
await second.addPendingRegistration(pendingRegistration('1400000000000000012'));
|
||||
|
||||
const listed = await createPod().getPendingRegistrations();
|
||||
expect(listed.map((entry) => entry.user_id)).toEqual(['1400000000000000011', '1400000000000000012']);
|
||||
});
|
||||
|
||||
it('keeps a pending registration another pod stored and removes it once decided', async () => {
|
||||
const pod = createPod();
|
||||
await executor.writeDirectly(
|
||||
REGISTRATION_PENDING_APPROVALS_KEY,
|
||||
JSON.stringify([pendingRegistration('1400000000000000021')]),
|
||||
);
|
||||
await pod.addPendingRegistration(pendingRegistration('1400000000000000022'));
|
||||
|
||||
expect((await createPod().getPendingRegistrations()).map((entry) => entry.user_id)).toEqual([
|
||||
'1400000000000000021',
|
||||
'1400000000000000022',
|
||||
]);
|
||||
|
||||
await pod.removePendingRegistration('1400000000000000021');
|
||||
await pod.removePendingRegistration('1400000000000000022');
|
||||
|
||||
expect(await createPod().getPendingRegistrations()).toEqual([]);
|
||||
expect(await executor.readDirectly(REGISTRATION_PENDING_APPROVALS_KEY)).toBe('[]');
|
||||
});
|
||||
|
||||
it('keeps a registration URL another pod created while this pod held a stale list', async () => {
|
||||
const first = createPod();
|
||||
const second = createPod();
|
||||
await first.getRegistrationUrls();
|
||||
await second.getRegistrationUrls();
|
||||
|
||||
const created = [
|
||||
await first.createRegistrationUrl(registrationUrlParams(null)),
|
||||
await second.createRegistrationUrl(registrationUrlParams(null)),
|
||||
];
|
||||
|
||||
const listed = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed.map((url) => url.id).toSorted()).toEqual(created.map((entry) => entry.registrationUrl.id).toSorted());
|
||||
});
|
||||
|
||||
it('refuses a registration URL another pod revoked while this pod held a stale list', async () => {
|
||||
const admin = createPod();
|
||||
const signup = createPod();
|
||||
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(null));
|
||||
expect(await signup.resolveRegistrationUrlCode(code)).not.toBeNull();
|
||||
|
||||
await admin.revokeRegistrationUrl(registrationUrl.id);
|
||||
|
||||
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toBeNull();
|
||||
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000501')).resolves.toBeNull();
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed?.use_count).toBe(0);
|
||||
});
|
||||
|
||||
it('admits a registration URL another pod created while this pod held a stale list', async () => {
|
||||
const admin = createPod();
|
||||
const signup = createPod();
|
||||
await signup.getRegistrationUrls();
|
||||
|
||||
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(1));
|
||||
|
||||
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toMatchObject({
|
||||
id: registrationUrl.id,
|
||||
approval_required: false,
|
||||
});
|
||||
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000601')).resolves.not.toBeNull();
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed?.use_count).toBe(1);
|
||||
});
|
||||
|
||||
it('never seats more signups than max_uses when pods claim the same registration URL at once', async () => {
|
||||
const pods = [createPod(), createPod(), createPod()];
|
||||
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(2));
|
||||
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
|
||||
if (registrationUrl === null) throw new Error('registration URL did not resolve');
|
||||
|
||||
const claims = await Promise.all(
|
||||
Array.from({length: 6}, (_, index) =>
|
||||
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000001${index}0`),
|
||||
),
|
||||
);
|
||||
|
||||
expect(claims.filter((claim) => claim !== null)).toHaveLength(2);
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed?.use_count).toBe(2);
|
||||
await expect(createPod().resolveRegistrationUrlCode(code)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a claim whose retry finds the registration URL exhausted, rather than reporting the lost attempt', async () => {
|
||||
const pod = createPod();
|
||||
const {code, registrationUrl} = await pod.createRegistrationUrl(registrationUrlParams(1));
|
||||
expect(await pod.resolveRegistrationUrlCode(code)).not.toBeNull();
|
||||
executor.watch(REGISTRATION_URLS_KEY);
|
||||
let competed = false;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
if (competed) return;
|
||||
competed = true;
|
||||
const stored = JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null') as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
await executor.writeDirectly(
|
||||
REGISTRATION_URLS_KEY,
|
||||
JSON.stringify(
|
||||
stored.map((entry) => ({
|
||||
...entry,
|
||||
use_count: 1,
|
||||
last_used_at: '2026-09-20T00:00:00.000Z',
|
||||
last_used_by_user_id: '1400000000000000901',
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000902')).resolves.toBeNull();
|
||||
|
||||
expect(executor.events).toEqual(['read', 'write rejected', 'read']);
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000901'});
|
||||
});
|
||||
|
||||
it('counts concurrent uses of an uncapped registration URL without ever refusing one', async () => {
|
||||
const pods = [createPod(), createPod()];
|
||||
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(null));
|
||||
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
|
||||
if (registrationUrl === null) throw new Error('registration URL did not resolve');
|
||||
|
||||
const claims = await Promise.all(
|
||||
Array.from({length: 5}, (_, index) =>
|
||||
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000002${index}0`),
|
||||
),
|
||||
);
|
||||
|
||||
expect(claims.every((claim) => claim !== null)).toBe(true);
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed?.use_count).toBe(5);
|
||||
});
|
||||
|
||||
it('frees a released seat for the next signup', async () => {
|
||||
const pod = createPod();
|
||||
const {code} = await pod.createRegistrationUrl(registrationUrlParams(1));
|
||||
const registrationUrl = await pod.resolveRegistrationUrlCode(code);
|
||||
if (registrationUrl === null) throw new Error('registration URL did not resolve');
|
||||
|
||||
const failedSignup = await pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000301');
|
||||
if (failedSignup === null) throw new Error('the first claim was refused');
|
||||
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000302')).resolves.toBeNull();
|
||||
await pod.releaseRegistrationUrlUse(failedSignup);
|
||||
|
||||
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000303')).resolves.not.toBeNull();
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000303'});
|
||||
});
|
||||
|
||||
it('enforces max_uses against the use count already stored in the blob', async () => {
|
||||
const pod = createPod();
|
||||
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e91';
|
||||
await executor.writeDirectly(
|
||||
REGISTRATION_URLS_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id,
|
||||
label: 'Issued earlier',
|
||||
code_hash: createHash('sha256').update(id).digest('hex'),
|
||||
created_by_user_id: '1400000000000000001',
|
||||
created_at: '2026-09-01T00:00:00.000Z',
|
||||
expires_at: null,
|
||||
max_uses: 3,
|
||||
use_count: 2,
|
||||
revoked_at: null,
|
||||
approval_required: true,
|
||||
last_used_at: '2026-09-02T00:00:00.000Z',
|
||||
last_used_by_user_id: '1400000000000000002',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(await pod.getRegistrationUrlsForAdmin()).toEqual([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
use_count: 2,
|
||||
max_uses: 3,
|
||||
approval_required: true,
|
||||
last_used_at: '2026-09-02T00:00:00.000Z',
|
||||
last_used_by_user_id: '1400000000000000002',
|
||||
}),
|
||||
]);
|
||||
const registrationUrl = await pod.resolveRegistrationUrlCode(id);
|
||||
if (registrationUrl === null) throw new Error('stored registration URL did not resolve');
|
||||
expect(registrationUrl).toMatchObject({id, approval_required: true});
|
||||
|
||||
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000401')).resolves.not.toBeNull();
|
||||
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000402')).resolves.toBeNull();
|
||||
|
||||
const [listed] = await createPod().getRegistrationUrlsForAdmin();
|
||||
expect(listed).toMatchObject({use_count: 3, max_uses: 3, last_used_by_user_id: '1400000000000000401'});
|
||||
await expect(pod.resolveRegistrationUrlCode(id)).resolves.toBeNull();
|
||||
expect(JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null')[0]).toMatchObject({
|
||||
use_count: 3,
|
||||
max_uses: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an SSO field another pod changed while this pod held a stale snapshot', async () => {
|
||||
const first = createPod();
|
||||
const second = createPod();
|
||||
await first.getSsoConfig();
|
||||
await second.getSsoConfig();
|
||||
|
||||
await first.setSsoConfig({displayName: 'Set by the first pod'});
|
||||
await second.setSsoConfig({clientId: 'set-by-the-second-pod'});
|
||||
|
||||
expect(await createPod().getSsoConfig()).toMatchObject({
|
||||
displayName: 'Set by the first pod',
|
||||
clientId: 'set-by-the-second-pod',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an SSO row alone when another pod wrote it between this pod reading and writing it', async () => {
|
||||
const pod = createPod();
|
||||
await pod.getSsoConfig();
|
||||
executor.watch('sso_enforced');
|
||||
let competed = false;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
if (competed) return;
|
||||
competed = true;
|
||||
await executor.writeDirectly('sso_enforced', 'true');
|
||||
});
|
||||
|
||||
await pod.setSsoConfig({displayName: 'Only the display name'});
|
||||
|
||||
expect(await executor.readDirectly('sso_enforced')).toBe('true');
|
||||
expect(await executor.readDirectly('sso_display_name')).toBe('Only the display name');
|
||||
});
|
||||
}
|
||||
|
||||
function pendingRegistration(userId: string) {
|
||||
return {
|
||||
user_id: userId,
|
||||
username: `pending_${userId.slice(-3)}`,
|
||||
discriminator: 1,
|
||||
global_name: null,
|
||||
email: `${userId}@example.com`,
|
||||
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
|
||||
registration_url_id: null,
|
||||
client_ip: '127.0.0.1',
|
||||
};
|
||||
}
|
||||
|
||||
function registrationUrlParams(maxUses: number | null) {
|
||||
return {
|
||||
label: maxUses === null ? 'Uncapped' : `Capped at ${maxUses}`,
|
||||
createdByUserId: '1400000000000000001',
|
||||
expiresAt: null,
|
||||
maxUses,
|
||||
approvalRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InstanceConfigRepository concurrent writes', () => {
|
||||
describe('in memory', () => {
|
||||
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
|
||||
});
|
||||
|
||||
describe.skipIf(!dockerAvailable)('on postgres', () => {
|
||||
let client: IPostgresClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
POSTGRES_CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', POSTGRES_CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({
|
||||
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
|
||||
maxConnections: 4,
|
||||
kvTable: POSTGRES_KV_TABLE,
|
||||
});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
client = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(client);
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', POSTGRES_CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
describeConcurrentInstanceConfigWrites(async () => {
|
||||
await client.query(`DELETE FROM ${POSTGRES_KV_TABLE}`);
|
||||
return new PostgresKvQueryExecutor(client);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
|
||||
import {fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
|
||||
import {
|
||||
getDefaultDateOfBirthCollection,
|
||||
@@ -17,6 +18,9 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
|
||||
import {InstanceConfiguration} from '@app/api/Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
|
||||
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import {
|
||||
InstanceConfigResponse,
|
||||
@@ -28,6 +32,10 @@ import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {
|
||||
type ScreenShareDeliveryConfig,
|
||||
ScreenShareDeliveryConfigSchema,
|
||||
@@ -59,6 +67,7 @@ import {z} from 'zod';
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -72,6 +81,22 @@ const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
|
||||
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
|
||||
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
|
||||
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
|
||||
export const INSTANCE_CONFIG_WRITE_ATTEMPTS = 5;
|
||||
|
||||
export class InstanceConfigWriteConflictError extends ConflictError {
|
||||
constructor(key: string) {
|
||||
super({
|
||||
code: APIErrorCodes.CONFLICT,
|
||||
message: `Instance config "${key}" changed concurrently on all ${INSTANCE_CONFIG_WRITE_ATTEMPTS} write attempts. Nothing was written. Retry the change.`,
|
||||
});
|
||||
this.name = 'InstanceConfigWriteConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredValueUpdate<T> {
|
||||
value: string | null;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export type InstanceRegistrationConfig = InstanceRegistration;
|
||||
|
||||
@@ -277,6 +302,11 @@ export interface InstanceRegistrationUrl extends RegistrationUrlResponse {
|
||||
type InstanceRegistrationUrlPublic = RegistrationUrlResponse;
|
||||
type InstancePendingRegistration = PendingRegistrationResponse;
|
||||
|
||||
export interface RegistrationUrlClaim {
|
||||
registration_url_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
const DEFAULT_REGISTRATION_CONFIG: InstanceRegistrationConfig = {
|
||||
mode: 'open',
|
||||
admin_registration_urls_enabled: true,
|
||||
@@ -345,6 +375,7 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'screen share delivery'
|
||||
| 'push service delivery'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -487,6 +518,10 @@ function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDe
|
||||
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
|
||||
}
|
||||
|
||||
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -910,6 +945,75 @@ function parseStoredSsoAllowedEmailDomains(raw: string | undefined, log = false)
|
||||
return Array.from(domains).slice(0, MAX_SSO_ALLOWED_DOMAINS);
|
||||
}
|
||||
|
||||
function readStoredSsoConfig(
|
||||
configs: ReadonlyMap<string, string>,
|
||||
options?: {includeSecret?: boolean},
|
||||
): InstanceSsoConfig {
|
||||
const flags = readStoredSsoFlags(configs);
|
||||
const read = (key: string): string | null => {
|
||||
const v = configs.get(key);
|
||||
if (!v) return null;
|
||||
const trimmed = v.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
};
|
||||
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
|
||||
const clientSecret = read('sso_client_secret');
|
||||
return {
|
||||
...flags,
|
||||
displayName: read('sso_display_name'),
|
||||
issuer: read('sso_issuer'),
|
||||
authorizationUrl: read('sso_authorization_url'),
|
||||
tokenUrl: read('sso_token_url'),
|
||||
userInfoUrl: read('sso_userinfo_url'),
|
||||
jwksUrl: read('sso_jwks_url'),
|
||||
clientId: read('sso_client_id'),
|
||||
clientSecret: options?.includeSecret ? clientSecret : undefined,
|
||||
clientSecretSet: Boolean(clientSecret),
|
||||
scope: read('sso_scope'),
|
||||
allowedEmailDomains: allowedDomains,
|
||||
redirectUri: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface SsoRowWrite {
|
||||
key: string;
|
||||
value: string | undefined;
|
||||
unset: string;
|
||||
}
|
||||
|
||||
function ssoRow<T>(key: string, value: T | undefined, current: T, format: (value: T) => string): SsoRowWrite {
|
||||
return {key, value: value === undefined ? undefined : format(value), unset: format(current)};
|
||||
}
|
||||
|
||||
function nextSsoRowValue(row: SsoRowWrite, raw: string | null): string | null {
|
||||
const value = row.value ?? raw ?? row.unset;
|
||||
return value === raw ? null : value;
|
||||
}
|
||||
|
||||
function formatSsoBoolean(value: boolean): string {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
function formatSsoString(value: string | null): string {
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function formatSsoDomains(value: Array<string>): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizeSsoAllowedEmailDomainsForWrite(domains: Array<string>, enabled: boolean): Array<string> {
|
||||
try {
|
||||
return normalizeSsoAllowedEmailDomains(domains);
|
||||
} catch (error) {
|
||||
if (enabled) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigRepository {
|
||||
private readonly kvClient: IKVProvider | null;
|
||||
private configCache: InstanceConfigCache;
|
||||
@@ -993,6 +1097,57 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private async updateStoredConfig<T>(key: string, next: (raw: string | null) => T): Promise<T> {
|
||||
const cache = this.configCache;
|
||||
const {result} = await this.compareAndSetStoredValue(cache, key, (raw) => {
|
||||
const config = next(raw);
|
||||
return {value: JSON.stringify(config), result: config};
|
||||
});
|
||||
await this.publishRefresh(cache.sourceId);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async compareAndSetStoredValue<T>(
|
||||
cache: InstanceConfigCache,
|
||||
key: string,
|
||||
next: (raw: string | null) => StoredValueUpdate<T>,
|
||||
): Promise<{result: T; written: boolean}> {
|
||||
await cache.getSnapshot();
|
||||
for (let attempt = 0; attempt < INSTANCE_CONFIG_WRITE_ATTEMPTS; attempt++) {
|
||||
cache.assertActive();
|
||||
const current = await this.fetchConfigForWrite(key);
|
||||
cache.assertActive();
|
||||
const {value, result} = next(current);
|
||||
if (value === null) return {result, written: false};
|
||||
if (await executeConditional(this.compareAndSetConfig(key, current, value))) {
|
||||
cache.update(key, value);
|
||||
return {result, written: true};
|
||||
}
|
||||
}
|
||||
Logger.error(
|
||||
{key, attempts: INSTANCE_CONFIG_WRITE_ATTEMPTS},
|
||||
'Instance config write lost to a concurrent write on every attempt',
|
||||
);
|
||||
throw new InstanceConfigWriteConflictError(key);
|
||||
}
|
||||
|
||||
private compareAndSetConfig(key: string, current: string | null, value: string): PreparedQuery {
|
||||
const updatedAt = new Date();
|
||||
if (current === null) {
|
||||
return InstanceConfiguration.insertIfNotExists({key, value, updated_at: updatedAt});
|
||||
}
|
||||
return InstanceConfiguration.conditionalPatchByPk(
|
||||
{key},
|
||||
{value: Db.set(value), updated_at: Db.set(updatedAt)},
|
||||
{value: current},
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchConfigForWrite(key: string): Promise<string | null> {
|
||||
const [row] = await fetchMany<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key}, {consistency: 'serial'});
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
private async fetchConfigFromDatabase(key: string): Promise<string | null> {
|
||||
const row = await fetchOne<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key});
|
||||
return row?.value ?? null;
|
||||
@@ -1015,6 +1170,7 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1082,8 +1238,12 @@ export class InstanceConfigRepository {
|
||||
return parseStoredGatewayRolloutConfig(raw);
|
||||
}
|
||||
|
||||
async setGatewayRolloutConfig(config: GatewayRolloutConfig): Promise<void> {
|
||||
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(decodeGatewayRolloutConfig(config)));
|
||||
updateGatewayRolloutConfig(
|
||||
update: (current: GatewayRolloutConfig) => GatewayRolloutConfig,
|
||||
): Promise<GatewayRolloutConfig> {
|
||||
return this.updateStoredConfig(GATEWAY_ROLLOUT_CONFIG_KEY, (raw) =>
|
||||
decodeGatewayRolloutConfig(update(parseStoredGatewayRolloutConfig(raw))),
|
||||
);
|
||||
}
|
||||
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
@@ -1092,8 +1252,19 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(VoiceNoiseSuppressionConfigSchema, config, 'voice noise suppression');
|
||||
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
|
||||
await this.updateVoiceNoiseSuppressionConfig(() => config);
|
||||
}
|
||||
|
||||
updateVoiceNoiseSuppressionConfig(
|
||||
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
|
||||
): Promise<VoiceNoiseSuppressionConfig> {
|
||||
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
|
||||
'voice noise suppression',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
|
||||
@@ -1102,8 +1273,36 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(ScreenShareDeliveryConfigSchema, config, 'screen share delivery');
|
||||
await this.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
|
||||
await this.updateScreenShareDeliveryConfig(() => config);
|
||||
}
|
||||
|
||||
updateScreenShareDeliveryConfig(
|
||||
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
|
||||
): Promise<ScreenShareDeliveryConfig> {
|
||||
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ScreenShareDeliveryConfigSchema,
|
||||
update(parseStoredScreenShareDeliveryConfig(raw)),
|
||||
'screen share delivery',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredPushServiceDeliveryConfig(raw);
|
||||
}
|
||||
|
||||
updatePushServiceDeliveryConfig(
|
||||
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
|
||||
): Promise<PushServiceDeliveryConfig> {
|
||||
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
PushServiceDeliveryConfigSchema,
|
||||
update(parseStoredPushServiceDeliveryConfig(raw)),
|
||||
'push service delivery',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
@@ -1112,8 +1311,19 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery');
|
||||
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
|
||||
await this.updateExperimentDeliveryConfig(() => config);
|
||||
}
|
||||
|
||||
updateExperimentDeliveryConfig(
|
||||
update: (current: ExperimentDeliveryConfig) => ExperimentDeliveryConfig,
|
||||
): Promise<ExperimentDeliveryConfig> {
|
||||
return this.updateStoredConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ExperimentDeliveryConfigSchema,
|
||||
update(parseStoredExperimentDeliveryConfig(raw)),
|
||||
'experiment delivery',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async readLimitConfigInputs(): Promise<LimitConfigInputs> {
|
||||
@@ -1149,26 +1359,27 @@ export class InstanceConfigRepository {
|
||||
legal?: Partial<InstanceAppPublicConfig['legal']>;
|
||||
registration?: Partial<InstanceAppPublicConfig['registration']>;
|
||||
}): Promise<InstanceAppPublicConfig> {
|
||||
const current = await this.getAppPublicConfig();
|
||||
const next = decodeAppPublicConfig({
|
||||
branding: {
|
||||
...current.branding,
|
||||
...(config.branding ?? {}),
|
||||
},
|
||||
setup: {
|
||||
...current.setup,
|
||||
...(config.setup ?? {}),
|
||||
},
|
||||
legal: {
|
||||
...current.legal,
|
||||
...(config.legal ?? {}),
|
||||
},
|
||||
registration: {
|
||||
...current.registration,
|
||||
...(config.registration ?? {}),
|
||||
},
|
||||
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredAppPublicConfig(raw);
|
||||
return decodeAppPublicConfig({
|
||||
branding: {
|
||||
...current.branding,
|
||||
...(config.branding ?? {}),
|
||||
},
|
||||
setup: {
|
||||
...current.setup,
|
||||
...(config.setup ?? {}),
|
||||
},
|
||||
legal: {
|
||||
...current.legal,
|
||||
...(config.legal ?? {}),
|
||||
},
|
||||
registration: {
|
||||
...current.registration,
|
||||
...(config.registration ?? {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
|
||||
return next;
|
||||
}
|
||||
@@ -1188,10 +1399,21 @@ export class InstanceConfigRepository {
|
||||
return parseStoredInstancePolicyConfig(raw);
|
||||
}
|
||||
|
||||
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
|
||||
const current = await this.readStoredInstancePolicyConfig();
|
||||
const next = decodeInstancePolicyConfig({...current, ...config});
|
||||
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
|
||||
setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
|
||||
return this.updateInstancePolicyConfig(() => config);
|
||||
}
|
||||
|
||||
async updateInstancePolicyConfig(
|
||||
plan: (current: InstancePolicyConfig) => Partial<InstancePolicyConfig>,
|
||||
): Promise<InstancePolicyConfig> {
|
||||
const cache = this.configCache;
|
||||
const {result: next, written} = await this.compareAndSetStoredValue(cache, INSTANCE_POLICY_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredInstancePolicyConfig(raw);
|
||||
const patch = plan(current);
|
||||
const config = decodeInstancePolicyConfig({...current, ...patch});
|
||||
return {value: Object.keys(patch).length === 0 ? null : JSON.stringify(config), result: config};
|
||||
});
|
||||
if (written) await this.publishRefresh(cache.sourceId);
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
|
||||
return next;
|
||||
}
|
||||
@@ -1201,37 +1423,37 @@ export class InstanceConfigRepository {
|
||||
return parseStoredInstanceIntegrationsConfig(raw);
|
||||
}
|
||||
|
||||
async setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
|
||||
const current = await this.getInstanceIntegrationsConfig();
|
||||
const next = decodeInstanceIntegrationsConfig({
|
||||
gif: {
|
||||
...current.gif,
|
||||
...(config.gif ?? {}),
|
||||
},
|
||||
youtube: {
|
||||
...current.youtube,
|
||||
...(config.youtube ?? {}),
|
||||
},
|
||||
captcha: {
|
||||
...current.captcha,
|
||||
...(config.captcha ?? {}),
|
||||
},
|
||||
email: {
|
||||
...current.email,
|
||||
...(config.email ?? {}),
|
||||
smtp: {
|
||||
...current.email.smtp,
|
||||
...(config.email?.smtp ?? {}),
|
||||
setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
|
||||
return this.updateStoredConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredInstanceIntegrationsConfig(raw);
|
||||
return decodeInstanceIntegrationsConfig({
|
||||
gif: {
|
||||
...current.gif,
|
||||
...(config.gif ?? {}),
|
||||
},
|
||||
},
|
||||
bluesky: {
|
||||
...current.bluesky,
|
||||
...(config.bluesky ?? {}),
|
||||
keys: config.bluesky?.keys ?? current.bluesky.keys,
|
||||
},
|
||||
youtube: {
|
||||
...current.youtube,
|
||||
...(config.youtube ?? {}),
|
||||
},
|
||||
captcha: {
|
||||
...current.captcha,
|
||||
...(config.captcha ?? {}),
|
||||
},
|
||||
email: {
|
||||
...current.email,
|
||||
...(config.email ?? {}),
|
||||
smtp: {
|
||||
...current.email.smtp,
|
||||
...(config.email?.smtp ?? {}),
|
||||
},
|
||||
},
|
||||
bluesky: {
|
||||
...current.bluesky,
|
||||
...(config.bluesky ?? {}),
|
||||
keys: config.bluesky?.keys ?? current.bluesky.keys,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.setConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async getInstanceMediaConfig(): Promise<InstanceMediaConfig> {
|
||||
@@ -1239,16 +1461,16 @@ export class InstanceConfigRepository {
|
||||
return parseStoredInstanceMediaConfig(raw);
|
||||
}
|
||||
|
||||
async setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
|
||||
const current = await this.getInstanceMediaConfig();
|
||||
const next = decodeInstanceMediaConfig({
|
||||
attachment_decay: {
|
||||
...current.attachment_decay,
|
||||
...(config.attachment_decay ?? {}),
|
||||
},
|
||||
setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
|
||||
return this.updateStoredConfig(INSTANCE_MEDIA_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredInstanceMediaConfig(raw);
|
||||
return decodeInstanceMediaConfig({
|
||||
attachment_decay: {
|
||||
...current.attachment_decay,
|
||||
...(config.attachment_decay ?? {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.setConfig(INSTANCE_MEDIA_CONFIG_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
|
||||
@@ -1485,15 +1707,15 @@ export class InstanceConfigRepository {
|
||||
return parseStoredRegistrationConfig(raw);
|
||||
}
|
||||
|
||||
async setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
|
||||
const current = await this.getRegistrationConfig();
|
||||
const next = decodeRegistrationConfig({
|
||||
mode: config.mode ?? current.mode,
|
||||
admin_registration_urls_enabled:
|
||||
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
|
||||
setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
|
||||
return this.updateStoredConfig(REGISTRATION_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredRegistrationConfig(raw);
|
||||
return decodeRegistrationConfig({
|
||||
mode: config.mode ?? current.mode,
|
||||
admin_registration_urls_enabled:
|
||||
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
|
||||
});
|
||||
});
|
||||
await this.setConfig(REGISTRATION_CONFIG_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async getRegistrationPublicConfig(): Promise<InstanceRegistrationConfig> {
|
||||
@@ -1534,16 +1756,20 @@ export class InstanceConfigRepository {
|
||||
last_used_at: null,
|
||||
last_used_by_user_id: null,
|
||||
};
|
||||
const registrationUrls = await this.getRegistrationUrls();
|
||||
await this.setRegistrationUrls([registrationUrl, ...registrationUrls]);
|
||||
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
|
||||
validateStoredCollection(
|
||||
StoredRegistrationUrlSchema,
|
||||
[registrationUrl, ...parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs')],
|
||||
'registration URLs',
|
||||
),
|
||||
);
|
||||
return {registrationUrl: redactRegistrationUrl(registrationUrl), code};
|
||||
}
|
||||
|
||||
async revokeRegistrationUrl(id: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const registrationUrls = await this.getRegistrationUrls();
|
||||
await this.setRegistrationUrls(
|
||||
registrationUrls.map((registrationUrl) =>
|
||||
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
|
||||
parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs').map((registrationUrl) =>
|
||||
registrationUrl.id === id && !registrationUrl.revoked_at
|
||||
? {...registrationUrl, revoked_at: now}
|
||||
: registrationUrl,
|
||||
@@ -1556,9 +1782,8 @@ export class InstanceConfigRepository {
|
||||
if (!normalizedCode) return null;
|
||||
const hash = this.hashRegistrationUrlCode(normalizedCode);
|
||||
const now = new Date();
|
||||
const registrationUrls = await this.getRegistrationUrls();
|
||||
return (
|
||||
registrationUrls.find(
|
||||
(await this.fetchRegistrationUrlDefinitions()).find(
|
||||
(registrationUrl) =>
|
||||
(registrationUrl.id === normalizedCode || registrationUrl.code_hash === hash) &&
|
||||
isRegistrationUrlUsable(registrationUrl, now),
|
||||
@@ -1566,21 +1791,68 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async recordRegistrationUrlUse(id: string, userId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const registrationUrls = await this.getRegistrationUrls();
|
||||
await this.setRegistrationUrls(
|
||||
registrationUrls.map((registrationUrl) =>
|
||||
registrationUrl.id === id
|
||||
? {
|
||||
...registrationUrl,
|
||||
use_count: registrationUrl.use_count + 1,
|
||||
last_used_at: now,
|
||||
last_used_by_user_id: userId,
|
||||
}
|
||||
: registrationUrl,
|
||||
),
|
||||
);
|
||||
async claimRegistrationUrlUse(registrationUrlId: string, userId: string): Promise<RegistrationUrlClaim | null> {
|
||||
const cache = this.configCache;
|
||||
let claimed: {result: RegistrationUrlClaim | null; written: boolean};
|
||||
try {
|
||||
claimed = await this.compareAndSetStoredValue<RegistrationUrlClaim | null>(
|
||||
cache,
|
||||
REGISTRATION_URLS_KEY,
|
||||
(raw) => {
|
||||
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
|
||||
const now = new Date();
|
||||
const claimable = registrationUrls.find(
|
||||
(registrationUrl) =>
|
||||
registrationUrl.id === registrationUrlId && isRegistrationUrlUsable(registrationUrl, now),
|
||||
);
|
||||
if (!claimable) return {value: null, result: null};
|
||||
const next = registrationUrls.map((registrationUrl) =>
|
||||
registrationUrl === claimable
|
||||
? {
|
||||
...registrationUrl,
|
||||
use_count: registrationUrl.use_count + 1,
|
||||
last_used_at: now.toISOString(),
|
||||
last_used_by_user_id: userId,
|
||||
}
|
||||
: registrationUrl,
|
||||
);
|
||||
return {
|
||||
value: JSON.stringify(validateStoredCollection(StoredRegistrationUrlSchema, next, 'registration URLs')),
|
||||
result: {registration_url_id: registrationUrlId, user_id: userId},
|
||||
};
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceConfigWriteConflictError) throw new ServiceUnavailableError();
|
||||
throw error;
|
||||
}
|
||||
if (claimed.written) await this.publishRefresh(cache.sourceId);
|
||||
return claimed.result;
|
||||
}
|
||||
|
||||
async releaseRegistrationUrlUse(claim: RegistrationUrlClaim): Promise<void> {
|
||||
const cache = this.configCache;
|
||||
try {
|
||||
const {written} = await this.compareAndSetStoredValue<null>(cache, REGISTRATION_URLS_KEY, (raw) => {
|
||||
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
|
||||
const released = registrationUrls.find(
|
||||
(registrationUrl) => registrationUrl.id === claim.registration_url_id && registrationUrl.use_count > 0,
|
||||
);
|
||||
if (!released) return {value: null, result: null};
|
||||
const next = registrationUrls.map((registrationUrl) =>
|
||||
registrationUrl === released
|
||||
? {...registrationUrl, use_count: registrationUrl.use_count - 1}
|
||||
: registrationUrl,
|
||||
);
|
||||
return {value: JSON.stringify(next), result: null};
|
||||
});
|
||||
if (written) await this.publishRefresh(cache.sourceId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{registrationUrlId: claim.registration_url_id, userId: claim.user_id, error},
|
||||
'Releasing a registration URL use failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingRegistrations(): Promise<Array<InstancePendingRegistration>> {
|
||||
@@ -1591,104 +1863,82 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async addPendingRegistration(pendingRegistration: InstancePendingRegistration): Promise<void> {
|
||||
const pendingRegistrations = await this.getPendingRegistrations();
|
||||
const next = [
|
||||
pendingRegistration,
|
||||
...pendingRegistrations.filter((entry) => entry.user_id !== pendingRegistration.user_id),
|
||||
];
|
||||
await this.setPendingRegistrations(next);
|
||||
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
|
||||
validateStoredCollection(
|
||||
StoredPendingRegistrationSchema,
|
||||
[
|
||||
pendingRegistration,
|
||||
...parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
|
||||
(entry) => entry.user_id !== pendingRegistration.user_id,
|
||||
),
|
||||
],
|
||||
'pending registrations',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async removePendingRegistration(userId: string): Promise<void> {
|
||||
const pendingRegistrations = await this.getPendingRegistrations();
|
||||
await this.setPendingRegistrations(pendingRegistrations.filter((entry) => entry.user_id !== userId));
|
||||
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
|
||||
parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
|
||||
(entry) => entry.user_id !== userId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getSsoConfig(options?: {includeSecret?: boolean}): Promise<InstanceSsoConfig> {
|
||||
const configs = await this.getAllConfigs();
|
||||
const flags = readStoredSsoFlags(configs);
|
||||
const read = (key: string): string | null => {
|
||||
const v = configs.get(key);
|
||||
if (!v) return null;
|
||||
const trimmed = v.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
};
|
||||
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
|
||||
const clientSecret = read('sso_client_secret');
|
||||
return {
|
||||
...flags,
|
||||
displayName: read('sso_display_name'),
|
||||
issuer: read('sso_issuer'),
|
||||
authorizationUrl: read('sso_authorization_url'),
|
||||
tokenUrl: read('sso_token_url'),
|
||||
userInfoUrl: read('sso_userinfo_url'),
|
||||
jwksUrl: read('sso_jwks_url'),
|
||||
clientId: read('sso_client_id'),
|
||||
clientSecret: options?.includeSecret ? clientSecret : undefined,
|
||||
clientSecretSet: Boolean(clientSecret),
|
||||
scope: read('sso_scope'),
|
||||
allowedEmailDomains: allowedDomains,
|
||||
redirectUri: null,
|
||||
};
|
||||
return readStoredSsoConfig(await this.getAllConfigs(), options);
|
||||
}
|
||||
|
||||
async setSsoConfig(config: Partial<InstanceSsoConfig>): Promise<InstanceSsoConfig> {
|
||||
const current = await this.getSsoConfig({includeSecret: true});
|
||||
const definedConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, value]) => value !== undefined),
|
||||
) as Partial<InstanceSsoConfig>;
|
||||
const next: InstanceSsoConfig = {
|
||||
...current,
|
||||
...definedConfig,
|
||||
clientSecret: config.clientSecret !== undefined ? config.clientSecret : current.clientSecret,
|
||||
};
|
||||
if (config.enabled === true && config.enforced === undefined && !current.enabled) {
|
||||
next.enforced = true;
|
||||
}
|
||||
let allowedEmailDomains: Array<string>;
|
||||
try {
|
||||
allowedEmailDomains = normalizeSsoAllowedEmailDomains(next.allowedEmailDomains);
|
||||
} catch (error) {
|
||||
if (next.enabled) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
|
||||
allowedEmailDomains = [];
|
||||
}
|
||||
const entries: Array<[string, string]> = [
|
||||
['sso_enabled', next.enabled ? 'true' : 'false'],
|
||||
['sso_enforced', next.enforced ? 'true' : 'false'],
|
||||
['sso_display_name', next.displayName ?? ''],
|
||||
['sso_issuer', next.issuer ?? ''],
|
||||
['sso_authorization_url', next.authorizationUrl ?? ''],
|
||||
['sso_token_url', next.tokenUrl ?? ''],
|
||||
['sso_userinfo_url', next.userInfoUrl ?? ''],
|
||||
['sso_jwks_url', next.jwksUrl ?? ''],
|
||||
['sso_client_id', next.clientId ?? ''],
|
||||
['sso_scope', next.scope ?? ''],
|
||||
['sso_allowed_domains', JSON.stringify(allowedEmailDomains)],
|
||||
['sso_auto_provision', next.autoProvision ? 'true' : 'false'],
|
||||
['sso_redirect_uri', ''],
|
||||
const configs = await this.getAllConfigs();
|
||||
const current = readStoredSsoConfig(configs, {includeSecret: true});
|
||||
const enabled = config.enabled ?? current.enabled;
|
||||
const allowedEmailDomains =
|
||||
config.allowedEmailDomains === undefined
|
||||
? undefined
|
||||
: normalizeSsoAllowedEmailDomainsForWrite(config.allowedEmailDomains, enabled);
|
||||
const rows: Array<SsoRowWrite> = [
|
||||
ssoRow('sso_enabled', config.enabled, current.enabled, formatSsoBoolean),
|
||||
ssoRow('sso_enforced', config.enforced, current.enforced, formatSsoBoolean),
|
||||
ssoRow('sso_display_name', config.displayName, current.displayName, formatSsoString),
|
||||
ssoRow('sso_issuer', config.issuer, current.issuer, formatSsoString),
|
||||
ssoRow('sso_authorization_url', config.authorizationUrl, current.authorizationUrl, formatSsoString),
|
||||
ssoRow('sso_token_url', config.tokenUrl, current.tokenUrl, formatSsoString),
|
||||
ssoRow('sso_userinfo_url', config.userInfoUrl, current.userInfoUrl, formatSsoString),
|
||||
ssoRow('sso_jwks_url', config.jwksUrl, current.jwksUrl, formatSsoString),
|
||||
ssoRow('sso_client_id', config.clientId, current.clientId, formatSsoString),
|
||||
ssoRow('sso_scope', config.scope, current.scope, formatSsoString),
|
||||
ssoRow('sso_allowed_domains', allowedEmailDomains, current.allowedEmailDomains, formatSsoDomains),
|
||||
ssoRow('sso_auto_provision', config.autoProvision, current.autoProvision, formatSsoBoolean),
|
||||
ssoRow('sso_redirect_uri', undefined, null, formatSsoString),
|
||||
];
|
||||
if (config.clientSecret !== undefined) {
|
||||
entries.push(['sso_client_secret', config.clientSecret ?? '']);
|
||||
rows.push(ssoRow('sso_client_secret', config.clientSecret, current.clientSecret ?? null, formatSsoString));
|
||||
}
|
||||
await this.setConfigs(entries);
|
||||
const cache = this.configCache;
|
||||
const results = await Promise.allSettled(
|
||||
rows
|
||||
.filter((row) => row.value !== undefined || !configs.has(row.key))
|
||||
.map((row) =>
|
||||
this.compareAndSetStoredValue(cache, row.key, (raw) => ({value: nextSsoRowValue(row, raw), result: null})),
|
||||
),
|
||||
);
|
||||
const errors: Array<unknown> = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
|
||||
if (results.some((result) => result.status === 'fulfilled' && result.value.written)) {
|
||||
try {
|
||||
await this.publishRefresh(cache.sourceId);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'Failed to write or publish the SSO config');
|
||||
return this.getSsoConfig({includeSecret: true});
|
||||
}
|
||||
|
||||
private async setRegistrationUrls(registrationUrls: Array<InstanceRegistrationUrl>): Promise<void> {
|
||||
const validated = validateStoredCollection(StoredRegistrationUrlSchema, registrationUrls, 'registration URLs');
|
||||
await this.setConfig(REGISTRATION_URLS_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
private async setPendingRegistrations(pendingRegistrations: Array<InstancePendingRegistration>): Promise<void> {
|
||||
const validated = validateStoredCollection(
|
||||
StoredPendingRegistrationSchema,
|
||||
pendingRegistrations,
|
||||
'pending registrations',
|
||||
);
|
||||
await this.setConfig(REGISTRATION_PENDING_APPROVALS_KEY, JSON.stringify(validated));
|
||||
private async fetchRegistrationUrlDefinitions(): Promise<Array<InstanceRegistrationUrl>> {
|
||||
const raw = await this.fetchConfigFromDatabase(REGISTRATION_URLS_KEY);
|
||||
return parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
|
||||
}
|
||||
|
||||
private hashRegistrationUrlCode(code: string): string {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
|
||||
interface PushServiceDeliveryConfigNatsMessage {
|
||||
type: 'push_service_delivery_config';
|
||||
config: PushServiceDeliveryConfig;
|
||||
}
|
||||
|
||||
export class PushServiceDeliveryConfigPublisher {
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: PushServiceDeliveryConfig): Promise<void> {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const message: PushServiceDeliveryConfigNatsMessage = {
|
||||
type: 'push_service_delivery_config',
|
||||
config,
|
||||
};
|
||||
connection.publish(PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
|
||||
await connection.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getKvMeta} from '@app/api/database/CassandraMetaRegistry';
|
||||
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
|
||||
import {InstanceConfiguration} from '@app/api/Tables';
|
||||
|
||||
type InstanceConfigWriteEvent = 'read' | 'write' | 'write rejected';
|
||||
|
||||
interface WriteGate {
|
||||
size: number;
|
||||
paused: Array<() => void>;
|
||||
}
|
||||
|
||||
const FETCH_ROW_QUERY = InstanceConfiguration.selectCql({
|
||||
where: InstanceConfiguration.where.eq('key'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class InstanceConfigWriteRaceExecutor implements CassandraQueryExecutorForTesting {
|
||||
readonly events: Array<InstanceConfigWriteEvent> = [];
|
||||
private watchedKey: string | null = null;
|
||||
private gate: WriteGate | null = null;
|
||||
private beforeEachWrite: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(private readonly base: CassandraQueryExecutorForTesting) {}
|
||||
|
||||
watch(key: string): void {
|
||||
this.watchedKey = key;
|
||||
this.events.length = 0;
|
||||
}
|
||||
|
||||
pauseWritesUntil(size: number): void {
|
||||
this.gate = {size, paused: []};
|
||||
}
|
||||
|
||||
competeBeforeEachWrite(write: () => Promise<void>): void {
|
||||
this.beforeEachWrite = write;
|
||||
}
|
||||
|
||||
async writeDirectly(key: string, value: string): Promise<void> {
|
||||
await this.base.executeQuery(InstanceConfiguration.upsertAll({key, value, updated_at: new Date()}));
|
||||
}
|
||||
|
||||
async readDirectly(key: string): Promise<string | null> {
|
||||
const [row] = await this.base.executeQuery<InstanceConfigurationRow>({cql: FETCH_ROW_QUERY, params: {key}});
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
async executeQuery<T = Record<string, unknown>, P extends CassandraParams = CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
): Promise<Array<T>> {
|
||||
const meta = query.kvMeta ?? getKvMeta(query.cql);
|
||||
if (this.watchedKey === null || !this.isWatched(meta, query.params)) {
|
||||
return this.base.executeQuery<T, P>(query);
|
||||
}
|
||||
if (meta?.action === 'select') {
|
||||
this.events.push('read');
|
||||
return this.base.executeQuery<T, P>(query);
|
||||
}
|
||||
await this.passGate();
|
||||
await this.beforeEachWrite?.();
|
||||
const rows = await this.base.executeQuery<T, P>(query);
|
||||
const applied = (rows[0] as {'[applied]'?: unknown} | undefined)?.['[applied]'];
|
||||
this.events.push(applied === false ? 'write rejected' : 'write');
|
||||
return rows;
|
||||
}
|
||||
|
||||
executeBatch(queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, atomic?: boolean): Promise<void> {
|
||||
return this.base.executeBatch(queries, atomic);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.base.reset?.();
|
||||
this.watchedKey = null;
|
||||
this.gate = null;
|
||||
this.beforeEachWrite = null;
|
||||
this.events.length = 0;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.base.shutdown?.();
|
||||
}
|
||||
|
||||
private isWatched(meta: KvQueryMeta | null | undefined, params: CassandraParams): boolean {
|
||||
return meta?.table.name === InstanceConfiguration.name && params.key === this.watchedKey;
|
||||
}
|
||||
|
||||
private async passGate(): Promise<void> {
|
||||
const gate = this.gate;
|
||||
if (gate === null) return;
|
||||
await new Promise<void>((release) => {
|
||||
gate.paused.push(release);
|
||||
if (gate.paused.length < gate.size) return;
|
||||
this.gate = null;
|
||||
for (const release of gate.paused) release();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user