@milaboratories/pl-middle-layer 1.68.2 → 1.69.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.
Files changed (68) hide show
  1. package/dist/index.cjs +10 -0
  2. package/dist/index.d.ts +6 -2
  3. package/dist/index.js +5 -2
  4. package/dist/middle_layer/build_stamp.cjs +1 -1
  5. package/dist/middle_layer/build_stamp.js +1 -1
  6. package/dist/middle_layer/index.cjs +2 -0
  7. package/dist/middle_layer/index.d.ts +2 -1
  8. package/dist/middle_layer/index.js +2 -1
  9. package/dist/middle_layer/middle_layer.cjs +333 -26
  10. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  11. package/dist/middle_layer/middle_layer.d.ts +125 -7
  12. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  13. package/dist/middle_layer/middle_layer.js +335 -28
  14. package/dist/middle_layer/middle_layer.js.map +1 -1
  15. package/dist/middle_layer/project_list.d.ts +1 -1
  16. package/dist/middle_layer/sharing_list.cjs +13 -5
  17. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  18. package/dist/middle_layer/sharing_list.d.ts +16 -2
  19. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  20. package/dist/middle_layer/sharing_list.js +14 -6
  21. package/dist/middle_layer/sharing_list.js.map +1 -1
  22. package/dist/middle_layer/template_list.cjs +72 -0
  23. package/dist/middle_layer/template_list.cjs.map +1 -0
  24. package/dist/middle_layer/template_list.d.ts +77 -0
  25. package/dist/middle_layer/template_list.d.ts.map +1 -0
  26. package/dist/middle_layer/template_list.js +64 -0
  27. package/dist/middle_layer/template_list.js.map +1 -0
  28. package/dist/model/index.cjs +8 -0
  29. package/dist/model/index.d.ts +5 -2
  30. package/dist/model/index.js +4 -2
  31. package/dist/model/sharing_model.cjs +59 -2
  32. package/dist/model/sharing_model.cjs.map +1 -1
  33. package/dist/model/sharing_model.d.ts +70 -7
  34. package/dist/model/sharing_model.d.ts.map +1 -1
  35. package/dist/model/sharing_model.js +57 -3
  36. package/dist/model/sharing_model.js.map +1 -1
  37. package/dist/model/template_serializer.d.ts +36 -0
  38. package/dist/model/template_serializer.d.ts.map +1 -1
  39. package/dist/model/template_share.cjs +42 -0
  40. package/dist/model/template_share.cjs.map +1 -0
  41. package/dist/model/template_share.d.ts +27 -0
  42. package/dist/model/template_share.d.ts.map +1 -0
  43. package/dist/model/template_share.js +42 -0
  44. package/dist/model/template_share.js.map +1 -0
  45. package/dist/mutator/project.cjs +2 -2
  46. package/dist/mutator/project.js +2 -2
  47. package/dist/mutator/sharing.cjs +40 -2
  48. package/dist/mutator/sharing.cjs.map +1 -1
  49. package/dist/mutator/sharing.js +40 -3
  50. package/dist/mutator/sharing.js.map +1 -1
  51. package/dist/mutator/template.cjs +54 -0
  52. package/dist/mutator/template.cjs.map +1 -0
  53. package/dist/mutator/template.js +52 -0
  54. package/dist/mutator/template.js.map +1 -0
  55. package/package.json +10 -10
  56. package/src/middle_layer/index.ts +9 -0
  57. package/src/middle_layer/middle_layer.ts +449 -36
  58. package/src/middle_layer/sharing_list.ts +37 -7
  59. package/src/middle_layer/template_list.ts +177 -0
  60. package/src/middle_layer/templates.test.ts +301 -0
  61. package/src/model/index.ts +14 -0
  62. package/src/model/sharing_model.test.ts +115 -1
  63. package/src/model/sharing_model.ts +134 -9
  64. package/src/model/template_share.test.ts +78 -0
  65. package/src/model/template_share.ts +52 -0
  66. package/src/mutator/sharing.ts +55 -3
  67. package/src/mutator/template.ts +75 -0
  68. 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 { canGrantToEveryone, canImpersonate } from "./sharing_model";
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 {@link EnvelopeData.projects}. */
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
- /** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */
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: 1;
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
- projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid
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 uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.
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")) as EnvelopeData;
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
+ }
@@ -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: 1,
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
+ }