@milaboratories/pl-middle-layer 1.68.3 → 1.69.1
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/dist/index.cjs +10 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/index.cjs +2 -0
- package/dist/middle_layer/index.d.ts +2 -1
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +333 -26
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +125 -7
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +335 -28
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/sharing_list.cjs +13 -5
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +16 -2
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +14 -6
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +72 -0
- package/dist/middle_layer/template_list.cjs.map +1 -0
- package/dist/middle_layer/template_list.d.ts +77 -0
- package/dist/middle_layer/template_list.d.ts.map +1 -0
- package/dist/middle_layer/template_list.js +64 -0
- package/dist/middle_layer/template_list.js.map +1 -0
- package/dist/model/index.cjs +8 -0
- package/dist/model/index.d.ts +5 -2
- package/dist/model/index.js +4 -2
- package/dist/model/sharing_model.cjs +59 -2
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +70 -7
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +57 -3
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/model/template_serializer.d.ts +36 -0
- package/dist/model/template_serializer.d.ts.map +1 -1
- package/dist/model/template_share.cjs +42 -0
- package/dist/model/template_share.cjs.map +1 -0
- package/dist/model/template_share.d.ts +27 -0
- package/dist/model/template_share.d.ts.map +1 -0
- package/dist/model/template_share.js +42 -0
- package/dist/model/template_share.js.map +1 -0
- package/dist/mutator/project.cjs +2 -2
- package/dist/mutator/project.js +2 -2
- package/dist/mutator/sharing.cjs +40 -2
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +40 -3
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +54 -0
- package/dist/mutator/template.cjs.map +1 -0
- package/dist/mutator/template.js +52 -0
- package/dist/mutator/template.js.map +1 -0
- package/package.json +10 -10
- package/src/middle_layer/index.ts +9 -0
- package/src/middle_layer/middle_layer.ts +449 -36
- package/src/middle_layer/sharing_list.ts +37 -7
- package/src/middle_layer/template_list.ts +177 -0
- package/src/middle_layer/templates.test.ts +301 -0
- package/src/model/index.ts +14 -0
- package/src/model/sharing_model.test.ts +115 -1
- package/src/model/sharing_model.ts +134 -9
- package/src/model/template_share.test.ts +78 -0
- package/src/model/template_share.ts +52 -0
- package/src/mutator/sharing.ts +55 -3
- package/src/mutator/template.ts +75 -0
- package/src/test/with_ml.ts +38 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { test, expect } from "vitest";
|
|
2
2
|
import { Role } from "@milaboratories/pl-client";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
canGrantToEveryone,
|
|
5
|
+
canImpersonate,
|
|
6
|
+
decodeEnvelopeData,
|
|
7
|
+
EnvelopeSchemaVersionCurrent,
|
|
8
|
+
envelopeProjectMap,
|
|
9
|
+
normalizeEnvelopeData,
|
|
10
|
+
} from "./sharing_model";
|
|
4
11
|
|
|
5
12
|
// canImpersonate is the admin gate for "open another user's root". It must be strictly
|
|
6
13
|
// stricter than canGrantToEveryone: a regular USER may share their own projects but must
|
|
@@ -20,3 +27,110 @@ test("canGrantToEveryone and canImpersonate does not include USER", () => {
|
|
|
20
27
|
expect(canGrantToEveryone(Role.USER)).toBe(false);
|
|
21
28
|
expect(canImpersonate(Role.USER)).toBe(false);
|
|
22
29
|
});
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
// Envelope decode — the recognise-or-hide gate every reader of a share passes through.
|
|
33
|
+
//
|
|
34
|
+
// Pure by construction: the gate is a function of the blob, and the three sites that
|
|
35
|
+
// discover envelopes (the pending view, the live-envelope view the accept flow reads,
|
|
36
|
+
// and the donor's own outbox) all skip an envelope this returns `undefined` for. So an
|
|
37
|
+
// envelope that does not decode cannot be offered, accepted, or listed.
|
|
38
|
+
|
|
39
|
+
/** A v1 envelope, exactly as one written before the payload discriminant existed: the
|
|
40
|
+
* project map sits at the top level and there is no `payload` field. */
|
|
41
|
+
const v1Envelope = {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
shareId: "5d1a6f6c-2b6e-4a3f-9c2d-8f0e1b7a4c55",
|
|
44
|
+
sharedAt: 1_700_000_000_000,
|
|
45
|
+
expiresAt: null,
|
|
46
|
+
mode: "copy",
|
|
47
|
+
sender: "donor",
|
|
48
|
+
title: "Two projects",
|
|
49
|
+
projects: {
|
|
50
|
+
"9c7e4d10-2b83-4f6a-91d5-7e0c3a8b5f42": {
|
|
51
|
+
label: "Project 1",
|
|
52
|
+
source: "42",
|
|
53
|
+
updatedAt: 1_700_000_000_000,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const blob = (envelope: unknown) => Buffer.from(JSON.stringify(envelope), "utf-8");
|
|
59
|
+
|
|
60
|
+
test("a v1 envelope still decodes, and reads as a share of projects", () => {
|
|
61
|
+
const data = decodeEnvelopeData(blob(v1Envelope));
|
|
62
|
+
|
|
63
|
+
// Upcast on read: past the decode nothing knows two shapes ever existed.
|
|
64
|
+
expect(data?.schemaVersion).toBe(EnvelopeSchemaVersionCurrent);
|
|
65
|
+
expect(data?.payload).toStrictEqual({ kind: "projects", projects: v1Envelope.projects });
|
|
66
|
+
expect(envelopeProjectMap(data!)).toStrictEqual(v1Envelope.projects);
|
|
67
|
+
|
|
68
|
+
// Everything a view renders survives the upcast unchanged.
|
|
69
|
+
expect(data).toMatchObject({
|
|
70
|
+
shareId: v1Envelope.shareId,
|
|
71
|
+
sharedAt: v1Envelope.sharedAt,
|
|
72
|
+
expiresAt: null,
|
|
73
|
+
mode: "copy",
|
|
74
|
+
sender: "donor",
|
|
75
|
+
title: "Two projects",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a current envelope carrying a template decodes as one", () => {
|
|
80
|
+
const data = decodeEnvelopeData(
|
|
81
|
+
blob({
|
|
82
|
+
...v1Envelope,
|
|
83
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
84
|
+
projects: undefined,
|
|
85
|
+
payload: {
|
|
86
|
+
kind: "template",
|
|
87
|
+
document: { schema: "template-v1", blocks: [] },
|
|
88
|
+
label: "A pipeline",
|
|
89
|
+
from: "donor",
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(data?.payload).toStrictEqual({
|
|
95
|
+
kind: "template",
|
|
96
|
+
document: { schema: "template-v1", blocks: [] },
|
|
97
|
+
label: "A pipeline",
|
|
98
|
+
from: "donor",
|
|
99
|
+
});
|
|
100
|
+
// A template payload carries no project snapshot, so a project-shaped reader sees nothing
|
|
101
|
+
// rather than something it would then try to copy.
|
|
102
|
+
expect(envelopeProjectMap(data!)).toStrictEqual({});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an envelope whose payload kind this build does not know does not decode at all", () => {
|
|
106
|
+
// The whole point of the discriminant: a share this build cannot act on is hidden rather
|
|
107
|
+
// than offered, and the decode is where that is decided — once, for every reader.
|
|
108
|
+
expect(
|
|
109
|
+
decodeEnvelopeData(
|
|
110
|
+
blob({
|
|
111
|
+
...v1Envelope,
|
|
112
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
113
|
+
projects: undefined,
|
|
114
|
+
payload: { kind: "workspace", whatever: true },
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an envelope from a newer schema does not decode either", () => {
|
|
121
|
+
expect(
|
|
122
|
+
decodeEnvelopeData(blob({ ...v1Envelope, schemaVersion: EnvelopeSchemaVersionCurrent + 1 })),
|
|
123
|
+
).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a v1 envelope with no project map at all does not decode", () => {
|
|
127
|
+
// There is no payload to reconstruct: a v1 blob without `projects` describes nothing,
|
|
128
|
+
// which is not the same as describing an empty pack.
|
|
129
|
+
expect(decodeEnvelopeData(blob({ ...v1Envelope, projects: undefined }))).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("anything that is not an envelope object does not decode", () => {
|
|
133
|
+
expect(normalizeEnvelopeData(null)).toBeUndefined();
|
|
134
|
+
expect(normalizeEnvelopeData("an envelope")).toBeUndefined();
|
|
135
|
+
expect(normalizeEnvelopeData(42)).toBeUndefined();
|
|
136
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResourceType, Role } from "@milaboratories/pl-client";
|
|
2
2
|
import { Role as RoleEnum } from "@milaboratories/pl-client";
|
|
3
|
-
import type { Branded, ProjectId } from "@milaboratories/pl-model-common";
|
|
3
|
+
import type { Branded, ProjectId, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -84,23 +84,65 @@ export function canImpersonate(role: Role | null): boolean {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in
|
|
87
|
+
/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in a
|
|
88
|
+
* `projects` {@link EnvelopePayload}. */
|
|
88
89
|
export interface EnvelopeProject {
|
|
89
90
|
label: string; // carried so the pending-share UI renders without traversing into the project
|
|
90
91
|
source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change
|
|
91
92
|
updatedAt: number; // ms epoch of the last (re)snapshot
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* What a share carries. The discriminant is what a reader checks before anything else: a
|
|
97
|
+
* client that does not know a kind hides the share instead of offering something it cannot
|
|
98
|
+
* act on.
|
|
99
|
+
*
|
|
100
|
+
* `projects` snapshots ride as `project/{uuid}` fields on the envelope and this map only
|
|
101
|
+
* describes them; a `template` payload has no fields at all — the document is right here.
|
|
102
|
+
*/
|
|
103
|
+
export type EnvelopePayload =
|
|
104
|
+
| { kind: "projects"; projects: Record<ProjectFieldUuid, EnvelopeProject> }
|
|
105
|
+
| {
|
|
106
|
+
kind: "template";
|
|
107
|
+
document: ProjectTemplateV1;
|
|
108
|
+
/** Label to give the template on the recipient's own shelf. */
|
|
109
|
+
label: string;
|
|
110
|
+
/** Donor login, kept on the accepted template as its provenance. */
|
|
111
|
+
from: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type EnvelopePayloadKind = EnvelopePayload["kind"];
|
|
115
|
+
|
|
116
|
+
/** Every envelope schema version this build can read. Adding a version here is what makes
|
|
117
|
+
* {@link normalizeEnvelopeData} accept it; bumping {@link EnvelopeSchemaVersionCurrent} to a
|
|
118
|
+
* version missing from this union is a compile error. */
|
|
119
|
+
export type EnvelopeSchemaVersion = 1 | 2;
|
|
120
|
+
|
|
121
|
+
/** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */
|
|
122
|
+
export const EnvelopeSchemaVersionCurrent = 2 satisfies EnvelopeSchemaVersion;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated.
|
|
126
|
+
*
|
|
127
|
+
* Always the current version in memory: a v1 envelope (project map at the top level, no
|
|
128
|
+
* `payload` field) is upcast on read by {@link normalizeEnvelopeData}, so no reader past the
|
|
129
|
+
* decode has to know that two shapes ever existed.
|
|
130
|
+
*/
|
|
95
131
|
export interface EnvelopeData {
|
|
96
|
-
schemaVersion:
|
|
132
|
+
schemaVersion: typeof EnvelopeSchemaVersionCurrent;
|
|
97
133
|
shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes
|
|
98
134
|
sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId
|
|
99
135
|
expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)
|
|
100
136
|
mode: EnvelopeMode; // what the acceptor's app should do with the contents
|
|
101
137
|
sender: string; // donor login (informational; backend granted_by is authoritative)
|
|
102
138
|
title: string; // display name shown to recipients; defaults to the first project's name
|
|
103
|
-
|
|
139
|
+
payload: EnvelopePayload; // what the share carries
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The project map of a projects-payload envelope, or `{}` for any other payload — the one
|
|
143
|
+
* place a project-shaped reader turns a payload into the map it expects. */
|
|
144
|
+
export function envelopeProjectMap(data: EnvelopeData): Record<ProjectFieldUuid, EnvelopeProject> {
|
|
145
|
+
return data.payload.kind === "projects" ? data.payload.projects : {};
|
|
104
146
|
}
|
|
105
147
|
|
|
106
148
|
/** Dynamic field on SharingState, one per handled share, keyed by shareId. */
|
|
@@ -110,7 +152,7 @@ export interface SharingDecision {
|
|
|
110
152
|
decision: "accepted" | "rejected";
|
|
111
153
|
timestamp: number; // ms epoch — when the acceptor acted
|
|
112
154
|
envelopeSharedAt: number; // the acted-on envelope instance's sharedAt — pins which instance was handled (paired with the shareId key; the resource id is never stored)
|
|
113
|
-
acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)
|
|
155
|
+
acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share, and for a template share, which creates none)
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
|
|
@@ -134,10 +176,45 @@ export interface EnvelopeAcceptance {
|
|
|
134
176
|
* Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`
|
|
135
177
|
* blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource
|
|
136
178
|
* `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node
|
|
137
|
-
* path
|
|
179
|
+
* path decodes the same JSON with `getDataAsJson` and normalizes it with
|
|
180
|
+
* {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.
|
|
181
|
+
*
|
|
182
|
+
* `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.
|
|
138
183
|
*/
|
|
139
|
-
export function decodeEnvelopeData(data: Uint8Array): EnvelopeData {
|
|
140
|
-
return JSON.parse(Buffer.from(data).toString("utf-8"))
|
|
184
|
+
export function decodeEnvelopeData(data: Uint8Array): EnvelopeData | undefined {
|
|
185
|
+
return normalizeEnvelopeData(JSON.parse(Buffer.from(data).toString("utf-8")));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Brings a decoded envelope blob to the current shape, or reports that this build cannot act
|
|
190
|
+
* on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A
|
|
191
|
+
* caller hides such a share rather than offering the recipient something it cannot handle.
|
|
192
|
+
*
|
|
193
|
+
* A v1 envelope carried its project map at the top level and had no `payload` field; it reads
|
|
194
|
+
* here as a `projects` payload, so envelopes written before the discriminant existed keep
|
|
195
|
+
* working unchanged.
|
|
196
|
+
*/
|
|
197
|
+
export function normalizeEnvelopeData(raw: unknown): EnvelopeData | undefined {
|
|
198
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
199
|
+
const e = raw as RawEnvelopeData;
|
|
200
|
+
if (!Object.hasOwn(ReadableSchemaVersions, e.schemaVersion)) return undefined;
|
|
201
|
+
|
|
202
|
+
const payload =
|
|
203
|
+
e.payload ??
|
|
204
|
+
(e.projects !== undefined ? ({ kind: "projects", projects: e.projects } as const) : undefined);
|
|
205
|
+
if (payload === undefined) return undefined;
|
|
206
|
+
if (!Object.hasOwn(KnownPayloadKinds, payload.kind)) return undefined;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
210
|
+
shareId: e.shareId,
|
|
211
|
+
sharedAt: e.sharedAt,
|
|
212
|
+
expiresAt: e.expiresAt,
|
|
213
|
+
mode: e.mode,
|
|
214
|
+
sender: e.sender,
|
|
215
|
+
title: e.title,
|
|
216
|
+
payload,
|
|
217
|
+
};
|
|
141
218
|
}
|
|
142
219
|
|
|
143
220
|
/**
|
|
@@ -166,3 +243,51 @@ export type ShareProjectsOptions =
|
|
|
166
243
|
title: string;
|
|
167
244
|
mode: EnvelopeMode;
|
|
168
245
|
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Options for {@link MiddleLayer.shareTemplate}.
|
|
249
|
+
*
|
|
250
|
+
* Recipients XOR everyone, exactly as {@link ShareProjectsOptions}, minus the mode: a template
|
|
251
|
+
* share is always granted read-only, because the recipient copies no resource out of the
|
|
252
|
+
* envelope — the document is in the envelope's own data.
|
|
253
|
+
*/
|
|
254
|
+
export type ShareTemplateOptions =
|
|
255
|
+
| {
|
|
256
|
+
recipients: string[]; // recipient logins
|
|
257
|
+
title: string; // display name shown to recipients; defaults to the template's label
|
|
258
|
+
}
|
|
259
|
+
| {
|
|
260
|
+
everyone: true; // share with all users on the server
|
|
261
|
+
title: string;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
//
|
|
265
|
+
// Internals
|
|
266
|
+
//
|
|
267
|
+
|
|
268
|
+
/** Every payload kind this build can act on; anything else is hidden rather than offered.
|
|
269
|
+
* Keyed by {@link EnvelopePayloadKind}, so adding a kind to {@link EnvelopePayload} without
|
|
270
|
+
* teaching the decoder about it is a compile error, not a share that silently disappears. */
|
|
271
|
+
const KnownPayloadKinds: Record<EnvelopePayloadKind, true> = {
|
|
272
|
+
projects: true,
|
|
273
|
+
template: true,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by
|
|
277
|
+
* {@link EnvelopeSchemaVersion}, so widening that union without deciding how the new shape
|
|
278
|
+
* is upcast is a compile error. */
|
|
279
|
+
const ReadableSchemaVersions: Record<EnvelopeSchemaVersion, true> = {
|
|
280
|
+
1: true,
|
|
281
|
+
2: true,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The envelope blob as it comes off the wire, before {@link normalizeEnvelopeData} decides
|
|
286
|
+
* whether this build can act on it: the version is any number, the payload may be missing,
|
|
287
|
+
* and `projects` is the v1 top-level project map.
|
|
288
|
+
*/
|
|
289
|
+
type RawEnvelopeData = Omit<EnvelopeData, "schemaVersion" | "payload"> & {
|
|
290
|
+
schemaVersion: number;
|
|
291
|
+
payload?: EnvelopePayload;
|
|
292
|
+
projects?: Record<ProjectFieldUuid, EnvelopeProject>;
|
|
293
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
BlockKindSelectorReference,
|
|
4
|
+
BlockPackLocationReference,
|
|
5
|
+
ProjectTemplateV1,
|
|
6
|
+
ProjectTemplateV1Entry,
|
|
7
|
+
} from "@milaboratories/pl-model-common";
|
|
8
|
+
import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
|
|
9
|
+
import { unshareableTemplateEntries } from "./template_share";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a stored template may travel to another machine.
|
|
13
|
+
*
|
|
14
|
+
* A function of the document alone, which is why it is asked both when a share is attempted
|
|
15
|
+
* and when a template is merely displayed — the refusal has to be visible on the template
|
|
16
|
+
* itself, not only after the user tries.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const KIND = "@platforma-open/milaboratories.demo.kind@^1.0.0" as BlockKindSelectorReference;
|
|
20
|
+
|
|
21
|
+
const entry = (id: string, location?: string): ProjectTemplateV1Entry => ({
|
|
22
|
+
id,
|
|
23
|
+
kind: KIND,
|
|
24
|
+
params: {},
|
|
25
|
+
...(location !== undefined ? { location: location as BlockPackLocationReference } : {}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const documentOf = (...blocks: ProjectTemplateV1Entry[]): ProjectTemplateV1 => ({
|
|
29
|
+
schema: PROJECT_TEMPLATE_SCHEMA_V1,
|
|
30
|
+
blocks,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("unshareableTemplateEntries", () => {
|
|
34
|
+
test("a template whose entries name no place at all can be shared", () => {
|
|
35
|
+
// The common case: every entry resolves through its kind, which means the same thing
|
|
36
|
+
// on the recipient's machine as it does here.
|
|
37
|
+
expect(unshareableTemplateEntries(documentOf(entry("a"), entry("b")))).toStrictEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an entry installed from a folder on this machine refuses the share, and says which folder", () => {
|
|
41
|
+
const problems = unshareableTemplateEntries(
|
|
42
|
+
documentOf(entry("a"), entry("b", "file:///Users/dev/blocks/demo/block")),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(problems.map((p) => p.entryId)).toStrictEqual(["b"]);
|
|
46
|
+
expect(problems[0].error).toContain("file:///Users/dev/blocks/demo/block");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("every offending entry is reported, not only the first", () => {
|
|
50
|
+
// A UI names each block once instead of sending its user round the loop per entry.
|
|
51
|
+
const problems = unshareableTemplateEntries(
|
|
52
|
+
documentOf(
|
|
53
|
+
entry("a", "file:///blocks/a"),
|
|
54
|
+
entry("b"),
|
|
55
|
+
entry("c", "FILE:///blocks/c"), // the scheme is case-insensitive
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(problems.map((p) => p.entryId)).toStrictEqual(["a", "c"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a location whose scheme cannot be read is refused too", () => {
|
|
63
|
+
// Nothing can resolve it anywhere, here included — so the reason differs from the
|
|
64
|
+
// `file:` one, and the message says so rather than blaming the recipient's machine.
|
|
65
|
+
const problems = unshareableTemplateEntries(documentOf(entry("a", "/blocks/a")));
|
|
66
|
+
|
|
67
|
+
expect(problems.map((p) => p.entryId)).toStrictEqual(["a"]);
|
|
68
|
+
expect(problems[0].error).toContain("nothing can resolve");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a location naming a place both machines can reach is not refused", () => {
|
|
72
|
+
// The rule is about a place that means something different elsewhere, not about
|
|
73
|
+
// locations as such.
|
|
74
|
+
expect(
|
|
75
|
+
unshareableTemplateEntries(documentOf(entry("a", "https://blocks.example.org/demo"))),
|
|
76
|
+
).toStrictEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
|
|
2
|
+
import { parseBlockPackLocation } from "@milaboratories/pl-model-common";
|
|
3
|
+
|
|
4
|
+
/** One template entry standing in the way of sharing the template, and why. */
|
|
5
|
+
export type TemplateShareProblem = {
|
|
6
|
+
/** The template-local id of the entry the problem belongs to; on an exported template it is
|
|
7
|
+
* the block's project-local uuid. */
|
|
8
|
+
readonly entryId: string;
|
|
9
|
+
readonly error: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every entry of a template that cannot travel to another machine, or an empty list for a
|
|
14
|
+
* template that can be shared.
|
|
15
|
+
*
|
|
16
|
+
* An entry's `location` names a place rather than a name, and a `file:` place is a folder on
|
|
17
|
+
* the author's own disk: a recipient resolving it finds nothing, or worse finds something
|
|
18
|
+
* else. Such a template stays perfectly usable where it was made, so it is stored and applied
|
|
19
|
+
* as normal — only sharing it is refused.
|
|
20
|
+
*
|
|
21
|
+
* A location whose scheme cannot be read is refused for the same reason: nothing can resolve
|
|
22
|
+
* it anywhere, here included.
|
|
23
|
+
*
|
|
24
|
+
* Every offending entry is reported, not only the first, so a UI can name each block instead
|
|
25
|
+
* of sending its user round the loop once per entry.
|
|
26
|
+
*/
|
|
27
|
+
export function unshareableTemplateEntries(
|
|
28
|
+
document: ProjectTemplateV1,
|
|
29
|
+
): readonly TemplateShareProblem[] {
|
|
30
|
+
const problems: TemplateShareProblem[] = [];
|
|
31
|
+
for (const entry of document.blocks) {
|
|
32
|
+
if (entry.location === undefined) continue;
|
|
33
|
+
let scheme: string;
|
|
34
|
+
try {
|
|
35
|
+
scheme = parseBlockPackLocation(entry.location).scheme;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
problems.push({
|
|
38
|
+
entryId: entry.id,
|
|
39
|
+
error: `Block is installed from a location nothing can resolve: ${e instanceof Error ? e.message : String(e)}`,
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (scheme === "file")
|
|
44
|
+
problems.push({
|
|
45
|
+
entryId: entry.id,
|
|
46
|
+
error:
|
|
47
|
+
`Block is installed from ${entry.location}, a folder on this machine — it resolves ` +
|
|
48
|
+
"to nothing on the recipient's, so this template cannot be shared",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return problems;
|
|
52
|
+
}
|
package/src/mutator/sharing.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { PlTransaction, ResourceRef, SignedResourceId } from "@milaboratori
|
|
|
2
2
|
import { field, isNotNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
|
|
5
|
-
import type { ProjectId } from "@milaboratories/pl-model-common";
|
|
5
|
+
import type { ProjectId, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
|
|
6
6
|
import { ProjectMetaKey } from "../model/project_model";
|
|
7
7
|
import { duplicateProject } from "./project";
|
|
8
8
|
import type {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
SharingDecision,
|
|
16
16
|
} from "../model/sharing_model";
|
|
17
17
|
import {
|
|
18
|
+
EnvelopeSchemaVersionCurrent,
|
|
18
19
|
SharedEnvelopeResourceType,
|
|
19
20
|
acceptanceField,
|
|
20
21
|
decisionField,
|
|
@@ -101,14 +102,14 @@ export async function buildShareEnvelope(
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const data: EnvelopeData = {
|
|
104
|
-
schemaVersion:
|
|
105
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
105
106
|
shareId,
|
|
106
107
|
sharedAt,
|
|
107
108
|
expiresAt: params.expiresAt,
|
|
108
109
|
mode: params.mode,
|
|
109
110
|
sender: params.sender,
|
|
110
111
|
title: params.title,
|
|
111
|
-
projects,
|
|
112
|
+
payload: { kind: "projects", projects },
|
|
112
113
|
};
|
|
113
114
|
|
|
114
115
|
// Immutable data set once at creation, never altered.
|
|
@@ -127,6 +128,57 @@ export async function buildShareEnvelope(
|
|
|
127
128
|
return { envelope, data };
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Builds one {@link SharedEnvelopeResourceType} carrying a template document on the donor side,
|
|
133
|
+
* and attaches it under `{shareId}` on the donor's outbox. The caller issues the grant — always
|
|
134
|
+
* read-only — and commits, keeping create + grant atomic.
|
|
135
|
+
*
|
|
136
|
+
* There is nothing to snapshot and no input field to seal: the document is the whole payload and
|
|
137
|
+
* rides in the envelope's immutable `data`, which is also why the recipient needs no write access
|
|
138
|
+
* (it copies no resource out of the envelope).
|
|
139
|
+
*
|
|
140
|
+
* @returns the new envelope resource and the generated `EnvelopeData`.
|
|
141
|
+
*/
|
|
142
|
+
export function buildTemplateShareEnvelope(
|
|
143
|
+
tx: PlTransaction,
|
|
144
|
+
outboxRid: SignedResourceId,
|
|
145
|
+
template: { document: ProjectTemplateV1; label: string },
|
|
146
|
+
params: {
|
|
147
|
+
sender: string;
|
|
148
|
+
title: string;
|
|
149
|
+
/** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
|
|
150
|
+
expiresAt: number | null;
|
|
151
|
+
/** Existing shareId for a change; a fresh one is minted when omitted. */
|
|
152
|
+
shareId?: ShareId;
|
|
153
|
+
sharedAt?: number;
|
|
154
|
+
},
|
|
155
|
+
): { envelope: ResourceRef; data: EnvelopeData } {
|
|
156
|
+
const data: EnvelopeData = {
|
|
157
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
158
|
+
shareId: params.shareId ?? newShareId(),
|
|
159
|
+
sharedAt: params.sharedAt ?? Date.now(),
|
|
160
|
+
expiresAt: params.expiresAt,
|
|
161
|
+
mode: "read-only",
|
|
162
|
+
sender: params.sender,
|
|
163
|
+
title: params.title,
|
|
164
|
+
payload: {
|
|
165
|
+
kind: "template",
|
|
166
|
+
document: template.document,
|
|
167
|
+
label: template.label,
|
|
168
|
+
from: params.sender,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Immutable data set once at creation, never altered.
|
|
173
|
+
const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
|
|
174
|
+
|
|
175
|
+
// Attach to the outbox under {shareId} in the same transaction so the held-resource rule
|
|
176
|
+
// keeps the ephemeral envelope alive.
|
|
177
|
+
tx.createField(field(outboxRid, data.shareId), "Dynamic", envelope);
|
|
178
|
+
|
|
179
|
+
return { envelope, data };
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
/**
|
|
131
183
|
* Records a response onto the envelope as a dynamic `acceptance/{login}` field: the acceptor
|
|
132
184
|
* writing their own decision (their writable grant permits it), or the donor transferring an
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PlTransaction, ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
|
|
2
|
+
import { field, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { StoredTemplateData, TemplateId } from "../middle_layer/template_list";
|
|
5
|
+
import {
|
|
6
|
+
TemplateCreatedTimestamp,
|
|
7
|
+
TemplateLabelKey,
|
|
8
|
+
TemplateResourceType,
|
|
9
|
+
} from "../middle_layer/template_list";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates one `UserTemplate` inside the given write transaction and attaches it to the
|
|
13
|
+
* templates list under a freshly minted uuid field.
|
|
14
|
+
*
|
|
15
|
+
* Create and attach are the same transaction on purpose: an ephemeral resource nothing
|
|
16
|
+
* holds is collectable, so the list field is what keeps the template alive.
|
|
17
|
+
*
|
|
18
|
+
* The document rides in the immutable `data` blob, set once here and never altered; only
|
|
19
|
+
* the label and the creation timestamp go to KV, and only the label is ever written again.
|
|
20
|
+
*
|
|
21
|
+
* @returns the new template resource; the caller reads its `globalId` after the commit.
|
|
22
|
+
*/
|
|
23
|
+
export function createTemplate(
|
|
24
|
+
tx: PlTransaction,
|
|
25
|
+
listRid: SignedResourceId,
|
|
26
|
+
label: string,
|
|
27
|
+
data: StoredTemplateData,
|
|
28
|
+
): ResourceRef {
|
|
29
|
+
const tpl = tx.createEphemeral(TemplateResourceType, JSON.stringify(data));
|
|
30
|
+
tx.lock(tpl);
|
|
31
|
+
tx.setKValue(tpl, TemplateLabelKey, JSON.stringify(label));
|
|
32
|
+
tx.setKValue(tpl, TemplateCreatedTimestamp, String(Date.now()));
|
|
33
|
+
tx.createField(field(listRid, randomUUID()), "Dynamic", tpl);
|
|
34
|
+
return tpl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Renames a stored template. Touches the label KV entry and nothing else, so the stored
|
|
38
|
+
* document stays byte-identical. */
|
|
39
|
+
export function renameTemplate(tx: PlTransaction, rid: SignedResourceId, label: string): void {
|
|
40
|
+
tx.setKValue(rid, TemplateLabelKey, JSON.stringify(label));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detaches a template from the templates list, which is what destroys it — the list field is
|
|
45
|
+
* the only thing holding the ephemeral resource.
|
|
46
|
+
*
|
|
47
|
+
* The field name is a uuid unrelated to the template id, so the field carrying the template
|
|
48
|
+
* is found by value, the same way a project is removed from the project list.
|
|
49
|
+
*/
|
|
50
|
+
export async function deleteTemplate(
|
|
51
|
+
tx: PlTransaction,
|
|
52
|
+
listRid: SignedResourceId,
|
|
53
|
+
id: TemplateId,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const fieldName = await findTemplateField(tx, listRid, id);
|
|
56
|
+
if (fieldName === undefined) throw new Error(`Template ${id} not found in template list.`);
|
|
57
|
+
tx.removeField(field(listRid, fieldName));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//
|
|
61
|
+
// Internals
|
|
62
|
+
//
|
|
63
|
+
|
|
64
|
+
async function findTemplateField(
|
|
65
|
+
tx: PlTransaction,
|
|
66
|
+
listRid: SignedResourceId,
|
|
67
|
+
id: TemplateId,
|
|
68
|
+
): Promise<string | undefined> {
|
|
69
|
+
const data = await tx.getResourceData(listRid, true);
|
|
70
|
+
for (const f of data.fields) {
|
|
71
|
+
if (isNullSignedResourceId(f.value)) continue;
|
|
72
|
+
if (resourceIdToString(f.value) === (id as string)) return f.name;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { PlClient } from "@milaboratories/pl-client";
|
|
4
|
+
import { TestHelpers } from "@milaboratories/pl-client";
|
|
5
|
+
import { MiddleLayer } from "../middle_layer/middle_layer";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A live {@link MiddleLayer} over a temporary root, closed again when the body returns.
|
|
9
|
+
*
|
|
10
|
+
* Needs a backend: the client comes from `PL_ADDRESS` (plus `PL_TEST_USER` /
|
|
11
|
+
* `PL_TEST_PASSWORD` where the server requires auth), so a test using this fails at
|
|
12
|
+
* connect time when none is configured.
|
|
13
|
+
*/
|
|
14
|
+
export async function withMl(
|
|
15
|
+
cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const workFolder = path.resolve(`work/${randomUUID()}`);
|
|
18
|
+
|
|
19
|
+
await TestHelpers.withTempRoot(async (pl: PlClient) => {
|
|
20
|
+
const ml = await MiddleLayer.init(pl, workFolder, {
|
|
21
|
+
defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
|
|
22
|
+
devBlockUpdateRecheckInterval: 300,
|
|
23
|
+
localSecret: MiddleLayer.generateLocalSecret(),
|
|
24
|
+
localProjections: [],
|
|
25
|
+
openFileDialogCallback: () => {
|
|
26
|
+
throw new Error("Not implemented.");
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 1);
|
|
30
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 2);
|
|
31
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 3);
|
|
32
|
+
try {
|
|
33
|
+
await cb(ml, workFolder);
|
|
34
|
+
} finally {
|
|
35
|
+
await ml.close();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|