mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-09-29 05:57:58 +00:00
Rename ProjectionLocator to ProjectionAllocator and wire in.
The reason why we can't have an orchestrator own the projects yet is simply because currently the projections are created from _protected rooms set_ specific revision issuers that are disposed alongside it. And of course if we were naive and implemented the global orchestrator, we'd be tied to a stale protected rooms set if a Draupnir was restarted via safe mode or whatever.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
ClientsInRoomMap,
|
||||
Task,
|
||||
Logger,
|
||||
makeProtectedRoomsSetProjectionAllocator,
|
||||
ActionException,
|
||||
ActionExceptionKind,
|
||||
ConfigRecoverableError,
|
||||
@@ -171,7 +172,8 @@ export class DraupnirBotModeToggle implements BotModeTogle {
|
||||
clientCapabilityFactory,
|
||||
clientProvider,
|
||||
roomStateManagerFactory,
|
||||
stores
|
||||
stores,
|
||||
makeProtectedRoomsSetProjectionAllocator()
|
||||
);
|
||||
return new DraupnirBotModeToggle(
|
||||
clientUserID,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ActionExceptionKind,
|
||||
ActionResult,
|
||||
ClientsInRoomMap,
|
||||
makeProtectedRoomsSetProjectionAllocator,
|
||||
Ok,
|
||||
Task,
|
||||
assertThrowableIsError,
|
||||
@@ -85,7 +86,8 @@ export class AppServiceDraupnirManager {
|
||||
this.clientCapabilityFactory,
|
||||
clientProvider,
|
||||
this.roomStateManagerFactory,
|
||||
stores
|
||||
stores,
|
||||
makeProtectedRoomsSetProjectionAllocator()
|
||||
);
|
||||
this.baseManager = new StandardDraupnirManager(draupnirFactory);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
ClientsInRoomMap,
|
||||
Logger,
|
||||
Ok,
|
||||
ProjectionAllocator,
|
||||
ProtectedRoomsSet,
|
||||
StandardLoggableConfigTracker,
|
||||
isError,
|
||||
} from "@the-draupnir-project/matrix-protection-suite";
|
||||
@@ -64,7 +66,8 @@ export class DraupnirFactory {
|
||||
private readonly clientCapabilityFactory: ClientCapabilityFactory,
|
||||
private readonly clientProvider: ClientForUserID,
|
||||
private readonly roomStateManagerFactory: RoomStateManagerFactory,
|
||||
private readonly stores: TopLevelStores
|
||||
private readonly stores: TopLevelStores,
|
||||
private readonly projectionAllocator: ProjectionAllocator<ProtectedRoomsSet>
|
||||
) {
|
||||
// nothing to do.
|
||||
}
|
||||
@@ -102,6 +105,7 @@ export class DraupnirFactory {
|
||||
client,
|
||||
clientPlatform,
|
||||
clientUserID,
|
||||
this.projectionAllocator,
|
||||
config,
|
||||
configLogTracker
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Ok,
|
||||
PolicyListConfig,
|
||||
PolicyRoomManager,
|
||||
ProjectionAllocator,
|
||||
ProtectedRoomsConfig,
|
||||
ProtectedRoomsSet,
|
||||
ProtectionHandleRegistryDescription,
|
||||
@@ -173,6 +174,7 @@ export async function makeProtectedRoomsSet(
|
||||
client: MatrixSendClient,
|
||||
clientPlatform: ClientPlatform,
|
||||
userID: StringUserID,
|
||||
projectionAllocator: ProjectionAllocator<ProtectedRoomsSet>,
|
||||
config: IConfig,
|
||||
loggableConfigTracker: LoggableConfigTracker
|
||||
): Promise<ActionResult<ProtectedRoomsSet>> {
|
||||
@@ -226,6 +228,7 @@ export async function makeProtectedRoomsSet(
|
||||
protectionsConfig.ok,
|
||||
userID,
|
||||
DefaultMixinExtractor,
|
||||
projectionAllocator,
|
||||
makeHandleMissingProtectionPermissions(
|
||||
clientPlatform.toRoomMessageSender(),
|
||||
managementRoom.toRoomIDOrAlias()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-FileCopyrightText: 2026 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ok } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
ProjectionAllocator,
|
||||
StandardProjectionAllocator,
|
||||
} from "./ProjectionAllocator";
|
||||
|
||||
test("ProjectionAllocator semantics are implemented by the standard allocator", async () => {
|
||||
await ProjectionAllocator.check(async () => {
|
||||
return Ok(new StandardProjectionAllocator());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
// SPDX-FileCopyrightText: 2026 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
AnyProjectionDescription,
|
||||
ProjectionPartition,
|
||||
describeProjection,
|
||||
} from "./ProjectionDescription";
|
||||
import { ExtractProjectionNode, Projection } from "./Projection";
|
||||
import { ProjectionNode } from "./ProjectionNode";
|
||||
import {
|
||||
AllocatableLifetime,
|
||||
Disposable,
|
||||
StandardLifetime,
|
||||
} from "../Interface/Lifetime";
|
||||
import { SemanticType } from "../Interface/SemanticType";
|
||||
|
||||
export type DisposableProjection<TProjection extends Projection = Projection> =
|
||||
TProjection & Disposable;
|
||||
|
||||
export type ProjectionDescriptionFor<TProjection extends Projection> =
|
||||
ExtractProjectionNode<TProjection> extends ProjectionNode<infer TDescription>
|
||||
? TDescription
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Constructs registered projections and allocates them against a supplied lifetime.
|
||||
*
|
||||
* The allocator exists because currently we can't make a global orchestrator
|
||||
* own projections while there are context specific dependencies required by
|
||||
* the projection, specifically dependencies that are't invariant between protected
|
||||
* room set instances, such as legacy revision issuers, as opposed to the draupnir
|
||||
* user ID.
|
||||
*/
|
||||
export interface ProjectionAllocator<Context = unknown> {
|
||||
registerProjection<TProjection extends DisposableProjection>(
|
||||
description: ProjectionDescriptionFor<TProjection>,
|
||||
factory: (
|
||||
context: Context,
|
||||
partition: ProjectionPartition<ProjectionDescriptionFor<TProjection>>
|
||||
) => Result<TProjection>
|
||||
): this;
|
||||
|
||||
allocate<TProjection extends DisposableProjection>(
|
||||
lifetime: AllocatableLifetime,
|
||||
context: Context,
|
||||
description: ProjectionDescriptionFor<TProjection>,
|
||||
partition: ProjectionPartition<ProjectionDescriptionFor<TProjection>>
|
||||
): Result<TProjection>;
|
||||
}
|
||||
|
||||
export const ProjectionAllocator = SemanticType<ProjectionAllocator>(
|
||||
"ProjectionAllocator"
|
||||
)
|
||||
.declare({
|
||||
allocation: {
|
||||
what: "allocator can allocate registered projections",
|
||||
why: "allows callers to construct projections without depending on their concrete implementations",
|
||||
},
|
||||
lifetimeOwnership: {
|
||||
what: "allocated projections are owned by the supplied lifetime",
|
||||
why: "ensures projection resources are disposed with their caller",
|
||||
},
|
||||
freshAllocation: {
|
||||
what: "each allocation invokes the registered projection factory",
|
||||
why: "keeps projection identity and ownership independent between callers",
|
||||
},
|
||||
failWhenLifetimeInDisposal: {
|
||||
what: "allocation fails without invoking the projection factory when the supplied lifetime is in disposal",
|
||||
why: "prevents projections from being created without an owner that can dispose them",
|
||||
},
|
||||
canonicalDescription: {
|
||||
what: "only the projection description registered with a factory can be used to allocate that projection",
|
||||
why: "prevents a different description with the same name from selecting a factory with an incompatible partition schema",
|
||||
},
|
||||
factoryFailure: {
|
||||
what: "an error returned by a projection factory is returned by allocation",
|
||||
why: "allows callers to handle projection construction failures without receiving a partially allocated projection",
|
||||
},
|
||||
failWhenNotRegistered: {
|
||||
what: "allocating a projection that is unregistered fails early and errors",
|
||||
why: "prevents mistakes where the wrong projection is requested or registration code is forgotten",
|
||||
},
|
||||
partitionIsEssential: {
|
||||
what: "allocating a projection requires its partition information",
|
||||
why: "prevents leaking projections to the wrong code and catches bugs early",
|
||||
},
|
||||
failWhenUnexpectedPartition: {
|
||||
what: "allocating a projection fails if extra or unexpected partition data is provided",
|
||||
why: "prevents accidentally leaking projections to the wrong code and catches the problem early",
|
||||
},
|
||||
})
|
||||
.verify({
|
||||
async allocation(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticTestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
const projection = {
|
||||
[Symbol.dispose]() {
|
||||
// nothing to dispose.
|
||||
},
|
||||
} as TestProjection;
|
||||
const context = { source: "semantic context" };
|
||||
allocator.registerProjection<TestProjection>(
|
||||
description,
|
||||
(factoryContext, partition) => {
|
||||
expect(factoryContext).toBe(context);
|
||||
expect(partition.roomID).toBe("!room:example.org");
|
||||
return Ok(projection);
|
||||
}
|
||||
);
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
context,
|
||||
description,
|
||||
{
|
||||
roomID: "!room:example.org",
|
||||
}
|
||||
);
|
||||
expect(result.expect("Projection allocation should succeed")).toBe(
|
||||
projection
|
||||
);
|
||||
},
|
||||
async lifetimeOwnership(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticLifetimeProjection",
|
||||
partitionKeys: [],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
let disposalCount = 0;
|
||||
allocator.registerProjection<TestProjection>(description, () => {
|
||||
return Ok({
|
||||
[Symbol.dispose]() {
|
||||
disposalCount += 1;
|
||||
},
|
||||
} as TestProjection);
|
||||
});
|
||||
{
|
||||
await using lifetime = new StandardLifetime();
|
||||
allocator
|
||||
.allocate<TestProjection>(lifetime, {}, description, {})
|
||||
.expect("Projection allocation should succeed");
|
||||
}
|
||||
expect(disposalCount).toBe(1);
|
||||
},
|
||||
async freshAllocation(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticFreshProjection",
|
||||
partitionKeys: [],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
let allocationCount = 0;
|
||||
allocator.registerProjection<TestProjection>(description, () => {
|
||||
allocationCount += 1;
|
||||
return Ok({ [Symbol.dispose]() {} } as TestProjection);
|
||||
});
|
||||
await using lifetime = new StandardLifetime();
|
||||
const first = allocator
|
||||
.allocate<TestProjection>(lifetime, {}, description, {})
|
||||
.expect("First projection allocation should succeed");
|
||||
const second = allocator
|
||||
.allocate<TestProjection>(lifetime, {}, description, {})
|
||||
.expect("Second projection allocation should succeed");
|
||||
expect(first).not.toBe(second);
|
||||
expect(allocationCount).toBe(2);
|
||||
},
|
||||
async failWhenLifetimeInDisposal(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticDisposedLifetimeProjection",
|
||||
partitionKeys: [],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
let factoryInvocations = 0;
|
||||
allocator.registerProjection<TestProjection>(description, () => {
|
||||
factoryInvocations += 1;
|
||||
return Ok({ [Symbol.dispose]() {} } as TestProjection);
|
||||
});
|
||||
const lifetime = new StandardLifetime();
|
||||
await lifetime[Symbol.asyncDispose]();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
description,
|
||||
{}
|
||||
);
|
||||
expect(isError(result)).toBe(true);
|
||||
expect(factoryInvocations).toBe(0);
|
||||
},
|
||||
async canonicalDescription(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticCanonicalProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
const equivalentDescription = describeProjection({
|
||||
name: "SemanticCanonicalProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
allocator.registerProjection<TestProjection>(description, () =>
|
||||
Ok({ [Symbol.dispose]() {} } as TestProjection)
|
||||
);
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
equivalentDescription,
|
||||
{ roomID: "!room:example.org" }
|
||||
);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
async factoryFailure(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticFailingFactoryProjection",
|
||||
partitionKeys: [],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
const factoryError = ResultError.Result("Projection creation failed");
|
||||
allocator.registerProjection<TestProjection>(
|
||||
description,
|
||||
() => factoryError
|
||||
);
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
description,
|
||||
{}
|
||||
);
|
||||
expect(result).toBe(factoryError);
|
||||
},
|
||||
async failWhenNotRegistered(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticUnregisteredProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
description,
|
||||
{
|
||||
roomID: "!room:example.org",
|
||||
}
|
||||
);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
async partitionIsEssential(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticRequiredPartitionProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
description,
|
||||
{} as never
|
||||
);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
async failWhenUnexpectedPartition(makeSubject) {
|
||||
const allocator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticUnexpectedPartitionProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = DisposableProjection<
|
||||
Projection<ProjectionNode<typeof description>>
|
||||
>;
|
||||
await using lifetime = new StandardLifetime();
|
||||
const result = allocator.allocate<TestProjection>(
|
||||
lifetime,
|
||||
{},
|
||||
description,
|
||||
{
|
||||
roomID: "!room:example.org",
|
||||
unknownKey: "value",
|
||||
} as never
|
||||
);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
type AnyProjectionFactory<Context> = (
|
||||
context: Context,
|
||||
partition: ProjectionPartition
|
||||
) => Result<DisposableProjection>;
|
||||
|
||||
function validateProjectionPartition(
|
||||
description: AnyProjectionDescription,
|
||||
partition: ProjectionPartition
|
||||
): Result<void> {
|
||||
const expectedKeys = new Set(description.partitionKeys);
|
||||
const unexpectedKey = Object.keys(partition).find(
|
||||
(key) => !expectedKeys.has(key)
|
||||
);
|
||||
if (unexpectedKey !== undefined) {
|
||||
return ResultError.Result(
|
||||
`Partition for projection ${description.name} has unexpected key ${unexpectedKey}`
|
||||
);
|
||||
}
|
||||
const missingKey = description.partitionKeys.find(
|
||||
(key) => partition[key] === undefined
|
||||
);
|
||||
if (missingKey !== undefined) {
|
||||
return ResultError.Result(
|
||||
`Partition for projection ${description.name} is missing key ${missingKey}`
|
||||
);
|
||||
}
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
export class StandardProjectionAllocator<
|
||||
Context = unknown,
|
||||
> implements ProjectionAllocator<Context> {
|
||||
private readonly factories = new Map<
|
||||
AnyProjectionDescription,
|
||||
AnyProjectionFactory<Context>
|
||||
>();
|
||||
private readonly registeredNames = new Set<string>();
|
||||
|
||||
public registerProjection<TProjection extends DisposableProjection>(
|
||||
description: ProjectionDescriptionFor<TProjection>,
|
||||
factory: (
|
||||
context: Context,
|
||||
partition: ProjectionPartition<ProjectionDescriptionFor<TProjection>>
|
||||
) => Result<TProjection>
|
||||
): this {
|
||||
if (this.registeredNames.has(description.name)) {
|
||||
throw new TypeError(
|
||||
`There is already a projection registered with the name ${description.name}`
|
||||
);
|
||||
}
|
||||
this.registeredNames.add(description.name);
|
||||
this.factories.set(
|
||||
description,
|
||||
factory as unknown as AnyProjectionFactory<Context>
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
public allocate<TProjection extends DisposableProjection>(
|
||||
lifetime: AllocatableLifetime,
|
||||
context: Context,
|
||||
description: ProjectionDescriptionFor<TProjection>,
|
||||
partition: ProjectionPartition<ProjectionDescriptionFor<TProjection>>
|
||||
): Result<TProjection> {
|
||||
return lifetime.allocateDisposable(() => {
|
||||
const validationResult = validateProjectionPartition(
|
||||
description,
|
||||
partition
|
||||
);
|
||||
if (isError(validationResult)) {
|
||||
return validationResult;
|
||||
}
|
||||
const factory = this.factories.get(description);
|
||||
if (factory === undefined) {
|
||||
return ResultError.Result(
|
||||
`No projection factory registered for the supplied ${description.name} description`
|
||||
);
|
||||
}
|
||||
return factory(context, partition) as Result<TProjection>;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ok } from "@gnuxie/typescript-result";
|
||||
import { Projection } from "./Projection";
|
||||
import { describeProjection } from "./ProjectionDescription";
|
||||
import {
|
||||
ProjectionLocator,
|
||||
StandardProjectionLocator,
|
||||
} from "./ProjectionLocator";
|
||||
import { ProjectionNode } from "./ProjectionNode";
|
||||
|
||||
const TestProjectionDescription = describeProjection({
|
||||
name: "TestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
|
||||
type TestProjectionDescription = typeof TestProjectionDescription;
|
||||
type TestProjectionNode = ProjectionNode<TestProjectionDescription>;
|
||||
type TestProjection = Projection<TestProjectionNode>;
|
||||
|
||||
test("ProjectionLocator semantics are implemented by the standard locator", async () => {
|
||||
await ProjectionLocator.check(async () => {
|
||||
return Ok(new StandardProjectionLocator({}));
|
||||
});
|
||||
});
|
||||
|
||||
test("passes the constructor context to the projection provider", () => {
|
||||
const context = { source: "context" };
|
||||
const projection = {} as TestProjection;
|
||||
const locator = new StandardProjectionLocator(
|
||||
context
|
||||
).registerProjection<TestProjection>({
|
||||
projectionDescription: TestProjectionDescription,
|
||||
factory(providerContext, location) {
|
||||
expect(providerContext).toBe(context);
|
||||
expect(location.partition.roomID).toBe("!room:example.org");
|
||||
return Ok(projection);
|
||||
},
|
||||
});
|
||||
const result = locator.locate<TestProjection>({
|
||||
description: TestProjectionDescription,
|
||||
partition: {
|
||||
roomID: "!room:example.org",
|
||||
},
|
||||
});
|
||||
expect(result.expect("Projection location should succeed")).toBe(projection);
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
AnyProjectionDescription,
|
||||
ProjectionPartition,
|
||||
describeProjection,
|
||||
} from "./ProjectionDescription";
|
||||
import { ExtractProjectionNode, Projection } from "./Projection";
|
||||
import { ProjectionNode } from "./ProjectionNode";
|
||||
import { Disposable } from "../Interface/Lifetime";
|
||||
import { SemanticType } from "../Interface/SemanticType";
|
||||
|
||||
export type DisposableProjection<TProjection extends Projection = Projection> =
|
||||
TProjection & Disposable;
|
||||
|
||||
export type ProjectionDescriptionFor<TProjection extends Projection> =
|
||||
ExtractProjectionNode<TProjection> extends ProjectionNode<infer TDescription>
|
||||
? TDescription
|
||||
: never;
|
||||
|
||||
export type ProjectionLocation<
|
||||
TProjectionDescription extends AnyProjectionDescription =
|
||||
AnyProjectionDescription,
|
||||
> = {
|
||||
readonly description: TProjectionDescription;
|
||||
readonly partition: ProjectionPartition<TProjectionDescription>;
|
||||
};
|
||||
|
||||
export type ProjectionProviderDescription<
|
||||
Context = unknown,
|
||||
TProjectionDescription extends AnyProjectionDescription =
|
||||
AnyProjectionDescription,
|
||||
TProjection extends Projection = Projection,
|
||||
> = {
|
||||
readonly projectionDescription: TProjectionDescription;
|
||||
factory(
|
||||
context: Context,
|
||||
location: ProjectionLocation<TProjectionDescription>
|
||||
): Result<TProjection>;
|
||||
};
|
||||
|
||||
// FIXME: We need to be able to attenuate locators to specific
|
||||
// draupnir instances for internal code/security defence in depth (mostly
|
||||
// to prevent mistakes).
|
||||
export interface ProjectionLocator<Context = unknown> {
|
||||
registerProjection<TProjection extends Projection>(
|
||||
provider: ProjectionProviderDescription<
|
||||
Context,
|
||||
ProjectionDescriptionFor<TProjection>,
|
||||
TProjection
|
||||
>
|
||||
): this;
|
||||
|
||||
locate<TProjection extends Projection>(
|
||||
location: ProjectionLocation<ProjectionDescriptionFor<TProjection>>
|
||||
): Result<TProjection>;
|
||||
}
|
||||
|
||||
export const ProjectionLocator = SemanticType<ProjectionLocator>(
|
||||
"ProjectionLocator"
|
||||
)
|
||||
.declare({
|
||||
location: {
|
||||
what: "locator can locate registered projections and return them",
|
||||
why: "allows the orchestrator to own projections rather than draupnir instances",
|
||||
},
|
||||
failWhenNotLocated: {
|
||||
what: "locating a projection that is unregistered fails early and errors",
|
||||
why: "prevents mistakes where the wrong projection is sought or registration code is forgotten",
|
||||
},
|
||||
partitionIsEssential: {
|
||||
what: "locating a projection requires the partition information to be provided",
|
||||
why: "prevents leaking projections to the wrong code and catches bugs early",
|
||||
},
|
||||
failWhenUnexpectedPartition: {
|
||||
what: "locating a projection fails if extra or unexpected partition data is provided",
|
||||
why: "prevents accidentally leaking projections to the wrong code (security issue) and catches the problem early",
|
||||
},
|
||||
})
|
||||
.verify({
|
||||
async location(makeSubject) {
|
||||
const locator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticTestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = Projection<ProjectionNode<typeof description>>;
|
||||
const projection = {} as TestProjection;
|
||||
locator.registerProjection<TestProjection>({
|
||||
projectionDescription: description,
|
||||
factory(_context, location) {
|
||||
expect(location.partition.roomID).toBe("!room:example.org");
|
||||
return Ok(projection);
|
||||
},
|
||||
});
|
||||
const result = locator.locate<TestProjection>({
|
||||
description,
|
||||
partition: {
|
||||
roomID: "!room:example.org",
|
||||
},
|
||||
});
|
||||
expect(result.expect("Projection location should succeed")).toBe(
|
||||
projection
|
||||
);
|
||||
},
|
||||
async failWhenNotLocated(makeSubject) {
|
||||
const locator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticTestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = Projection<ProjectionNode<typeof description>>;
|
||||
const result = locator.locate<TestProjection>({
|
||||
description,
|
||||
partition: {
|
||||
roomID: "!room:example.org",
|
||||
},
|
||||
});
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
async partitionIsEssential(makeSubject) {
|
||||
const locator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticTestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = Projection<ProjectionNode<typeof description>>;
|
||||
const result = locator.locate<TestProjection>({
|
||||
description,
|
||||
partition: {},
|
||||
} as never);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
async failWhenUnexpectedPartition(makeSubject) {
|
||||
const locator = (await makeSubject()).expect(
|
||||
"Should be able to make the subject"
|
||||
);
|
||||
const description = describeProjection({
|
||||
name: "SemanticTestProjection",
|
||||
partitionKeys: ["roomID"],
|
||||
}).build();
|
||||
type TestProjection = Projection<ProjectionNode<typeof description>>;
|
||||
const result = locator.locate<TestProjection>({
|
||||
description,
|
||||
partition: {
|
||||
roomID: "!room:example.org",
|
||||
unknownKey: "value",
|
||||
},
|
||||
} as never);
|
||||
expect(isError(result)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
type AnyProjectionProviderDescription<Context> =
|
||||
ProjectionProviderDescription<Context>;
|
||||
|
||||
function validateProjectionLocation(
|
||||
location: ProjectionLocation
|
||||
): Result<void> {
|
||||
const expectedKeys = new Set(location.description.partitionKeys);
|
||||
const unexpectedKey = Object.keys(location.partition).find(
|
||||
(key) => !expectedKeys.has(key)
|
||||
);
|
||||
if (unexpectedKey !== undefined) {
|
||||
return ResultError.Result(
|
||||
`Projection location for ${location.description.name} has unexpected partition key ${unexpectedKey}`
|
||||
);
|
||||
}
|
||||
const missingKey = location.description.partitionKeys.find(
|
||||
(key) => location.partition[key] === undefined
|
||||
);
|
||||
if (missingKey !== undefined) {
|
||||
return ResultError.Result(
|
||||
`Projection location for ${location.description.name} is missing partition key ${missingKey}`
|
||||
);
|
||||
}
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
export class StandardProjectionLocator<
|
||||
Context = unknown,
|
||||
> implements ProjectionLocator<Context> {
|
||||
private readonly providers = new Map<
|
||||
string,
|
||||
AnyProjectionProviderDescription<Context>
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
private readonly context: Context,
|
||||
providers: readonly AnyProjectionProviderDescription<Context>[] = []
|
||||
) {
|
||||
for (const provider of providers) {
|
||||
this.registerProjection(provider);
|
||||
}
|
||||
}
|
||||
|
||||
public registerProjection<TProjection extends Projection>(
|
||||
provider: ProjectionProviderDescription<
|
||||
Context,
|
||||
ProjectionDescriptionFor<TProjection>,
|
||||
TProjection
|
||||
>
|
||||
): this {
|
||||
if (this.providers.has(provider.projectionDescription.name)) {
|
||||
throw new TypeError(
|
||||
`There is already a projection registered with the name ${provider.projectionDescription.name}`
|
||||
);
|
||||
}
|
||||
this.providers.set(
|
||||
provider.projectionDescription.name,
|
||||
provider as unknown as AnyProjectionProviderDescription<Context>
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
public locate<TProjection extends Projection>(
|
||||
location: ProjectionLocation<ProjectionDescriptionFor<TProjection>>
|
||||
): Result<TProjection> {
|
||||
const validationResult = validateProjectionLocation(location);
|
||||
if (isError(validationResult)) {
|
||||
return validationResult;
|
||||
}
|
||||
const provider = this.providers.get(location.description.name);
|
||||
if (provider === undefined) {
|
||||
return ResultError.Result(
|
||||
`No projection registered with the name ${location.description.name}`
|
||||
);
|
||||
}
|
||||
return provider.factory(this.context, location) as Result<TProjection>;
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,13 @@ import {
|
||||
import { WatchedPolicyRooms } from "./WatchedPolicyRooms/WatchedPolicyRooms";
|
||||
import { MixinExtractor } from "../SafeMatrixEvents/EventMixinExtraction/EventMixinExtraction";
|
||||
import { RoomCreateEvent, RoomVersionMirror } from "../MatrixTypes/CreateRoom";
|
||||
import { ProjectionLocator } from "../Projection/ProjectionLocator";
|
||||
import { makeProtectedRoomsSetProjectionLocator } from "./ProtectedRoomsSetProjectionLocator";
|
||||
import { ProjectionAllocator } from "../Projection/ProjectionAllocator";
|
||||
|
||||
export interface ProtectedRoomsSet {
|
||||
readonly watchedPolicyRooms: WatchedPolicyRooms;
|
||||
readonly protectedRoomsManager: ProtectedRoomsManager;
|
||||
readonly protections: ProtectionsManager;
|
||||
readonly projectionLocator: ProjectionLocator<ProtectedRoomsSet>;
|
||||
readonly projectionAllocator: ProjectionAllocator<ProtectedRoomsSet>;
|
||||
readonly setRoomMembership: SetRoomMembership;
|
||||
readonly setMembership: SetMembershipRevisionIssuer;
|
||||
readonly setRoomState: SetRoomState;
|
||||
@@ -103,7 +102,6 @@ export class StandardProtectedRoomsSet implements ProtectedRoomsSet {
|
||||
private readonly setMembershipPolicyRevisionListener =
|
||||
this.setMembershipPolicyRevision.bind(this);
|
||||
public readonly setPoliciesMatchingMembership: SetMembershipPolicyRevisionIssuer;
|
||||
public readonly projectionLocator: ProjectionLocator<ProtectedRoomsSet>;
|
||||
|
||||
constructor(
|
||||
public readonly watchedPolicyRooms: WatchedPolicyRooms,
|
||||
@@ -111,6 +109,7 @@ export class StandardProtectedRoomsSet implements ProtectedRoomsSet {
|
||||
public readonly protections: ProtectionsManager,
|
||||
public readonly userID: StringUserID,
|
||||
public readonly eventMixinExtractor: MixinExtractor,
|
||||
public readonly projectionAllocator: ProjectionAllocator<ProtectedRoomsSet>,
|
||||
private readonly handleMissingProtectionPermissions?: HandleMissingProtectionPermissions
|
||||
) {
|
||||
this.setRoomMembership.on("membership", this.membershipChangeListener);
|
||||
@@ -127,7 +126,6 @@ export class StandardProtectedRoomsSet implements ProtectedRoomsSet {
|
||||
"revision",
|
||||
this.setMembershipPolicyRevisionListener
|
||||
);
|
||||
this.projectionLocator = makeProtectedRoomsSetProjectionLocator(this);
|
||||
}
|
||||
public get setRoomState() {
|
||||
return this.protectedRoomsManager.setRoomState;
|
||||
|
||||
+22
-17
@@ -5,9 +5,9 @@
|
||||
import { Ok } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
DisposableProjection,
|
||||
ProjectionLocator,
|
||||
StandardProjectionLocator,
|
||||
} from "../Projection/ProjectionLocator";
|
||||
ProjectionAllocator,
|
||||
StandardProjectionAllocator,
|
||||
} from "../Projection/ProjectionAllocator";
|
||||
import {
|
||||
MemberBanIntentProjection,
|
||||
StandardMemberBanIntentProjection,
|
||||
@@ -20,28 +20,33 @@ import {
|
||||
import { ServerBanIntentProjectionDescription } from "./StandardProtections/ServerBanSynchronisation/ServerBanIntentProjectionNode";
|
||||
import type { ProtectedRoomsSet } from "./ProtectedRoomsSet";
|
||||
|
||||
export function makeProtectedRoomsSetProjectionLocator(
|
||||
protectedRoomsSet: ProtectedRoomsSet
|
||||
): ProjectionLocator<ProtectedRoomsSet> {
|
||||
return new StandardProjectionLocator<ProtectedRoomsSet>(protectedRoomsSet)
|
||||
.registerProjection<DisposableProjection<MemberBanIntentProjection>>({
|
||||
projectionDescription: MemberBanIntentProjectionDescription,
|
||||
factory(context) {
|
||||
/**
|
||||
* This is used at the top level of the appservice and Draupnir to provide instances
|
||||
* of projections that depend on context from a protected rooms set to construct.
|
||||
* The projections are still owned by the protected rooms set that wants them.
|
||||
* This is just something that we have to deal with until we port the entire
|
||||
* data pipeline over unfortunately.
|
||||
*/
|
||||
export function makeProtectedRoomsSetProjectionAllocator(): ProjectionAllocator<ProtectedRoomsSet> {
|
||||
return new StandardProjectionAllocator<ProtectedRoomsSet>()
|
||||
.registerProjection<DisposableProjection<MemberBanIntentProjection>>(
|
||||
MemberBanIntentProjectionDescription,
|
||||
(context) => {
|
||||
return Ok(
|
||||
new StandardMemberBanIntentProjection(
|
||||
context.setPoliciesMatchingMembership
|
||||
)
|
||||
);
|
||||
},
|
||||
})
|
||||
.registerProjection<DisposableProjection<ServerBanIntentProjection>>({
|
||||
projectionDescription: ServerBanIntentProjectionDescription,
|
||||
factory(context) {
|
||||
}
|
||||
)
|
||||
.registerProjection<DisposableProjection<ServerBanIntentProjection>>(
|
||||
ServerBanIntentProjectionDescription,
|
||||
(context) => {
|
||||
return Ok(
|
||||
new StandardServerBanIntentProjection(
|
||||
context.watchedPolicyRooms.revisionIssuer
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
+5
-8
@@ -42,7 +42,7 @@ import {
|
||||
import { OwnLifetime } from "../../../Interface/Lifetime";
|
||||
import { MemberBanIntentProjection } from "./MemberBanIntentProjection";
|
||||
import { MemberBanIntentProjectionDescription } from "./MemberBanIntentProjectionNode";
|
||||
import { DisposableProjection } from "../../../Projection/ProjectionLocator";
|
||||
import { DisposableProjection } from "../../../Projection/ProjectionAllocator";
|
||||
|
||||
function isRecommendationWorthBanning(policyRule: PolicyRule) {
|
||||
return (
|
||||
@@ -203,16 +203,13 @@ describeProtection<MemberBanSynchronisationProtectionCapabilities>({
|
||||
_settings,
|
||||
capabilitySet
|
||||
) => {
|
||||
const intentProjection = protectedRoomsSet.projectionLocator.locate<
|
||||
const intentProjection = protectedRoomsSet.projectionAllocator.allocate<
|
||||
DisposableProjection<MemberBanIntentProjection>
|
||||
>({
|
||||
description: MemberBanIntentProjectionDescription,
|
||||
partition: {
|
||||
draupnirID: protectedRoomsSet.userID,
|
||||
},
|
||||
>(lifetime, protectedRoomsSet, MemberBanIntentProjectionDescription, {
|
||||
draupnirID: protectedRoomsSet.userID,
|
||||
});
|
||||
if (isError(intentProjection)) {
|
||||
return intentProjection.elaborate("Unable to locate intent projection");
|
||||
return intentProjection.elaborate("Unable to allocate intent projection");
|
||||
}
|
||||
return Ok(
|
||||
new MemberBanSynchronisationProtection(
|
||||
|
||||
+5
-10
@@ -30,7 +30,7 @@ import { ServerBanIntentProjection } from "./ServerBanIntentProjection";
|
||||
import { ServerBanIntentProjectionDescription } from "./ServerBanIntentProjectionNode";
|
||||
import { ServerBanSynchronisationCapability } from "./ServerBanSynchronisationCapability";
|
||||
import { Logger } from "../../../Logging/Logger";
|
||||
import { DisposableProjection } from "../../../Projection/ProjectionLocator";
|
||||
import { DisposableProjection } from "../../../Projection/ProjectionAllocator";
|
||||
|
||||
const log = new Logger("ServerBanSynchronisationProtection");
|
||||
|
||||
@@ -125,18 +125,13 @@ describeProtection<Capabilities>({
|
||||
_settings,
|
||||
capabilities
|
||||
) => {
|
||||
const intentProjection = protectedRoomsSet.projectionLocator.locate<
|
||||
const intentProjection = protectedRoomsSet.projectionAllocator.allocate<
|
||||
DisposableProjection<ServerBanIntentProjection>
|
||||
>({
|
||||
description: ServerBanIntentProjectionDescription,
|
||||
partition: {
|
||||
draupnirID: protectedRoomsSet.userID,
|
||||
},
|
||||
>(lifetime, protectedRoomsSet, ServerBanIntentProjectionDescription, {
|
||||
draupnirID: protectedRoomsSet.userID,
|
||||
});
|
||||
if (isError(intentProjection)) {
|
||||
return intentProjection.elaborate(
|
||||
"Unable to locate the intent projection"
|
||||
);
|
||||
return intentProjection.elaborate("Unable to allocate intent projection");
|
||||
}
|
||||
return Ok(
|
||||
new ServerBanSynchronisationProtection(
|
||||
|
||||
@@ -43,6 +43,8 @@ import { StandardWatchedPolicyRooms } from "../Protection/WatchedPolicyRooms/Sta
|
||||
import { DefaultEventDecoder } from "../MatrixTypes/DefaultEventDecoder";
|
||||
import { DefaultMixinExtractor } from "../SafeMatrixEvents/MatrixEventMixinDescriptions/DefaultMixinExtractor";
|
||||
import { RoomCreateEvent } from "../MatrixTypes/CreateRoom";
|
||||
import { ProjectionAllocator } from "../Projection/ProjectionAllocator";
|
||||
import { makeProtectedRoomsSetProjectionAllocator } from "../Protection/ProtectedRoomsSetProjectionAllocator";
|
||||
|
||||
const log = new Logger("DeclareRoomState");
|
||||
|
||||
@@ -56,6 +58,7 @@ export type DescribeProtectedRoomsSet = {
|
||||
rooms?: DescribeRoomOptions[];
|
||||
lists?: DescribeRoomOptions[];
|
||||
clientUserID?: StringUserID;
|
||||
projectionAllocator?: ProjectionAllocator<ProtectedRoomsSet>;
|
||||
};
|
||||
|
||||
export type ProtectedRoomsSetDescription = {
|
||||
@@ -69,6 +72,7 @@ export async function describeProtectedRoomsSet({
|
||||
rooms = [],
|
||||
lists = [],
|
||||
clientUserID = randomUserID(),
|
||||
projectionAllocator = makeProtectedRoomsSetProjectionAllocator(),
|
||||
}: DescribeProtectedRoomsSet): Promise<ProtectedRoomsSetDescription> {
|
||||
const listDescriptions = lists.map(describeRoom);
|
||||
const roomDescriptions = [...listDescriptions, ...rooms.map(describeRoom)];
|
||||
@@ -139,7 +143,8 @@ export async function describeProtectedRoomsSet({
|
||||
protectedRoomsManager.ok,
|
||||
new FakeProtectionsManager(),
|
||||
clientUserID,
|
||||
DefaultMixinExtractor
|
||||
DefaultMixinExtractor,
|
||||
projectionAllocator
|
||||
);
|
||||
return {
|
||||
protectedRoomsSet,
|
||||
|
||||
@@ -119,7 +119,7 @@ export * from "./PolicyList/StandardPolicyRoomRevisionIssuer";
|
||||
|
||||
export * from "./Projection/Projection";
|
||||
export * from "./Projection/ProjectionDescription";
|
||||
export * from "./Projection/ProjectionLocator";
|
||||
export * from "./Projection/ProjectionAllocator";
|
||||
export * from "./Projection/ProjectionNode";
|
||||
|
||||
export * from "./Protection/Capability/StandardCapability/CapabilityMethodSchema";
|
||||
@@ -192,6 +192,7 @@ export * from "./Protection/DescriptionMeta";
|
||||
|
||||
export * from "./Protection/DirectPropagationPolicyListRevisionIssuer";
|
||||
export * from "./Protection/ProtectedRoomsSet";
|
||||
export * from "./Protection/ProtectedRoomsSetProjectionAllocator";
|
||||
export * from "./Protection/Protection";
|
||||
export * from "./Protection/ProtectionHandles";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user