@microck/canonfig 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/agent/agent-resolution.errors.js +42 -0
- package/dist/agent/agent-resolution.layer.js +204 -0
- package/dist/agent/agent-resolution.service.js +2259 -0
- package/dist/agent/agent-resolution.types.js +1 -0
- package/dist/agent/controlled-executor.js +704 -0
- package/dist/agent/harness-adapters.js +85 -0
- package/dist/cli/cli.js +618 -0
- package/dist/cli/exit-codes.js +28 -0
- package/dist/cli/follower-commands.js +3 -0
- package/dist/cli/render.js +56 -0
- package/dist/cli/source-commands.js +5 -0
- package/dist/domain/brand.js +29 -0
- package/dist/domain/identity.js +31 -0
- package/dist/domain/npm-package-spec.js +186 -0
- package/dist/domain/profile.js +950 -0
- package/dist/domain/recipe-versions.js +297 -0
- package/dist/domain/resource.js +259 -0
- package/dist/domain/synchronization.js +346 -0
- package/dist/enrollment/enrollment.errors.js +43 -0
- package/dist/enrollment/enrollment.layer.js +724 -0
- package/dist/enrollment/enrollment.service.js +3 -0
- package/dist/enrollment/enrollment.types.js +59 -0
- package/dist/enrollment/follower-client.js +585 -0
- package/dist/enrollment/source-server.js +313 -0
- package/dist/machine/linux.layer.js +1183 -0
- package/dist/machine/machine-state.errors.js +52 -0
- package/dist/machine/machine-state.service.js +3 -0
- package/dist/machine/machine-state.types.js +1 -0
- package/dist/machine/macos.layer.js +470 -0
- package/dist/machine/windows.layer.js +879 -0
- package/dist/profile/discovery.js +740 -0
- package/dist/profile/profile-catalog.errors.js +50 -0
- package/dist/profile/profile-catalog.layer.js +20 -0
- package/dist/profile/profile-catalog.service.js +7 -0
- package/dist/profile/profile-codec.js +153 -0
- package/dist/profile/publication.js +298 -0
- package/dist/profile/tool-catalog.js +384 -0
- package/dist/runtime/doctor.js +306 -0
- package/dist/runtime/layers.js +706 -0
- package/dist/runtime/main.js +38 -0
- package/dist/schedule/linux-schedule.js +24 -0
- package/dist/schedule/macos-schedule.js +25 -0
- package/dist/schedule/schedule-manager.errors.js +17 -0
- package/dist/schedule/schedule-manager.layer.js +205 -0
- package/dist/schedule/schedule-manager.service.js +3 -0
- package/dist/schedule/schedule-manager.types.js +114 -0
- package/dist/schedule/windows-schedule.js +25 -0
- package/dist/state/state-repository.errors.js +55 -0
- package/dist/state/state-repository.layer.js +1507 -0
- package/dist/state/state-repository.service.js +3 -0
- package/dist/state/state-repository.types.js +1 -0
- package/dist/state/state-schema.js +298 -0
- package/dist/synchronization/config-codec.js +97 -0
- package/dist/synchronization/executor.js +700 -0
- package/dist/synchronization/follower-orchestration.js +939 -0
- package/dist/synchronization/follower-sync-config.js +81 -0
- package/dist/synchronization/npm-artifact.js +670 -0
- package/dist/synchronization/planner.js +378 -0
- package/dist/synchronization/recovery.js +397 -0
- package/dist/synchronization/resource-executors.js +1198 -0
- package/dist/synchronization/resource-plans.js +645 -0
- package/dist/synchronization/synchronization.errors.js +102 -0
- package/dist/synchronization/synchronization.layer.js +97 -0
- package/dist/synchronization/synchronization.service.js +11 -0
- package/dist/synchronization/synchronization.types.js +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, verify, X509Certificate, } from "node:crypto";
|
|
2
|
+
import { Effect, Layer, Redacted, Schema } from "effect";
|
|
3
|
+
import { generate } from "selfsigned";
|
|
4
|
+
import { CertificateFingerprint, ContentDigest, CredentialReference, FollowerId, GroupName, InvitationCode, Timestamp, } from "../domain/brand.js";
|
|
5
|
+
import { FollowerIdentity, SourceIdentity } from "../domain/identity.js";
|
|
6
|
+
import { MachineProfileSchema, validateMachineProfile, } from "../domain/profile.js";
|
|
7
|
+
import { MachineState } from "../machine/machine-state.service.js";
|
|
8
|
+
import { EnrollmentStateConflictError, FollowerNotFoundError, } from "../state/state-repository.errors.js";
|
|
9
|
+
import { StateRepository } from "../state/state-repository.service.js";
|
|
10
|
+
import { DuplicateFollowerIdentityError, EnrollmentConfigurationError, EnrollmentFingerprintMismatchError, EnrollmentSourceMismatchError, InvitationExpiredError, InvitationNotFoundError, InvitationReplayError, InvalidFollowerCredentialError, RevokedFollowerCredentialError, SourceNotInitializedError, TransportIntegrityError, TransportResourceNotFoundError, } from "./enrollment.errors.js";
|
|
11
|
+
import { Enrollment } from "./enrollment.service.js";
|
|
12
|
+
import { TransportPublishedResourceSchema } from "./enrollment.types.js";
|
|
13
|
+
import { canonicalJson, digestOf, sha256BytesHex, sha256Hex, } from "../profile/profile-codec.js";
|
|
14
|
+
import { revisionSigningPayload } from "../profile/publication.js";
|
|
15
|
+
const decode = Schema.decodeUnknownSync;
|
|
16
|
+
const maximumInvitationLifetimeMilliseconds = 24 * 60 * 60 * 1000;
|
|
17
|
+
const sha256 = (value) => decode(ContentDigest)(createHash("sha256").update(value).digest("hex"));
|
|
18
|
+
const certificateFingerprint = (certificate) => decode(CertificateFingerprint)(new X509Certificate(certificate).fingerprint256.replaceAll(":", "").toLowerCase());
|
|
19
|
+
const asJson = (value) => decode(Schema.MutableJson)(JSON.parse(JSON.stringify(value)));
|
|
20
|
+
const resourceIsAuthorized = (resource, groups) => resource.groups === undefined
|
|
21
|
+
|| resource.groups.length === 0
|
|
22
|
+
|| resource.groups.some((group) => groups.has(group));
|
|
23
|
+
const visibleResources = (revision, groups) => {
|
|
24
|
+
const visibleIds = new Set(revision.resources
|
|
25
|
+
.filter((resource) => resourceIsAuthorized(resource, groups))
|
|
26
|
+
.map((resource) => resource.id));
|
|
27
|
+
// Authorization is a projection of the signed revision, not a dependency
|
|
28
|
+
// rewrite. Remove every dependent whose complete dependency closure is not
|
|
29
|
+
// visible, including transitive dependents.
|
|
30
|
+
let changed = true;
|
|
31
|
+
while (changed) {
|
|
32
|
+
changed = false;
|
|
33
|
+
for (const resource of revision.resources) {
|
|
34
|
+
if (visibleIds.has(resource.id)
|
|
35
|
+
&& resource.dependsOn.some((dependency) => !visibleIds.has(dependency))) {
|
|
36
|
+
visibleIds.delete(resource.id);
|
|
37
|
+
changed = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return revision.resources.filter((resource) => visibleIds.has(resource.id));
|
|
42
|
+
};
|
|
43
|
+
const revisionPayload = (revision, signingKeyId) => revisionSigningPayload({
|
|
44
|
+
id: revision.id,
|
|
45
|
+
profileId: revision.profileId,
|
|
46
|
+
sequence: revision.sequence,
|
|
47
|
+
canonicalBytes: revision.canonicalBytes,
|
|
48
|
+
digest: revision.digest,
|
|
49
|
+
publishedAt: revision.publishedAt,
|
|
50
|
+
resources: revision.resources,
|
|
51
|
+
groups: revision.groups,
|
|
52
|
+
scheduleDefault: revision.scheduleDefault,
|
|
53
|
+
signingKeyId,
|
|
54
|
+
});
|
|
55
|
+
const validateRevision = (revision, signingKeyId, publicKey) => Effect.try({
|
|
56
|
+
try: () => {
|
|
57
|
+
if (sha256Hex(revision.canonicalBytes) !== revision.digest) {
|
|
58
|
+
throw new Error("canonical content digest mismatch");
|
|
59
|
+
}
|
|
60
|
+
const profile = decode(MachineProfileSchema)(JSON.parse(revision.canonicalBytes));
|
|
61
|
+
const profileErrors = validateMachineProfile(profile);
|
|
62
|
+
if (profileErrors.length > 0) {
|
|
63
|
+
throw new Error("profile verification contract mismatch");
|
|
64
|
+
}
|
|
65
|
+
if (profile.id !== revision.profileId) {
|
|
66
|
+
throw new Error("profile identity mismatch");
|
|
67
|
+
}
|
|
68
|
+
if (revision.scheduleDefault !== undefined
|
|
69
|
+
&& canonicalJson(asJson(revision.scheduleDefault))
|
|
70
|
+
!== canonicalJson(asJson(profile.scheduleDefault))) {
|
|
71
|
+
throw new Error("revision schedule default metadata mismatch");
|
|
72
|
+
}
|
|
73
|
+
const expectedResources = profile.resources.map((resource) => {
|
|
74
|
+
const base = {
|
|
75
|
+
id: resource.id,
|
|
76
|
+
kind: resource.kind,
|
|
77
|
+
policy: resource.policy,
|
|
78
|
+
target: resource.target,
|
|
79
|
+
dependsOn: resource.dependsOn ?? [],
|
|
80
|
+
blobs: [digestOf(asJson(resource.spec))],
|
|
81
|
+
};
|
|
82
|
+
return resource.groups === undefined
|
|
83
|
+
? base
|
|
84
|
+
: { ...base, groups: resource.groups };
|
|
85
|
+
});
|
|
86
|
+
if (canonicalJson(asJson(expectedResources))
|
|
87
|
+
!== canonicalJson(asJson(revision.resources))) {
|
|
88
|
+
throw new Error("revision resource metadata mismatch");
|
|
89
|
+
}
|
|
90
|
+
const encodedSignature = revision.signature.slice("ed25519:".length);
|
|
91
|
+
if (!revision.signature.startsWith("ed25519:")
|
|
92
|
+
|| !verify(null, Buffer.from(revisionPayload(revision, signingKeyId)), publicKey, Buffer.from(encodedSignature, "base64url"))) {
|
|
93
|
+
throw new Error("source signature mismatch");
|
|
94
|
+
}
|
|
95
|
+
return profile;
|
|
96
|
+
},
|
|
97
|
+
catch: (cause) => new TransportIntegrityError({
|
|
98
|
+
artifact: revision.id,
|
|
99
|
+
message: cause instanceof Error
|
|
100
|
+
? cause.message
|
|
101
|
+
: "revision validation failed",
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const sourceMaterial = (record) => ({
|
|
105
|
+
source: record.identity,
|
|
106
|
+
signingKeyReference: record.signingKeyReference,
|
|
107
|
+
tlsKeyReference: record.tlsKeyReference,
|
|
108
|
+
tlsCertificateReference: record.tlsCertificateReference,
|
|
109
|
+
tlsFingerprint: record.tlsFingerprint,
|
|
110
|
+
});
|
|
111
|
+
const repositoryError = (operation) => (error) => {
|
|
112
|
+
if (error instanceof EnrollmentStateConflictError) {
|
|
113
|
+
switch (error.reason) {
|
|
114
|
+
case "invitation-not-found":
|
|
115
|
+
return new InvitationNotFoundError({ message: "the invitation is unknown" });
|
|
116
|
+
case "invitation-used":
|
|
117
|
+
return new InvitationReplayError({ message: "the invitation was already used" });
|
|
118
|
+
case "invitation-expired":
|
|
119
|
+
return new InvitationExpiredError({ message: "the invitation has expired" });
|
|
120
|
+
case "invitation-mismatch":
|
|
121
|
+
return new EnrollmentSourceMismatchError({
|
|
122
|
+
message: "the invitation does not match this source",
|
|
123
|
+
});
|
|
124
|
+
case "follower-identity-conflict":
|
|
125
|
+
case "credential-conflict":
|
|
126
|
+
return new DuplicateFollowerIdentityError({
|
|
127
|
+
message: "the follower identity is already enrolled",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (error instanceof FollowerNotFoundError) {
|
|
132
|
+
return new InvalidFollowerCredentialError({
|
|
133
|
+
message: "the follower credential is invalid",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return new EnrollmentConfigurationError({
|
|
137
|
+
operation,
|
|
138
|
+
message: "durable enrollment state is unavailable",
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const validateEndpoint = (endpoint) => Effect.try({
|
|
142
|
+
try: () => {
|
|
143
|
+
const parsed = new URL(endpoint);
|
|
144
|
+
const loopback = parsed.hostname === "127.0.0.1"
|
|
145
|
+
|| parsed.hostname === "[::1]"
|
|
146
|
+
|| parsed.hostname === "::1";
|
|
147
|
+
if (parsed.protocol !== "https:"
|
|
148
|
+
|| !loopback
|
|
149
|
+
|| parsed.username !== ""
|
|
150
|
+
|| parsed.password !== "") {
|
|
151
|
+
throw new Error("invalid loopback HTTPS endpoint");
|
|
152
|
+
}
|
|
153
|
+
return parsed.origin;
|
|
154
|
+
},
|
|
155
|
+
catch: () => new EnrollmentConfigurationError({
|
|
156
|
+
operation: "create invitation",
|
|
157
|
+
message: "the endpoint must be a loopback HTTPS origin",
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
const makeEnrollment = Effect.gen(function* () {
|
|
161
|
+
const repository = yield* StateRepository;
|
|
162
|
+
const machine = yield* MachineState;
|
|
163
|
+
const maximumRevisionValidationCacheEntries = 1024;
|
|
164
|
+
const maximumAuthorizedBlobIndexEntries = 1024;
|
|
165
|
+
const maximumCandidatesPerAuthorizedBlobIndexEntry = 1024;
|
|
166
|
+
const validatedRevisionCache = new Map();
|
|
167
|
+
const authorizedBlobIndex = new Map();
|
|
168
|
+
let cachedSigningKeys;
|
|
169
|
+
const cacheSet = (cache, key, value, maximumEntries) => {
|
|
170
|
+
cache.delete(key);
|
|
171
|
+
cache.set(key, value);
|
|
172
|
+
while (cache.size > maximumEntries) {
|
|
173
|
+
const oldest = cache.keys().next().value;
|
|
174
|
+
if (oldest === undefined)
|
|
175
|
+
break;
|
|
176
|
+
cache.delete(oldest);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const cachedRevision = Effect.fn("Enrollment.cachedRevision")(function* (revision, signingKeyId, signingKeyVersion, publicKey) {
|
|
180
|
+
const revisionVersion = sha256Hex(canonicalJson(asJson(revision)));
|
|
181
|
+
const cacheKey = [
|
|
182
|
+
revision.id,
|
|
183
|
+
revisionVersion,
|
|
184
|
+
signingKeyId,
|
|
185
|
+
signingKeyVersion,
|
|
186
|
+
].join("\0");
|
|
187
|
+
const cached = validatedRevisionCache.get(cacheKey);
|
|
188
|
+
if (cached !== undefined) {
|
|
189
|
+
cacheSet(validatedRevisionCache, cacheKey, cached, maximumRevisionValidationCacheEntries);
|
|
190
|
+
return cached;
|
|
191
|
+
}
|
|
192
|
+
const profile = yield* validateRevision(revision, signingKeyId, publicKey);
|
|
193
|
+
cacheSet(validatedRevisionCache, cacheKey, profile, maximumRevisionValidationCacheEntries);
|
|
194
|
+
return profile;
|
|
195
|
+
});
|
|
196
|
+
const source = Effect.fn("Enrollment.source")(function* () {
|
|
197
|
+
const stored = yield* repository.getEnrollmentSource().pipe(Effect.mapError(repositoryError("load source identity")));
|
|
198
|
+
if (stored === undefined) {
|
|
199
|
+
return yield* new SourceNotInitializedError({ operation: "load source identity" });
|
|
200
|
+
}
|
|
201
|
+
return sourceMaterial(stored);
|
|
202
|
+
});
|
|
203
|
+
const initializeSource = Effect.fn("Enrollment.initializeSource")(function* () {
|
|
204
|
+
const existing = yield* repository.getEnrollmentSource().pipe(Effect.mapError(repositoryError("load source identity")));
|
|
205
|
+
if (existing !== undefined)
|
|
206
|
+
return sourceMaterial(existing);
|
|
207
|
+
const generated = yield* Effect.tryPromise({
|
|
208
|
+
try: async () => {
|
|
209
|
+
const signing = generateKeyPairSync("ed25519");
|
|
210
|
+
const signingPrivateKey = signing.privateKey.export({
|
|
211
|
+
type: "pkcs8",
|
|
212
|
+
format: "pem",
|
|
213
|
+
}).toString();
|
|
214
|
+
const signingPublicDer = signing.publicKey.export({
|
|
215
|
+
type: "spki",
|
|
216
|
+
format: "der",
|
|
217
|
+
});
|
|
218
|
+
const certificate = await generate([{ name: "commonName", value: "canonfig-loopback" }], {
|
|
219
|
+
algorithm: "sha256",
|
|
220
|
+
keyType: "ec",
|
|
221
|
+
curve: "P-256",
|
|
222
|
+
extensions: [
|
|
223
|
+
{ name: "basicConstraints", cA: false },
|
|
224
|
+
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
|
225
|
+
{ name: "extKeyUsage", serverAuth: true },
|
|
226
|
+
{
|
|
227
|
+
name: "subjectAltName",
|
|
228
|
+
altNames: [
|
|
229
|
+
{ type: 7, ip: "127.0.0.1" },
|
|
230
|
+
{ type: 7, ip: "::1" },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
signingPrivateKey,
|
|
237
|
+
signingFingerprint: sha256(signingPublicDer),
|
|
238
|
+
tlsPrivateKey: certificate.private,
|
|
239
|
+
tlsCertificate: certificate.cert,
|
|
240
|
+
tlsFingerprint: certificateFingerprint(certificate.cert),
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
catch: () => new EnrollmentConfigurationError({
|
|
244
|
+
operation: "generate source identity",
|
|
245
|
+
message: "source cryptographic material could not be generated",
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
const signingKeyReference = yield* machine.storeCredential({
|
|
249
|
+
name: "canonfig-source-signing-key",
|
|
250
|
+
value: Redacted.make(generated.signingPrivateKey),
|
|
251
|
+
}).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
252
|
+
operation: "store source signing key",
|
|
253
|
+
message: "secure credential storage is unavailable",
|
|
254
|
+
})));
|
|
255
|
+
const tlsKeyReference = yield* machine.storeCredential({
|
|
256
|
+
name: "canonfig-source-tls-key",
|
|
257
|
+
value: Redacted.make(generated.tlsPrivateKey),
|
|
258
|
+
}).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
259
|
+
operation: "store source TLS key",
|
|
260
|
+
message: "secure credential storage is unavailable",
|
|
261
|
+
})));
|
|
262
|
+
const tlsCertificateReference = yield* machine.storeCredential({
|
|
263
|
+
name: "canonfig-source-tls-certificate",
|
|
264
|
+
value: Redacted.make(generated.tlsCertificate),
|
|
265
|
+
}).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
266
|
+
operation: "store source TLS certificate",
|
|
267
|
+
message: "secure credential storage is unavailable",
|
|
268
|
+
})));
|
|
269
|
+
const identity = decode(SourceIdentity)({
|
|
270
|
+
keyId: `ed25519:${generated.signingFingerprint}`,
|
|
271
|
+
publicKeyFingerprint: generated.signingFingerprint,
|
|
272
|
+
});
|
|
273
|
+
const record = {
|
|
274
|
+
identity,
|
|
275
|
+
signingKeyReference,
|
|
276
|
+
tlsKeyReference,
|
|
277
|
+
tlsCertificateReference,
|
|
278
|
+
tlsFingerprint: generated.tlsFingerprint,
|
|
279
|
+
};
|
|
280
|
+
yield* repository.saveEnrollmentSource(record).pipe(Effect.mapError(repositoryError("save source identity")));
|
|
281
|
+
return sourceMaterial(record);
|
|
282
|
+
});
|
|
283
|
+
const createInvitation = Effect.fn("Enrollment.createInvitation")(function* (input) {
|
|
284
|
+
const material = yield* source();
|
|
285
|
+
const endpoint = yield* validateEndpoint(input.endpoint);
|
|
286
|
+
if (!Number.isSafeInteger(input.expiresInMilliseconds)
|
|
287
|
+
|| input.expiresInMilliseconds <= 0
|
|
288
|
+
|| input.expiresInMilliseconds > maximumInvitationLifetimeMilliseconds) {
|
|
289
|
+
return yield* new EnrollmentConfigurationError({
|
|
290
|
+
operation: "create invitation",
|
|
291
|
+
message: "invitation lifetime must be between 1 ms and 24 hours",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const groups = yield* Schema.decodeUnknownEffect(Schema.Array(GroupName))(input.groups ?? []).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
295
|
+
operation: "create invitation",
|
|
296
|
+
message: "invitation groups are invalid",
|
|
297
|
+
})));
|
|
298
|
+
const uniqueGroups = [...new Set(groups)];
|
|
299
|
+
const code = decode(InvitationCode)(randomBytes(32).toString("base64url"));
|
|
300
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
301
|
+
const expiresAt = decode(Timestamp)(new Date(Date.now() + input.expiresInMilliseconds).toISOString());
|
|
302
|
+
yield* repository.createEnrollmentInvitation({
|
|
303
|
+
codeDigest: sha256(code),
|
|
304
|
+
nonceDigest: sha256(nonce),
|
|
305
|
+
intendedSourceFingerprint: material.source.publicKeyFingerprint,
|
|
306
|
+
tlsFingerprint: material.tlsFingerprint,
|
|
307
|
+
endpoint,
|
|
308
|
+
groups: uniqueGroups,
|
|
309
|
+
expiresAt,
|
|
310
|
+
}).pipe(Effect.mapError(repositoryError("create invitation")));
|
|
311
|
+
return {
|
|
312
|
+
code,
|
|
313
|
+
nonce,
|
|
314
|
+
endpoint,
|
|
315
|
+
sourceFingerprint: material.source.publicKeyFingerprint,
|
|
316
|
+
tlsFingerprint: material.tlsFingerprint,
|
|
317
|
+
groups: uniqueGroups,
|
|
318
|
+
expiresAt,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
const enrollFollower = Effect.fn("Enrollment.enrollFollower")(function* (request) {
|
|
322
|
+
const material = yield* source();
|
|
323
|
+
if (request.sourceFingerprint !== material.source.publicKeyFingerprint) {
|
|
324
|
+
return yield* new EnrollmentSourceMismatchError({
|
|
325
|
+
message: "the invitation targets a different source identity",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (request.tlsFingerprint !== material.tlsFingerprint) {
|
|
329
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
330
|
+
message: "the pinned TLS fingerprint does not match this source",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const invitation = yield* repository.findEnrollmentInvitation(sha256(request.code)).pipe(Effect.mapError(repositoryError("find invitation")));
|
|
334
|
+
if (invitation === undefined) {
|
|
335
|
+
return yield* new InvitationNotFoundError({ message: "the invitation is unknown" });
|
|
336
|
+
}
|
|
337
|
+
if (invitation.usedAt !== undefined) {
|
|
338
|
+
return yield* new InvitationReplayError({ message: "the invitation was already used" });
|
|
339
|
+
}
|
|
340
|
+
if (Date.parse(invitation.expiresAt) <= Date.now()) {
|
|
341
|
+
return yield* new InvitationExpiredError({ message: "the invitation has expired" });
|
|
342
|
+
}
|
|
343
|
+
const normalizedName = request.followerName.trim().normalize("NFC");
|
|
344
|
+
if (normalizedName.length === 0
|
|
345
|
+
|| normalizedName.length > 128
|
|
346
|
+
|| /\p{Cc}/u.test(normalizedName)) {
|
|
347
|
+
return yield* new EnrollmentConfigurationError({
|
|
348
|
+
operation: "enroll follower",
|
|
349
|
+
message: "follower name is invalid",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const followerId = decode(FollowerId)(`follower-${sha256(`${material.source.publicKeyFingerprint}\0${normalizedName}`).slice(0, 32)}`);
|
|
353
|
+
// Reject a duplicate identity before touching credential storage: the
|
|
354
|
+
// credential key is deterministic per follower identity, so storing first
|
|
355
|
+
// would overwrite and then delete an already enrolled follower's secret.
|
|
356
|
+
const existingCredential = yield* repository.getFollowerCredential(followerId).pipe(Effect.match({
|
|
357
|
+
onFailure: (error) => ({ found: false, error }),
|
|
358
|
+
onSuccess: (record) => ({ found: true, record }),
|
|
359
|
+
}));
|
|
360
|
+
if (existingCredential.found && !existingCredential.record.follower.revoked) {
|
|
361
|
+
return yield* new DuplicateFollowerIdentityError({
|
|
362
|
+
message: "the follower identity is already enrolled",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (!existingCredential.found
|
|
366
|
+
&& !(existingCredential.error instanceof FollowerNotFoundError)) {
|
|
367
|
+
return yield* new EnrollmentConfigurationError({
|
|
368
|
+
operation: "enroll follower",
|
|
369
|
+
message: "durable enrollment state is unavailable",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const credential = randomBytes(32).toString("base64url");
|
|
373
|
+
const previousCredentialReference = existingCredential.found
|
|
374
|
+
? existingCredential.record.credentialReference
|
|
375
|
+
: undefined;
|
|
376
|
+
const credentialReference = yield* machine.storeCredential({
|
|
377
|
+
name: `canonfig-source-follower-${followerId}-${randomUUID()}`,
|
|
378
|
+
value: Redacted.make(credential),
|
|
379
|
+
}).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
380
|
+
operation: "store follower credential",
|
|
381
|
+
message: "secure credential storage is unavailable",
|
|
382
|
+
})));
|
|
383
|
+
const enrolledAt = decode(Timestamp)(new Date().toISOString());
|
|
384
|
+
const follower = decode(FollowerIdentity)({
|
|
385
|
+
id: followerId,
|
|
386
|
+
name: normalizedName,
|
|
387
|
+
groups: invitation.groups,
|
|
388
|
+
revoked: false,
|
|
389
|
+
credentialReference,
|
|
390
|
+
enrolledAt,
|
|
391
|
+
});
|
|
392
|
+
const pendingEnrollments = yield* repository.listPendingEnrollments().pipe(Effect.mapError(repositoryError("load pending enrollment")));
|
|
393
|
+
const pending = pendingEnrollments.find((entry) => entry.codeDigest === sha256(request.code));
|
|
394
|
+
if (pending !== undefined) {
|
|
395
|
+
if (pending.follower !== follower.id) {
|
|
396
|
+
return yield* new InvitationReplayError({
|
|
397
|
+
message: "the invitation was already used",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
// A process may have returned the prepared response before the follower
|
|
401
|
+
// persisted its local configuration. Re-prepare the same one-time
|
|
402
|
+
// invitation instead of leaving a retry permanently blocked.
|
|
403
|
+
yield* repository.cancelPendingEnrollment({
|
|
404
|
+
credentialDigest: pending.credentialDigest,
|
|
405
|
+
}).pipe(Effect.mapError(repositoryError("replace pending enrollment")));
|
|
406
|
+
yield* machine.removeCredential(pending.credentialReference).pipe(Effect.ignore);
|
|
407
|
+
}
|
|
408
|
+
yield* repository.consumeEnrollmentInvitation({
|
|
409
|
+
codeDigest: sha256(request.code),
|
|
410
|
+
nonceDigest: sha256(request.nonce),
|
|
411
|
+
intendedSourceFingerprint: request.sourceFingerprint,
|
|
412
|
+
tlsFingerprint: request.tlsFingerprint,
|
|
413
|
+
follower,
|
|
414
|
+
credentialDigest: sha256(credential),
|
|
415
|
+
credentialReference: decode(CredentialReference)(credentialReference),
|
|
416
|
+
consumedAt: enrolledAt,
|
|
417
|
+
}).pipe(Effect.mapError(repositoryError("consume invitation")), Effect.tapError(() => machine.removeCredential(credentialReference).pipe(Effect.ignore)));
|
|
418
|
+
if (previousCredentialReference !== undefined
|
|
419
|
+
&& previousCredentialReference !== credentialReference) {
|
|
420
|
+
yield* machine.removeCredential(previousCredentialReference).pipe(Effect.ignore);
|
|
421
|
+
}
|
|
422
|
+
const authorizedProfiles = yield* repository.listRevisions().pipe(Effect.mapError(repositoryError("list authorized profiles")), Effect.map((revisions) => revisions.map((revision) => ({
|
|
423
|
+
id: revision.id,
|
|
424
|
+
profileId: revision.profileId,
|
|
425
|
+
sequence: revision.sequence,
|
|
426
|
+
digest: decode(ContentDigest)(revision.digest),
|
|
427
|
+
publishedAt: revision.publishedAt,
|
|
428
|
+
}))));
|
|
429
|
+
return {
|
|
430
|
+
follower,
|
|
431
|
+
credential,
|
|
432
|
+
source: material.source,
|
|
433
|
+
tlsFingerprint: material.tlsFingerprint,
|
|
434
|
+
authorizedProfiles,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
const finalizeFollower = Effect.fn("Enrollment.finalizeFollower")(function* (credential) {
|
|
438
|
+
const credentialDigest = sha256(credential);
|
|
439
|
+
const pending = yield* repository.listPendingEnrollments().pipe(Effect.mapError(repositoryError("find pending enrollment")));
|
|
440
|
+
const pendingEnrollment = pending.find((entry) => entry.credentialDigest === credentialDigest);
|
|
441
|
+
if (pendingEnrollment === undefined) {
|
|
442
|
+
const stored = yield* repository.findFollowerCredential(credentialDigest).pipe(Effect.mapError(repositoryError("finalize follower enrollment")));
|
|
443
|
+
if (stored === undefined || stored.follower.revoked) {
|
|
444
|
+
return yield* new InvalidFollowerCredentialError({
|
|
445
|
+
message: "the follower credential is invalid",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
yield* repository.finalizeEnrollment({
|
|
451
|
+
follower: pendingEnrollment.follower,
|
|
452
|
+
credentialDigest,
|
|
453
|
+
credentialReference: pendingEnrollment.credentialReference,
|
|
454
|
+
}).pipe(Effect.mapError(repositoryError("finalize follower enrollment")));
|
|
455
|
+
});
|
|
456
|
+
const cancelPendingEnrollment = Effect.fn("Enrollment.cancelPendingEnrollment")(function* (credential) {
|
|
457
|
+
yield* repository.cancelPendingEnrollment({
|
|
458
|
+
credentialDigest: sha256(credential),
|
|
459
|
+
}).pipe(Effect.mapError(repositoryError("cancel pending enrollment")));
|
|
460
|
+
});
|
|
461
|
+
const revokeAuthenticatedFollower = Effect.fn("Enrollment.revokeAuthenticatedFollower")(function* (credential) {
|
|
462
|
+
const authenticated = yield* authenticate(credential);
|
|
463
|
+
yield* repository.revokeFollower(authenticated.follower.id).pipe(Effect.mapError(repositoryError("revoke follower")));
|
|
464
|
+
});
|
|
465
|
+
const authenticate = Effect.fn("Enrollment.authenticate")(function* (credential) {
|
|
466
|
+
if (credential.length < 32 || credential.length > 512) {
|
|
467
|
+
return yield* new InvalidFollowerCredentialError({
|
|
468
|
+
message: "the follower credential is invalid",
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const stored = yield* repository.findFollowerCredential(sha256(credential)).pipe(Effect.mapError(repositoryError("authenticate follower")));
|
|
472
|
+
if (stored === undefined) {
|
|
473
|
+
return yield* new InvalidFollowerCredentialError({
|
|
474
|
+
message: "the follower credential is invalid",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (stored.follower.revoked) {
|
|
478
|
+
return yield* new RevokedFollowerCredentialError({
|
|
479
|
+
message: "the follower credential has been revoked",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return { follower: stored.follower };
|
|
483
|
+
});
|
|
484
|
+
const signingKeys = Effect.fn("Enrollment.signingKeys")(function* () {
|
|
485
|
+
const material = yield* source();
|
|
486
|
+
const cacheKey = [
|
|
487
|
+
material.source.keyId,
|
|
488
|
+
material.source.publicKeyFingerprint,
|
|
489
|
+
material.signingKeyReference,
|
|
490
|
+
].join("\0");
|
|
491
|
+
if (cachedSigningKeys?.cacheKey === cacheKey) {
|
|
492
|
+
return cachedSigningKeys.value;
|
|
493
|
+
}
|
|
494
|
+
const stored = yield* machine.loadCredential({
|
|
495
|
+
reference: material.signingKeyReference,
|
|
496
|
+
}).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
497
|
+
operation: "load source signing key",
|
|
498
|
+
message: "source signing credentials are unavailable",
|
|
499
|
+
})));
|
|
500
|
+
const privateKey = yield* Effect.try({
|
|
501
|
+
try: () => createPrivateKey(Redacted.value(stored)),
|
|
502
|
+
catch: () => new EnrollmentConfigurationError({
|
|
503
|
+
operation: "decode source signing key",
|
|
504
|
+
message: "source signing credentials are invalid",
|
|
505
|
+
}),
|
|
506
|
+
});
|
|
507
|
+
const publicKey = createPublicKey(privateKey);
|
|
508
|
+
const fingerprint = sha256BytesHex(publicKey.export({
|
|
509
|
+
type: "spki",
|
|
510
|
+
format: "der",
|
|
511
|
+
}));
|
|
512
|
+
if (String(fingerprint) !== String(material.source.publicKeyFingerprint)) {
|
|
513
|
+
return yield* new TransportIntegrityError({
|
|
514
|
+
artifact: "source-signing-key",
|
|
515
|
+
message: "source signing key fingerprint mismatch",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const value = {
|
|
519
|
+
material,
|
|
520
|
+
privateKey,
|
|
521
|
+
publicKey,
|
|
522
|
+
publicPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
523
|
+
};
|
|
524
|
+
cachedSigningKeys = { cacheKey, value };
|
|
525
|
+
return value;
|
|
526
|
+
});
|
|
527
|
+
const authorizedRevisions = Effect.fn("Enrollment.authorizedRevisions")(function* (credential) {
|
|
528
|
+
const authenticated = yield* authenticate(credential);
|
|
529
|
+
const revisions = yield* repository.listRevisions().pipe(Effect.mapError(repositoryError("list authorized revisions")));
|
|
530
|
+
const groups = new Set(authenticated.follower.groups);
|
|
531
|
+
return revisions
|
|
532
|
+
.map((revision) => {
|
|
533
|
+
const visibleIds = new Set(revision.resources
|
|
534
|
+
.filter((resource) => resourceIsAuthorized(resource, groups))
|
|
535
|
+
.map((resource) => resource.id));
|
|
536
|
+
// Authorization is a projection of the signed revision, not a
|
|
537
|
+
// dependency rewrite. Remove every dependent whose complete
|
|
538
|
+
// dependency closure is not visible, including transitive
|
|
539
|
+
// dependents. This deliberately fails closed instead of allowing a
|
|
540
|
+
// follower to plan against an incomplete resource graph.
|
|
541
|
+
let changed = true;
|
|
542
|
+
while (changed) {
|
|
543
|
+
changed = false;
|
|
544
|
+
for (const resource of revision.resources) {
|
|
545
|
+
if (visibleIds.has(resource.id)
|
|
546
|
+
&& resource.dependsOn.some((dependency) => !visibleIds.has(dependency))) {
|
|
547
|
+
visibleIds.delete(resource.id);
|
|
548
|
+
changed = true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const resources = revision.resources
|
|
553
|
+
.filter((resource) => visibleIds.has(resource.id))
|
|
554
|
+
.map((resource) => {
|
|
555
|
+
const base = {
|
|
556
|
+
...resource,
|
|
557
|
+
dependsOn: resource.dependsOn.filter((dependency) => visibleIds.has(dependency)),
|
|
558
|
+
};
|
|
559
|
+
if (resource.groups === undefined)
|
|
560
|
+
return base;
|
|
561
|
+
return {
|
|
562
|
+
...base,
|
|
563
|
+
groups: resource.groups.filter((group) => groups.has(group)),
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
return { revision, resources };
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
const listAuthorizedRevisions = Effect.fn("Enrollment.listAuthorizedRevisions")(function* (credential) {
|
|
570
|
+
const revisions = yield* authorizedRevisions(credential);
|
|
571
|
+
return {
|
|
572
|
+
revisions: revisions.map(({ revision }) => ({
|
|
573
|
+
id: revision.id,
|
|
574
|
+
profileId: revision.profileId,
|
|
575
|
+
sequence: revision.sequence,
|
|
576
|
+
digest: decode(ContentDigest)(revision.digest),
|
|
577
|
+
publishedAt: revision.publishedAt,
|
|
578
|
+
})),
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
const getAuthorizedRevision = Effect.fn("Enrollment.getAuthorizedRevision")(function* (credential, revisionId) {
|
|
582
|
+
const revisions = yield* authorizedRevisions(credential);
|
|
583
|
+
const selected = revisions.find(({ revision }) => revision.id === revisionId);
|
|
584
|
+
if (selected === undefined) {
|
|
585
|
+
return yield* new TransportResourceNotFoundError({
|
|
586
|
+
resource: "revision",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const keys = yield* signingKeys();
|
|
590
|
+
const profile = yield* cachedRevision(selected.revision, keys.material.source.keyId, keys.material.source.publicKeyFingerprint, keys.publicKey);
|
|
591
|
+
const authoredById = new Map(profile.resources.map((resource) => [
|
|
592
|
+
resource.id,
|
|
593
|
+
resource,
|
|
594
|
+
]));
|
|
595
|
+
const resources = decode(Schema.Array(TransportPublishedResourceSchema))(selected.resources.map((resource) => {
|
|
596
|
+
const authored = authoredById.get(resource.id);
|
|
597
|
+
if (authored === undefined) {
|
|
598
|
+
throw new TransportIntegrityError({
|
|
599
|
+
artifact: resource.id,
|
|
600
|
+
message: "authorized resource has no canonical verification contract",
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return { ...resource, verify: authored.verify };
|
|
604
|
+
}));
|
|
605
|
+
const unsigned = {
|
|
606
|
+
id: selected.revision.id,
|
|
607
|
+
profileId: selected.revision.profileId,
|
|
608
|
+
sequence: selected.revision.sequence,
|
|
609
|
+
digest: decode(ContentDigest)(selected.revision.digest),
|
|
610
|
+
publishedAt: selected.revision.publishedAt,
|
|
611
|
+
resources,
|
|
612
|
+
scheduleDefault: profile.scheduleDefault,
|
|
613
|
+
signingKeyId: keys.material.source.keyId,
|
|
614
|
+
signingPublicKey: keys.publicPem,
|
|
615
|
+
sourceSignature: selected.revision.signature,
|
|
616
|
+
};
|
|
617
|
+
const metadataDigest = digestOf(asJson(unsigned));
|
|
618
|
+
const signature = `ed25519:${sign(null, Buffer.from(canonicalJson(asJson({ ...unsigned, metadataDigest }))), keys.privateKey).toString("base64url")}`;
|
|
619
|
+
const metadata = {
|
|
620
|
+
...unsigned,
|
|
621
|
+
metadataDigest,
|
|
622
|
+
signature,
|
|
623
|
+
};
|
|
624
|
+
return metadata;
|
|
625
|
+
});
|
|
626
|
+
const getAuthorizedBlob = Effect.fn("Enrollment.getAuthorizedBlob")(function* (credential, blobId) {
|
|
627
|
+
const authenticated = yield* authenticate(credential);
|
|
628
|
+
const blob = decode(ContentDigest)(blobId);
|
|
629
|
+
const groups = new Set(authenticated.follower.groups);
|
|
630
|
+
const candidates = yield* repository.listRevisionBlobCandidates(blob).pipe(Effect.mapError(repositoryError("list authorized blob candidates")));
|
|
631
|
+
const candidateFingerprint = candidates.map((candidate) => `${candidate.revision}\0${candidate.resource}`).join("\n");
|
|
632
|
+
const scope = [
|
|
633
|
+
blob,
|
|
634
|
+
[...groups].sort().join("\0"),
|
|
635
|
+
].join("\0");
|
|
636
|
+
const cached = candidates.length <= maximumCandidatesPerAuthorizedBlobIndexEntry
|
|
637
|
+
? authorizedBlobIndex.get(scope)
|
|
638
|
+
: undefined;
|
|
639
|
+
const keys = yield* signingKeys();
|
|
640
|
+
let entries = cached?.candidateFingerprint === candidateFingerprint
|
|
641
|
+
? cached.entries
|
|
642
|
+
: undefined;
|
|
643
|
+
if (entries === undefined) {
|
|
644
|
+
const next = [];
|
|
645
|
+
for (const candidate of candidates) {
|
|
646
|
+
const revision = yield* repository.getRevision(candidate.revision).pipe(Effect.mapError(repositoryError("load authorized blob revision")));
|
|
647
|
+
const profile = yield* cachedRevision(revision, keys.material.source.keyId, keys.material.source.publicKeyFingerprint, keys.publicKey);
|
|
648
|
+
const resource = visibleResources(revision, groups).find((item) => item.id === candidate.resource
|
|
649
|
+
&& item.blobs.some((candidateBlob) => candidateBlob === blob));
|
|
650
|
+
if (resource === undefined)
|
|
651
|
+
continue;
|
|
652
|
+
if (!profile.resources.some((item) => item.id === resource.id)) {
|
|
653
|
+
return yield* new TransportIntegrityError({
|
|
654
|
+
artifact: blobId,
|
|
655
|
+
message: "authorized blob has no canonical resource",
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
next.push({
|
|
659
|
+
revision: candidate.revision,
|
|
660
|
+
resource: candidate.resource,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
entries = next;
|
|
664
|
+
if (candidates.length <= maximumCandidatesPerAuthorizedBlobIndexEntry) {
|
|
665
|
+
cacheSet(authorizedBlobIndex, scope, { candidateFingerprint, entries }, maximumAuthorizedBlobIndexEntries);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
for (const entry of entries) {
|
|
669
|
+
const revision = yield* repository.getRevision(entry.revision).pipe(Effect.mapError(repositoryError("load authorized blob revision")));
|
|
670
|
+
const profile = yield* cachedRevision(revision, keys.material.source.keyId, keys.material.source.publicKeyFingerprint, keys.publicKey);
|
|
671
|
+
const resource = visibleResources(revision, groups).find((item) => item.id === entry.resource
|
|
672
|
+
&& item.blobs.some((candidate) => candidate === blob));
|
|
673
|
+
if (resource === undefined)
|
|
674
|
+
continue;
|
|
675
|
+
const authored = profile.resources.find((item) => item.id === resource.id);
|
|
676
|
+
if (authored === undefined) {
|
|
677
|
+
return yield* new TransportIntegrityError({
|
|
678
|
+
artifact: blobId,
|
|
679
|
+
message: "authorized blob has no canonical resource",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const bytes = Buffer.from(canonicalJson(asJson(authored.spec)));
|
|
683
|
+
if (sha256BytesHex(bytes) !== blobId) {
|
|
684
|
+
return yield* new TransportIntegrityError({
|
|
685
|
+
artifact: blobId,
|
|
686
|
+
message: "canonical blob digest mismatch",
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
return bytes;
|
|
690
|
+
}
|
|
691
|
+
return yield* new TransportResourceNotFoundError({ resource: "blob" });
|
|
692
|
+
});
|
|
693
|
+
const revokeFollower = Effect.fn("Enrollment.revokeFollower")(function* (follower) {
|
|
694
|
+
yield* repository.revokeFollower(follower).pipe(Effect.mapError(repositoryError("revoke follower")));
|
|
695
|
+
});
|
|
696
|
+
const updateFollowerGroups = Effect.fn("Enrollment.updateFollowerGroups")(function* (follower, groups) {
|
|
697
|
+
const validated = yield* Schema.decodeUnknownEffect(Schema.Array(GroupName))(groups).pipe(Effect.mapError(() => new EnrollmentConfigurationError({
|
|
698
|
+
operation: "update follower groups",
|
|
699
|
+
message: "follower groups are invalid",
|
|
700
|
+
})));
|
|
701
|
+
yield* repository.updateFollowerGroups(follower, [...new Set(validated)]).pipe(Effect.mapError(repositoryError("update follower groups")));
|
|
702
|
+
});
|
|
703
|
+
const getFollower = Effect.fn("Enrollment.getFollower")(function* (follower) {
|
|
704
|
+
const stored = yield* repository.getFollowerCredential(follower).pipe(Effect.mapError(repositoryError("get follower")));
|
|
705
|
+
return stored.follower;
|
|
706
|
+
});
|
|
707
|
+
return Enrollment.of({
|
|
708
|
+
initializeSource,
|
|
709
|
+
source,
|
|
710
|
+
createInvitation,
|
|
711
|
+
enrollFollower,
|
|
712
|
+
finalizeFollower,
|
|
713
|
+
cancelPendingEnrollment,
|
|
714
|
+
revokeAuthenticatedFollower,
|
|
715
|
+
authenticate,
|
|
716
|
+
listAuthorizedRevisions,
|
|
717
|
+
getAuthorizedRevision,
|
|
718
|
+
getAuthorizedBlob,
|
|
719
|
+
revokeFollower,
|
|
720
|
+
updateFollowerGroups,
|
|
721
|
+
getFollower,
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
export const EnrollmentLive = Layer.effect(Enrollment, makeEnrollment);
|