@echovisionlab/geul-event 0.1.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.md +6 -0
- package/README.md +6 -0
- package/dist/index.js +358 -0
- package/package.json +51 -0
- package/src/block_catalog.test.ts +1164 -0
- package/src/campaign-create-target-contract.test.ts +36 -0
- package/src/collaboration-locale-value-changes.test.ts +51 -0
- package/src/content-entity-type.test.ts +74 -0
- package/src/event-model.test.ts +125 -0
- package/src/form-locale-catalog.test.ts +67 -0
- package/src/generated.ts +59 -0
- package/src/index.ts +86 -0
- package/src/interactive-mutation.test.ts +287 -0
- package/src/interactive-mutation.ts +136 -0
- package/src/pgmq.test.ts +80 -0
- package/src/pgmq.ts +216 -0
- package/src/proto-helpers.test.ts +103 -0
- package/src/proto-helpers.ts +62 -0
- package/src/spicedb-policy.test.ts +226 -0
- package/src/translation-source.test.ts +28 -0
- package/src/translation-source.ts +15 -0
- package/src/user-delete-identity-mode.test.ts +34 -0
package/src/pgmq.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
export const PGMQ_ENVELOPE_SCHEMA_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
export type PgmqEnvelope = {
|
|
4
|
+
message_id: string;
|
|
5
|
+
message_type: string;
|
|
6
|
+
schema_version: typeof PGMQ_ENVELOPE_SCHEMA_VERSION;
|
|
7
|
+
created_at: string;
|
|
8
|
+
correlation_id?: string;
|
|
9
|
+
causation_id?: string;
|
|
10
|
+
payload_base64: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PgmqHeaders = Record<string, string>;
|
|
14
|
+
|
|
15
|
+
type QueryResult<Row> = { rows: Row[] };
|
|
16
|
+
|
|
17
|
+
export interface PgmqExecutor {
|
|
18
|
+
query<Row extends Record<string, unknown>>(
|
|
19
|
+
text: string,
|
|
20
|
+
values?: readonly unknown[],
|
|
21
|
+
): Promise<QueryResult<Row>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PgmqMessage = {
|
|
25
|
+
transportId: bigint;
|
|
26
|
+
readCount: number;
|
|
27
|
+
enqueuedAt: Date;
|
|
28
|
+
visibleAt: Date;
|
|
29
|
+
envelope?: PgmqEnvelope;
|
|
30
|
+
headers: PgmqHeaders;
|
|
31
|
+
/** Present when the row was read but violates the shared transport contract. */
|
|
32
|
+
contractError?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function encodeBase64(payload: Uint8Array): string {
|
|
36
|
+
return Buffer.from(payload).toString("base64");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decodeBase64(payload: string): Uint8Array {
|
|
40
|
+
return new Uint8Array(Buffer.from(payload, "base64"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createPgmqEnvelope(input: {
|
|
44
|
+
messageId: string;
|
|
45
|
+
messageType: string;
|
|
46
|
+
payload: Uint8Array;
|
|
47
|
+
correlationId?: string;
|
|
48
|
+
causationId?: string;
|
|
49
|
+
createdAt?: Date;
|
|
50
|
+
}): PgmqEnvelope {
|
|
51
|
+
const envelope: PgmqEnvelope = {
|
|
52
|
+
message_id: input.messageId,
|
|
53
|
+
message_type: input.messageType,
|
|
54
|
+
schema_version: PGMQ_ENVELOPE_SCHEMA_VERSION,
|
|
55
|
+
created_at: (input.createdAt ?? new Date()).toISOString(),
|
|
56
|
+
payload_base64: encodeBase64(input.payload),
|
|
57
|
+
...(input.correlationId ? { correlation_id: input.correlationId } : {}),
|
|
58
|
+
...(input.causationId ? { causation_id: input.causationId } : {}),
|
|
59
|
+
};
|
|
60
|
+
assertPgmqEnvelope(envelope);
|
|
61
|
+
return envelope;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function assertPgmqEnvelope(
|
|
65
|
+
value: unknown,
|
|
66
|
+
): asserts value is PgmqEnvelope {
|
|
67
|
+
if (!value || typeof value !== "object") {
|
|
68
|
+
throw new Error("PGMQ envelope must be an object");
|
|
69
|
+
}
|
|
70
|
+
const envelope = value as Partial<PgmqEnvelope>;
|
|
71
|
+
if (!envelope.message_id || typeof envelope.message_id !== "string") {
|
|
72
|
+
throw new Error("PGMQ envelope message_id is required");
|
|
73
|
+
}
|
|
74
|
+
if (!envelope.message_type || typeof envelope.message_type !== "string") {
|
|
75
|
+
throw new Error("PGMQ envelope message_type is required");
|
|
76
|
+
}
|
|
77
|
+
if (envelope.schema_version !== PGMQ_ENVELOPE_SCHEMA_VERSION) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Unsupported PGMQ envelope schema ${envelope.schema_version}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
typeof envelope.created_at !== "string" ||
|
|
84
|
+
Number.isNaN(Date.parse(envelope.created_at))
|
|
85
|
+
) {
|
|
86
|
+
throw new Error("PGMQ envelope created_at must be an ISO timestamp");
|
|
87
|
+
}
|
|
88
|
+
if (typeof envelope.payload_base64 !== "string") {
|
|
89
|
+
throw new Error("PGMQ envelope payload_base64 is required");
|
|
90
|
+
}
|
|
91
|
+
const normalized = Buffer.from(envelope.payload_base64, "base64").toString(
|
|
92
|
+
"base64",
|
|
93
|
+
);
|
|
94
|
+
if (normalized !== envelope.payload_base64) {
|
|
95
|
+
throw new Error("PGMQ envelope payload_base64 is invalid");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function pgmqEnvelopePayload(envelope: PgmqEnvelope): Uint8Array {
|
|
100
|
+
assertPgmqEnvelope(envelope);
|
|
101
|
+
return decodeBase64(envelope.payload_base64);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class PgmqClient {
|
|
105
|
+
async enqueue(
|
|
106
|
+
executor: PgmqExecutor,
|
|
107
|
+
queue: string,
|
|
108
|
+
envelope: PgmqEnvelope,
|
|
109
|
+
options: { headers?: PgmqHeaders; delaySeconds?: number } = {},
|
|
110
|
+
): Promise<bigint> {
|
|
111
|
+
assertPgmqEnvelope(envelope);
|
|
112
|
+
if (!queue) throw new Error("PGMQ queue is required");
|
|
113
|
+
const delaySeconds = Math.max(0, Math.trunc(options.delaySeconds ?? 0));
|
|
114
|
+
const result = await executor.query<{ msg_id: string | number | bigint }>(
|
|
115
|
+
"SELECT pgmq.send($1, $2::jsonb, $3::jsonb, $4::integer) AS msg_id",
|
|
116
|
+
[queue, envelope, options.headers ?? {}, delaySeconds],
|
|
117
|
+
);
|
|
118
|
+
const row = result.rows[0];
|
|
119
|
+
if (!row) throw new Error(`PGMQ send returned no message for ${queue}`);
|
|
120
|
+
return BigInt(row.msg_id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async read(
|
|
124
|
+
executor: PgmqExecutor,
|
|
125
|
+
queue: string,
|
|
126
|
+
options: { visibilityTimeoutSeconds: number; batch?: number },
|
|
127
|
+
): Promise<PgmqMessage[]> {
|
|
128
|
+
if (!queue) throw new Error("PGMQ queue is required");
|
|
129
|
+
const visibilityTimeoutSeconds = Math.trunc(
|
|
130
|
+
options.visibilityTimeoutSeconds,
|
|
131
|
+
);
|
|
132
|
+
const batch = Math.trunc(options.batch ?? 1);
|
|
133
|
+
if (visibilityTimeoutSeconds <= 0) {
|
|
134
|
+
throw new Error("PGMQ visibility timeout must be positive");
|
|
135
|
+
}
|
|
136
|
+
if (batch <= 0) throw new Error("PGMQ batch must be positive");
|
|
137
|
+
const result = await executor.query<{
|
|
138
|
+
msg_id: string | number | bigint;
|
|
139
|
+
read_ct: number;
|
|
140
|
+
enqueued_at: Date | string;
|
|
141
|
+
vt: Date | string;
|
|
142
|
+
message: unknown;
|
|
143
|
+
headers: PgmqHeaders | null;
|
|
144
|
+
}>(
|
|
145
|
+
"SELECT msg_id, read_ct, enqueued_at, vt, message, headers FROM pgmq.read($1, $2::integer, $3::integer, '{}'::jsonb)",
|
|
146
|
+
[queue, visibilityTimeoutSeconds, batch],
|
|
147
|
+
);
|
|
148
|
+
return result.rows.map((row) => {
|
|
149
|
+
const message = {
|
|
150
|
+
transportId: BigInt(row.msg_id),
|
|
151
|
+
readCount: row.read_ct,
|
|
152
|
+
enqueuedAt: new Date(row.enqueued_at),
|
|
153
|
+
visibleAt: new Date(row.vt),
|
|
154
|
+
headers: row.headers ?? {},
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
assertPgmqEnvelope(row.message);
|
|
158
|
+
return { ...message, envelope: row.message };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
...message,
|
|
162
|
+
contractError:
|
|
163
|
+
error instanceof Error ? error.message : "Invalid PGMQ envelope",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async complete(
|
|
170
|
+
executor: PgmqExecutor,
|
|
171
|
+
queue: string,
|
|
172
|
+
transportId: bigint,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
await this.booleanOperation(executor, "delete", queue, transportId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async retry(
|
|
178
|
+
executor: PgmqExecutor,
|
|
179
|
+
queue: string,
|
|
180
|
+
transportId: bigint,
|
|
181
|
+
delaySeconds: number,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const result = await executor.query<{ msg_id: string | number | bigint }>(
|
|
184
|
+
"SELECT msg_id FROM pgmq.set_vt($1, $2::bigint, $3::integer)",
|
|
185
|
+
[queue, transportId.toString(), Math.max(0, Math.trunc(delaySeconds))],
|
|
186
|
+
);
|
|
187
|
+
if (BigInt(result.rows[0]?.msg_id ?? -1) !== transportId) {
|
|
188
|
+
throw new Error(`PGMQ retry did not update ${queue}/${transportId}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async deadLetter(
|
|
193
|
+
executor: PgmqExecutor,
|
|
194
|
+
queue: string,
|
|
195
|
+
transportId: bigint,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
await this.booleanOperation(executor, "archive", queue, transportId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async booleanOperation(
|
|
201
|
+
executor: PgmqExecutor,
|
|
202
|
+
operation: "delete" | "archive",
|
|
203
|
+
queue: string,
|
|
204
|
+
transportId: bigint,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const result = await executor.query<{ succeeded: boolean }>(
|
|
207
|
+
`SELECT pgmq.${operation}($1, $2::bigint) AS succeeded`,
|
|
208
|
+
[queue, transportId.toString()],
|
|
209
|
+
);
|
|
210
|
+
if (result.rows[0]?.succeeded !== true) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`PGMQ ${operation} did not update ${queue}/${transportId}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { create, toBinary } from "@bufbuild/protobuf";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
OgGenerationLifecycleEventSchema,
|
|
5
|
+
UserDeleteIdentityCommandSchema,
|
|
6
|
+
} from "@echovisionlab/geul-proto/secure/events_pb.ts";
|
|
7
|
+
import {
|
|
8
|
+
deserializeProto,
|
|
9
|
+
FileIngestAttachedEventSchema,
|
|
10
|
+
FileIngestIdentitySchema,
|
|
11
|
+
FileIngestMediaKind,
|
|
12
|
+
TranscodeEntityType,
|
|
13
|
+
} from "./proto-helpers.ts";
|
|
14
|
+
|
|
15
|
+
describe("deserializeProto", () => {
|
|
16
|
+
it("decodes a generated protobuf message", () => {
|
|
17
|
+
const source = create(OgGenerationLifecycleEventSchema, {
|
|
18
|
+
generationId: "generation-1",
|
|
19
|
+
runId: "run-1",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(
|
|
23
|
+
deserializeProto(
|
|
24
|
+
OgGenerationLifecycleEventSchema,
|
|
25
|
+
toBinary(OgGenerationLifecycleEventSchema, source),
|
|
26
|
+
),
|
|
27
|
+
).toEqual(source);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("preserves Track first-attach absence and replacement CAS identity", () => {
|
|
31
|
+
const firstAttach = create(FileIngestIdentitySchema, {
|
|
32
|
+
entityType: TranscodeEntityType.TRACK,
|
|
33
|
+
entityId: "track-1",
|
|
34
|
+
fileId: "file-new",
|
|
35
|
+
mediaKind: FileIngestMediaKind.TRACK_AUDIO,
|
|
36
|
+
});
|
|
37
|
+
const firstDecoded = deserializeProto(
|
|
38
|
+
FileIngestIdentitySchema,
|
|
39
|
+
toBinary(FileIngestIdentitySchema, firstAttach),
|
|
40
|
+
);
|
|
41
|
+
expect(firstDecoded.expectedCurrentFileId).toBeUndefined();
|
|
42
|
+
|
|
43
|
+
const replacement = create(FileIngestIdentitySchema, {
|
|
44
|
+
entityType: TranscodeEntityType.TRACK,
|
|
45
|
+
entityId: "track-1",
|
|
46
|
+
fileId: "file-new",
|
|
47
|
+
mediaKind: FileIngestMediaKind.TRACK_AUDIO,
|
|
48
|
+
expectedCurrentFileId: "file-current",
|
|
49
|
+
});
|
|
50
|
+
const replacementDecoded = deserializeProto(
|
|
51
|
+
FileIngestIdentitySchema,
|
|
52
|
+
toBinary(FileIngestIdentitySchema, replacement),
|
|
53
|
+
);
|
|
54
|
+
expect(replacementDecoded.expectedCurrentFileId).toBe("file-current");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each([
|
|
58
|
+
{ name: "first attach", expectedCurrentFileId: undefined },
|
|
59
|
+
{ name: "replacement", expectedCurrentFileId: "file-current" },
|
|
60
|
+
])("round-trips a $name attached event", ({ expectedCurrentFileId }) => {
|
|
61
|
+
const source = create(FileIngestAttachedEventSchema, {
|
|
62
|
+
correlationId: `correlation-${expectedCurrentFileId ?? "first"}`,
|
|
63
|
+
identity: {
|
|
64
|
+
entityType: TranscodeEntityType.TRACK,
|
|
65
|
+
entityId: "track-1",
|
|
66
|
+
fileId: "file-new",
|
|
67
|
+
mediaKind: FileIngestMediaKind.TRACK_AUDIO,
|
|
68
|
+
expectedCurrentFileId,
|
|
69
|
+
},
|
|
70
|
+
sequenceNumber: 1n,
|
|
71
|
+
timestampMs: 1782864000000n,
|
|
72
|
+
fileName: "field-recording.wav",
|
|
73
|
+
mimeType: "audio/wav",
|
|
74
|
+
fileSize: 1024n,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const decoded = deserializeProto(
|
|
78
|
+
FileIngestAttachedEventSchema,
|
|
79
|
+
toBinary(FileIngestAttachedEventSchema, source),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(decoded).toEqual(source);
|
|
83
|
+
expect(decoded.identity?.expectedCurrentFileId).toBe(expectedCurrentFileId);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("preserves the immutable account-deletion identity/member pair", () => {
|
|
87
|
+
const source = create(UserDeleteIdentityCommandSchema, {
|
|
88
|
+
memberId: "14600000-0000-0000-0000-000000000001",
|
|
89
|
+
identityId: "14600000-0000-0000-0000-000000000003",
|
|
90
|
+
avatarAssetId: "14600000-0000-0000-0000-000000000002",
|
|
91
|
+
notificationEmail: "member@example.test",
|
|
92
|
+
notificationName: "Member",
|
|
93
|
+
notificationLocale: "ko",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
deserializeProto(
|
|
98
|
+
UserDeleteIdentityCommandSchema,
|
|
99
|
+
toBinary(UserDeleteIdentityCommandSchema, source),
|
|
100
|
+
),
|
|
101
|
+
).toEqual(source);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fromBinary, type Message } from "@bufbuild/protobuf";
|
|
2
|
+
import type { GenMessage } from "@bufbuild/protobuf/codegenv2";
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
AssetRef,
|
|
6
|
+
AssetWriteResult,
|
|
7
|
+
AssetWriteTarget,
|
|
8
|
+
} from "@echovisionlab/geul-proto/common/media_pb.ts";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
AssetDisposition,
|
|
12
|
+
MediaProcessingStatus,
|
|
13
|
+
} from "@echovisionlab/geul-proto/common/media_pb.ts";
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
FileIngestAttachedEvent,
|
|
17
|
+
FileIngestDownloadEvent,
|
|
18
|
+
FileIngestFailedEvent,
|
|
19
|
+
FileIngestFinalizedEvent,
|
|
20
|
+
FileIngestIdentity,
|
|
21
|
+
FileIngestProgress,
|
|
22
|
+
FileIngestUploadEvent,
|
|
23
|
+
MediaProcessingLifecycleEvent,
|
|
24
|
+
MediaProcessingLifecycleOutputs,
|
|
25
|
+
MeshOptimizationResultEvent,
|
|
26
|
+
OgGenerationJob,
|
|
27
|
+
OgGenerationLifecycleEvent,
|
|
28
|
+
OgGenerationTarget,
|
|
29
|
+
TranscodeCompleteEvent,
|
|
30
|
+
TranslationLifecycleEvent,
|
|
31
|
+
WaveformResultEvent,
|
|
32
|
+
} from "@echovisionlab/geul-proto/secure/events_pb.ts";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
FileIngestAttachedEventSchema,
|
|
36
|
+
FileIngestDownloadEventSchema,
|
|
37
|
+
FileIngestFailedEventSchema,
|
|
38
|
+
FileIngestFailureReason,
|
|
39
|
+
FileIngestFinalizedEventSchema,
|
|
40
|
+
FileIngestIdentitySchema,
|
|
41
|
+
FileIngestMediaKind,
|
|
42
|
+
FileIngestSource,
|
|
43
|
+
FileIngestUploadEventSchema,
|
|
44
|
+
MediaProcessingLifecycleEventSchema,
|
|
45
|
+
MeshOptimizationResultEventSchema,
|
|
46
|
+
OgEntityType,
|
|
47
|
+
OgGenerationJobSchema,
|
|
48
|
+
OgGenerationLifecycleEventSchema,
|
|
49
|
+
OgGenerationStatus,
|
|
50
|
+
OgGenerationTargetSchema,
|
|
51
|
+
TranscodeCompleteEventSchema,
|
|
52
|
+
TranscodeEntityType,
|
|
53
|
+
TranslationLifecycleEventSchema,
|
|
54
|
+
WaveformResultEventSchema,
|
|
55
|
+
} from "@echovisionlab/geul-proto/secure/events_pb.ts";
|
|
56
|
+
|
|
57
|
+
export function deserializeProto<T extends Message>(
|
|
58
|
+
schema: GenMessage<T>,
|
|
59
|
+
data: Uint8Array,
|
|
60
|
+
): T {
|
|
61
|
+
return fromBinary(schema, data);
|
|
62
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Artist } from "@echovisionlab/geul-proto/policy/spicedb/artist.ts";
|
|
4
|
+
import {
|
|
5
|
+
EmailEventMapping,
|
|
6
|
+
EmailLayout,
|
|
7
|
+
} from "@echovisionlab/geul-proto/policy/spicedb/email_authoring.ts";
|
|
8
|
+
import {
|
|
9
|
+
EmailSuppression,
|
|
10
|
+
MailAdapter,
|
|
11
|
+
} from "@echovisionlab/geul-proto/policy/spicedb/email_delivery.ts";
|
|
12
|
+
import { File } from "@echovisionlab/geul-proto/policy/spicedb/file.ts";
|
|
13
|
+
import {
|
|
14
|
+
DelegationKind,
|
|
15
|
+
DirectSession,
|
|
16
|
+
MCPOAuth,
|
|
17
|
+
NewAccountIdentityActor,
|
|
18
|
+
NewAuthorizationDecision,
|
|
19
|
+
Platform,
|
|
20
|
+
RelationshipOperation,
|
|
21
|
+
spiceDbCatalogFingerprint,
|
|
22
|
+
} from "@echovisionlab/geul-proto/policy/spicedb/core.ts";
|
|
23
|
+
import type {
|
|
24
|
+
Can,
|
|
25
|
+
RelationshipMutation,
|
|
26
|
+
Resource,
|
|
27
|
+
ResourceLookup,
|
|
28
|
+
SubjectLookup,
|
|
29
|
+
} from "@echovisionlab/geul-proto/policy/spicedb/core.ts";
|
|
30
|
+
import { Label } from "@echovisionlab/geul-proto/policy/spicedb/label.ts";
|
|
31
|
+
import { Post } from "@echovisionlab/geul-proto/policy/spicedb/post.ts";
|
|
32
|
+
import { ProgramEvent } from "@echovisionlab/geul-proto/policy/spicedb/program_event.ts";
|
|
33
|
+
import {
|
|
34
|
+
TranslationProvider,
|
|
35
|
+
TranslationSettings,
|
|
36
|
+
} from "@echovisionlab/geul-proto/policy/spicedb/translation.ts";
|
|
37
|
+
|
|
38
|
+
function compileTimeInvalidSurfaceMustStayUnrepresentable(): void {
|
|
39
|
+
const actor = NewAccountIdentityActor("identity-id");
|
|
40
|
+
|
|
41
|
+
// @ts-expect-error Resource keys cannot be caller-authored.
|
|
42
|
+
const rawResource: Resource = { type: "post", id: "post-id" };
|
|
43
|
+
// @ts-expect-error Action and permission keys cannot be caller-authored.
|
|
44
|
+
const rawCan: Can = {};
|
|
45
|
+
// @ts-expect-error Lookup resource and permission keys cannot be caller-authored.
|
|
46
|
+
const rawLookup: ResourceLookup = {
|
|
47
|
+
resourceType: "post",
|
|
48
|
+
permission: "view",
|
|
49
|
+
};
|
|
50
|
+
// @ts-expect-error Subject lookup resource, permission, and subject keys cannot be caller-authored.
|
|
51
|
+
const rawSubjectLookup: SubjectLookup = {
|
|
52
|
+
resourceType: "platform",
|
|
53
|
+
resourceId: "global",
|
|
54
|
+
permission: "is_admin",
|
|
55
|
+
subjectType: "account_identity",
|
|
56
|
+
};
|
|
57
|
+
// @ts-expect-error Relation and operation combinations require a domain constructor.
|
|
58
|
+
const rawRelationship: RelationshipMutation = {};
|
|
59
|
+
|
|
60
|
+
const validCan = Post.Edit("post-id");
|
|
61
|
+
const spreadCan = {
|
|
62
|
+
...validCan,
|
|
63
|
+
action: { ...validCan.action, permission: "delete" },
|
|
64
|
+
};
|
|
65
|
+
// @ts-expect-error Spreading a valid descriptor does not copy its private nominal key.
|
|
66
|
+
const forgedCan: Can = spreadCan;
|
|
67
|
+
const spreadResource = { ...Post.Resource("post-id"), type: "artist" };
|
|
68
|
+
// @ts-expect-error Spreading a valid resource does not copy its private nominal key.
|
|
69
|
+
const forgedResource: Resource = spreadResource;
|
|
70
|
+
const spreadLookup = {
|
|
71
|
+
...Post.LookupView(),
|
|
72
|
+
permission: "delete",
|
|
73
|
+
};
|
|
74
|
+
// @ts-expect-error Spreading a lookup does not copy its private nominal key.
|
|
75
|
+
const forgedLookup: ResourceLookup = spreadLookup;
|
|
76
|
+
|
|
77
|
+
// @ts-expect-error Post has no Owner relation.
|
|
78
|
+
Post.TouchOwner("post-id", actor);
|
|
79
|
+
// @ts-expect-error Artist has no Collaborator relation.
|
|
80
|
+
Artist.TouchCollaborator("artist-id", actor);
|
|
81
|
+
// @ts-expect-error platform_admin is a schema helper, not a Post action.
|
|
82
|
+
Post.PlatformAdmin("post-id");
|
|
83
|
+
// @ts-expect-error ManageParticipants requires a Post id.
|
|
84
|
+
Post.ManageParticipants();
|
|
85
|
+
// @ts-expect-error Artist owns only the manage resource lookup.
|
|
86
|
+
Artist.LookupView();
|
|
87
|
+
|
|
88
|
+
void rawResource;
|
|
89
|
+
void rawCan;
|
|
90
|
+
void rawRelationship;
|
|
91
|
+
void rawLookup;
|
|
92
|
+
void rawSubjectLookup;
|
|
93
|
+
void forgedCan;
|
|
94
|
+
void forgedResource;
|
|
95
|
+
void forgedLookup;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
void compileTimeInvalidSurfaceMustStayUnrepresentable;
|
|
99
|
+
|
|
100
|
+
describe("domain-shaped SpiceDB policy descriptors", () => {
|
|
101
|
+
it("keeps direct-session and MCP engine checks byte-identical", () => {
|
|
102
|
+
expect(spiceDbCatalogFingerprint).toBe(
|
|
103
|
+
"b1c92baf58538b463ae2a94d9b26c3c7e920b22bf92ca71841196b017592a2fe",
|
|
104
|
+
);
|
|
105
|
+
const actor = NewAccountIdentityActor("identity-id");
|
|
106
|
+
const can = Post.Edit("post-id");
|
|
107
|
+
const direct = NewAuthorizationDecision(
|
|
108
|
+
actor,
|
|
109
|
+
DirectSession("session-id"),
|
|
110
|
+
can,
|
|
111
|
+
);
|
|
112
|
+
const oauth = NewAuthorizationDecision(
|
|
113
|
+
actor,
|
|
114
|
+
MCPOAuth("oauth-grant-id", "Example Member · Example Client"),
|
|
115
|
+
can,
|
|
116
|
+
);
|
|
117
|
+
expect(new TextEncoder().encode(oauth.engineKey)).toEqual(
|
|
118
|
+
new TextEncoder().encode(direct.engineKey),
|
|
119
|
+
);
|
|
120
|
+
expect(oauth.engineKey).toBe("post\0post-id\0edit");
|
|
121
|
+
expect(oauth.delegation.kind).toBe(DelegationKind.MCPOAuth);
|
|
122
|
+
expect(oauth.delegation.delegationId).toBe("oauth-grant-id");
|
|
123
|
+
expect(oauth.delegation.delegationDisplayName).toBe(
|
|
124
|
+
"Example Member · Example Client",
|
|
125
|
+
);
|
|
126
|
+
expect(Object.keys(oauth.delegation)).toEqual([
|
|
127
|
+
"kind",
|
|
128
|
+
"sessionId",
|
|
129
|
+
"delegationId",
|
|
130
|
+
"delegationDisplayName",
|
|
131
|
+
]);
|
|
132
|
+
expect(Object.isFrozen(oauth)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps exact global admin actions in their owning domains", () => {
|
|
136
|
+
const actions = {
|
|
137
|
+
"file.manage_library": File.ManageLibrary(),
|
|
138
|
+
"program_event.update_type": ProgramEvent.UpdateType(),
|
|
139
|
+
"program_event.delete_type": ProgramEvent.DeleteType(),
|
|
140
|
+
"email_event_mapping.update": EmailEventMapping.Update(),
|
|
141
|
+
"email_layout.preview_content": EmailLayout.PreviewContent(),
|
|
142
|
+
"translation_settings.view": TranslationSettings.View(),
|
|
143
|
+
"translation_settings.update": TranslationSettings.Update(),
|
|
144
|
+
"translation_provider.list": TranslationProvider.List(),
|
|
145
|
+
"translation_provider.create": TranslationProvider.Create(),
|
|
146
|
+
"translation_provider.update": TranslationProvider.Update(),
|
|
147
|
+
"translation_provider.delete": TranslationProvider.Delete(),
|
|
148
|
+
"mail_adapter.list": MailAdapter.List(),
|
|
149
|
+
"mail_adapter.create": MailAdapter.Create(),
|
|
150
|
+
"mail_adapter.update": MailAdapter.Update(),
|
|
151
|
+
"mail_adapter.delete": MailAdapter.Delete(),
|
|
152
|
+
"mail_adapter.test": MailAdapter.Test(),
|
|
153
|
+
"email_suppression.release": EmailSuppression.Release(),
|
|
154
|
+
};
|
|
155
|
+
for (const [name, can] of Object.entries(actions)) {
|
|
156
|
+
expect(can).toMatchObject({
|
|
157
|
+
resource: { type: "platform", id: "global" },
|
|
158
|
+
action: { name, permission: "is_admin" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("exposes business aliases without schema helper actions", () => {
|
|
164
|
+
expect(Post.RemoveAuthor("post-id").action).toMatchObject({
|
|
165
|
+
name: "remove_author",
|
|
166
|
+
permission: "platform_admin",
|
|
167
|
+
});
|
|
168
|
+
expect(Artist.RemoveOwner("artist-id").action.permission).toBe(
|
|
169
|
+
"platform_admin",
|
|
170
|
+
);
|
|
171
|
+
expect(Label.RemoveOwner("label-id").action.permission).toBe(
|
|
172
|
+
"platform_admin",
|
|
173
|
+
);
|
|
174
|
+
expect(Post.Create()).toMatchObject({
|
|
175
|
+
resource: { type: "platform", id: "global" },
|
|
176
|
+
action: { name: "post.create", permission: "is_author" },
|
|
177
|
+
});
|
|
178
|
+
expect(Post).not.toHaveProperty("PlatformAdmin");
|
|
179
|
+
expect(Post).not.toHaveProperty("AuthorManage");
|
|
180
|
+
expect(Artist).not.toHaveProperty("OwnerManage");
|
|
181
|
+
expect(Label).not.toHaveProperty("ManagerManage");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("constructs exact object-ID-less resource lookups", () => {
|
|
185
|
+
const lookup = Post.LookupPlatformAdmin();
|
|
186
|
+
expect(lookup).toEqual({
|
|
187
|
+
resourceType: "post",
|
|
188
|
+
permission: "platform_admin",
|
|
189
|
+
});
|
|
190
|
+
expect(Object.isFrozen(lookup)).toBe(true);
|
|
191
|
+
expect(Post).not.toHaveProperty("PlatformAdmin");
|
|
192
|
+
expect(Artist).not.toHaveProperty("LookupView");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("constructs only the closed platform account-identity subject lookups", () => {
|
|
196
|
+
expect(Platform.LookupAdminSubjects()).toEqual({
|
|
197
|
+
resourceType: "platform",
|
|
198
|
+
resourceId: "global",
|
|
199
|
+
permission: "is_admin",
|
|
200
|
+
subjectType: "account_identity",
|
|
201
|
+
});
|
|
202
|
+
expect(Platform.LookupAuthorSubjects().permission).toBe("is_author");
|
|
203
|
+
expect(Platform.LookupUserSubjects().permission).toBe("is_user");
|
|
204
|
+
expect(Object.isFrozen(Platform.LookupAdminSubjects())).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("binds valid relation subjects and operations in domain constructors", () => {
|
|
208
|
+
const actor = NewAccountIdentityActor("identity-id");
|
|
209
|
+
expect(Post.TouchAuthor("post-id", actor)).toMatchObject({
|
|
210
|
+
resource: { type: "post", id: "post-id" },
|
|
211
|
+
relation: "author",
|
|
212
|
+
subject: { type: "account_identity", id: "identity-id", relation: "" },
|
|
213
|
+
operation: RelationshipOperation.Touch,
|
|
214
|
+
});
|
|
215
|
+
expect(Post.DeleteCollaborator("post-id", actor).operation).toBe(
|
|
216
|
+
RelationshipOperation.Delete,
|
|
217
|
+
);
|
|
218
|
+
expect(Artist.TouchParent("artist-id", "parent-id").subject).toEqual({
|
|
219
|
+
type: "artist",
|
|
220
|
+
id: "parent-id",
|
|
221
|
+
relation: "",
|
|
222
|
+
});
|
|
223
|
+
expect(Post).not.toHaveProperty("TouchOwner");
|
|
224
|
+
expect(Artist).not.toHaveProperty("TouchCollaborator");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
TranslationEntityType,
|
|
5
|
+
TranslationJobSchema,
|
|
6
|
+
} from "@echovisionlab/geul-proto/secure/translation_pb.ts";
|
|
7
|
+
import { assertTranslationJobRequestArtifact } from "./translation-source.ts";
|
|
8
|
+
|
|
9
|
+
describe("translation request artifact", () => {
|
|
10
|
+
it("requires one target and canonical request artifact digest", () => {
|
|
11
|
+
const job = create(TranslationJobSchema);
|
|
12
|
+
expect(() => assertTranslationJobRequestArtifact(job)).toThrow(
|
|
13
|
+
"translation job target is required",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
job.target = {
|
|
17
|
+
$typeName: "api.manage.v1.TranslationTarget",
|
|
18
|
+
entityType: TranslationEntityType.PAGE,
|
|
19
|
+
entityId: "entity-1",
|
|
20
|
+
};
|
|
21
|
+
expect(() => assertTranslationJobRequestArtifact(job)).toThrow(
|
|
22
|
+
"translation job request artifact digest is required",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
job.requestArtifactDigest = "artifact-digest";
|
|
26
|
+
expect(() => assertTranslationJobRequestArtifact(job)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TranslationJob } from "@echovisionlab/geul-proto/secure/translation_pb.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates the one request identity shared by every translation-capable
|
|
5
|
+
* domain. Domain-specific source epochs, hashes, and revision counters are
|
|
6
|
+
* intentionally absent from this boundary.
|
|
7
|
+
*/
|
|
8
|
+
export function assertTranslationJobRequestArtifact(job: TranslationJob): void {
|
|
9
|
+
if (job.target === undefined) {
|
|
10
|
+
throw new Error("translation job target is required");
|
|
11
|
+
}
|
|
12
|
+
if (job.requestArtifactDigest.trim().length === 0) {
|
|
13
|
+
throw new Error("translation job request artifact digest is required");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
UserDeleteIdentityCommandSchema,
|
|
5
|
+
UserDeleteIdentityMode,
|
|
6
|
+
} from "@echovisionlab/geul-proto/secure/events_pb.ts";
|
|
7
|
+
|
|
8
|
+
describe("UserDeleteIdentityCommand mode", () => {
|
|
9
|
+
it("keeps the zero value invalid instead of selecting a cleanup mode", () => {
|
|
10
|
+
const command = create(UserDeleteIdentityCommandSchema, {
|
|
11
|
+
memberId: "member-1",
|
|
12
|
+
identityId: "identity-1",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(command.mode).toBe(UserDeleteIdentityMode.UNSPECIFIED);
|
|
16
|
+
expect(command.mode).not.toBe(UserDeleteIdentityMode.TOMBSTONE);
|
|
17
|
+
expect(command.mode).not.toBe(
|
|
18
|
+
UserDeleteIdentityMode.UNONBOARDED_HARD_DELETE,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
UserDeleteIdentityMode.TOMBSTONE,
|
|
24
|
+
UserDeleteIdentityMode.UNONBOARDED_HARD_DELETE,
|
|
25
|
+
])("round-trips explicit mode %s", (mode) => {
|
|
26
|
+
const command = create(UserDeleteIdentityCommandSchema, {
|
|
27
|
+
mode,
|
|
28
|
+
memberId: "member-1",
|
|
29
|
+
identityId: "identity-1",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(command.mode).toBe(mode);
|
|
33
|
+
});
|
|
34
|
+
});
|